xftp: implementation of XFTP client as web page (rfc, low level functions)

This commit is contained in:
Evgeny Poberezkin
2026-01-31 12:41:18 +00:00
parent 8fdc0703bc
commit 4a4f719bfb
12 changed files with 2220 additions and 1 deletions
+12 -1
View File
@@ -2,7 +2,13 @@
This file provides guidance on coding style and approaches and on building the code.
## Code Style and Formatting
## Code Security
When designing code and planning implementations:
- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious.
- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances.
## Code Style, Formatting and Approaches
The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`.
@@ -41,6 +47,11 @@ Some files that use CPP language extension cannot be formatted as a whole, so in
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring
- Aim to minimize the code changes - do what is minimally required to solve users' problems
**Document and code structure:**
- **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure.
- When adding new sections to documents, continue the existing numbering scheme.
- Minimize diff size - prefer small, targeted changes over reorganization.
**Code analysis and review:**
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug.
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -497,6 +497,7 @@ test-suite simplexmq-test
XFTPCLI
XFTPClient
XFTPServerTests
XFTPWebTests
Static
Static.Embedded
Paths_simplexmq
+2
View File
@@ -35,6 +35,7 @@ import Util
import XFTPAgent
import XFTPCLI
import XFTPServerTests (xftpServerTests)
import XFTPWebTests (xftpWebTests)
#if defined(dbPostgres)
import Fixtures
@@ -149,6 +150,7 @@ main = do
describe "XFTP file description" fileDescriptionTests
describe "XFTP CLI" xftpCLITests
describe "XFTP agent" xftpAgentTests
xftpWebTests
describe "XRCP" remoteControlTests
describe "Server CLIs" cliTests
+632
View File
@@ -0,0 +1,632 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Per-function tests for the xftp-web TypeScript XFTP client library.
-- Each test calls the Haskell function and the corresponding TypeScript function
-- via node, then asserts byte-identical output.
--
-- Prerequisites: cd xftp-web && npm install && npm run build
-- Run: cabal test --test-option=--match="/XFTP Web Client/"
module XFTPWebTests (xftpWebTests) where
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as LB
import Data.Int (Int64)
import Data.List (intercalate)
import qualified Data.List.NonEmpty as NE
import Data.Word (Word16, Word32)
import Crypto.Error (throwCryptoError)
import qualified Crypto.PubKey.Curve25519 as X25519
import qualified Crypto.PubKey.Ed25519 as Ed25519
import qualified Data.ByteArray as BA
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.Lazy as LC
import Simplex.Messaging.Encoding
import System.Directory (doesDirectoryExist)
import System.Exit (ExitCode (..))
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)
import Test.Hspec hiding (fit, it)
import Util
xftpWebDir :: FilePath
xftpWebDir = "xftp-web"
-- | Run an inline ES module script via node, return stdout as ByteString.
callNode :: String -> IO B.ByteString
callNode script = do
(_, Just hout, _, ph) <-
createProcess
(proc "node" ["--input-type=module", "-e", script])
{ std_out = CreatePipe,
cwd = Just xftpWebDir
}
out <- B.hGetContents hout
ec <- waitForProcess ph
ec `shouldBe` ExitSuccess
pure out
-- | Format a ByteString as a JS Uint8Array constructor.
jsUint8 :: B.ByteString -> String
jsUint8 bs = "new Uint8Array([" <> intercalate "," (map show (B.unpack bs)) <> "])"
-- Import helpers for inline scripts.
impEnc, impPad, impDig, impKey :: String
impEnc = "import * as E from './dist/protocol/encoding.js';"
impPad = "import * as P from './dist/crypto/padding.js';"
impDig =
"import sodium from 'libsodium-wrappers-sumo';"
<> "import * as D from './dist/crypto/digest.js';"
<> "await sodium.ready;"
impKey =
"import sodium from 'libsodium-wrappers-sumo';"
<> "import * as K from './dist/crypto/keys.js';"
<> "await sodium.ready;"
-- | Wrap expression in process.stdout.write(Buffer.from(...)).
jsOut :: String -> String
jsOut expr = "process.stdout.write(Buffer.from(" <> expr <> "));"
xftpWebTests :: Spec
xftpWebTests = describe "XFTP Web Client" $ do
distExists <- runIO $ doesDirectoryExist (xftpWebDir <> "/dist")
if distExists
then do
tsEncodingTests
tsPaddingTests
tsDigestTests
tsKeyTests
else
it "skipped (run 'cd xftp-web && npm install && npm run build' first)" $
pendingWith "TS project not compiled"
-- ── protocol/encoding ──────────────────────────────────────────────
tsEncodingTests :: Spec
tsEncodingTests = describe "protocol/encoding" $ do
describe "encode" $ do
it "encodeWord16" $ do
let val = 42 :: Word16
actual <- callNode $ impEnc <> jsOut ("E.encodeWord16(" <> show val <> ")")
actual `shouldBe` smpEncode val
it "encodeWord16 max" $ do
let val = 65535 :: Word16
actual <- callNode $ impEnc <> jsOut ("E.encodeWord16(" <> show val <> ")")
actual `shouldBe` smpEncode val
it "encodeWord32" $ do
let val = 100000 :: Word32
actual <- callNode $ impEnc <> jsOut ("E.encodeWord32(" <> show val <> ")")
actual `shouldBe` smpEncode val
it "encodeInt64" $ do
let val = 1234567890123456789 :: Int64
actual <- callNode $ impEnc <> jsOut ("E.encodeInt64(" <> show val <> "n)")
actual `shouldBe` smpEncode val
it "encodeInt64 negative" $ do
let val = -42 :: Int64
actual <- callNode $ impEnc <> jsOut ("E.encodeInt64(" <> show val <> "n)")
actual `shouldBe` smpEncode val
it "encodeInt64 zero" $ do
let val = 0 :: Int64
actual <- callNode $ impEnc <> jsOut ("E.encodeInt64(" <> show val <> "n)")
actual `shouldBe` smpEncode val
it "encodeBytes" $ do
let val = "hello" :: B.ByteString
actual <- callNode $ impEnc <> jsOut ("E.encodeBytes(" <> jsUint8 val <> ")")
actual `shouldBe` smpEncode val
it "encodeBytes empty" $ do
let val = "" :: B.ByteString
actual <- callNode $ impEnc <> jsOut ("E.encodeBytes(" <> jsUint8 val <> ")")
actual `shouldBe` smpEncode val
it "encodeLarge" $ do
let val = "test data for large encoding" :: B.ByteString
actual <- callNode $ impEnc <> jsOut ("E.encodeLarge(" <> jsUint8 val <> ")")
actual `shouldBe` smpEncode (Large val)
it "encodeTail" $ do
let val = "raw tail bytes" :: B.ByteString
actual <- callNode $ impEnc <> jsOut ("E.encodeTail(" <> jsUint8 val <> ")")
actual `shouldBe` smpEncode (Tail val)
it "encodeBool True" $ do
actual <- callNode $ impEnc <> jsOut "E.encodeBool(true)"
actual `shouldBe` smpEncode True
it "encodeBool False" $ do
actual <- callNode $ impEnc <> jsOut "E.encodeBool(false)"
actual `shouldBe` smpEncode False
it "encodeString" $ do
let val = "hello" :: String
actual <- callNode $ impEnc <> jsOut "E.encodeString('hello')"
actual `shouldBe` smpEncode val
it "encodeMaybe Nothing" $ do
actual <- callNode $ impEnc <> jsOut "E.encodeMaybe(E.encodeBytes, null)"
actual `shouldBe` smpEncode (Nothing :: Maybe B.ByteString)
it "encodeMaybe Just" $ do
let val = "hello" :: B.ByteString
actual <- callNode $ impEnc <> jsOut ("E.encodeMaybe(E.encodeBytes, " <> jsUint8 val <> ")")
actual `shouldBe` smpEncode (Just val)
it "encodeList" $ do
let vals = ["ab", "cd", "ef"] :: [B.ByteString]
actual <-
callNode $
impEnc
<> "const xs = ["
<> intercalate "," (map jsUint8 vals)
<> "];"
<> jsOut "E.encodeList(E.encodeBytes, xs)"
actual `shouldBe` smpEncodeList vals
it "encodeList empty" $ do
let vals = [] :: [B.ByteString]
actual <-
callNode $
impEnc <> jsOut "E.encodeList(E.encodeBytes, [])"
actual `shouldBe` smpEncodeList vals
it "encodeNonEmpty" $ do
let vals = ["ab", "cd"] :: [B.ByteString]
actual <-
callNode $
impEnc
<> "const xs = ["
<> intercalate "," (map jsUint8 vals)
<> "];"
<> jsOut "E.encodeNonEmpty(E.encodeBytes, xs)"
actual `shouldBe` smpEncode (NE.fromList vals)
describe "decode round-trips" $ do
it "decodeWord16" $ do
let encoded = smpEncode (42 :: Word16)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeWord16(E.decodeWord16(d))"
actual `shouldBe` encoded
it "decodeWord32" $ do
let encoded = smpEncode (100000 :: Word32)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeWord32(E.decodeWord32(d))"
actual `shouldBe` encoded
it "decodeInt64" $ do
let encoded = smpEncode (1234567890123456789 :: Int64)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeInt64(E.decodeInt64(d))"
actual `shouldBe` encoded
it "decodeInt64 negative" $ do
let encoded = smpEncode (-42 :: Int64)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeInt64(E.decodeInt64(d))"
actual `shouldBe` encoded
it "decodeBytes" $ do
let encoded = smpEncode ("hello" :: B.ByteString)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeBytes(E.decodeBytes(d))"
actual `shouldBe` encoded
it "decodeLarge" $ do
let encoded = smpEncode (Large "large data")
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeLarge(E.decodeLarge(d))"
actual `shouldBe` encoded
it "decodeBool" $ do
let encoded = smpEncode True
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeBool(E.decodeBool(d))"
actual `shouldBe` encoded
it "decodeString" $ do
let encoded = smpEncode ("hello" :: String)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeString(E.decodeString(d))"
actual `shouldBe` encoded
it "decodeMaybe Just" $ do
let encoded = smpEncode (Just ("hello" :: B.ByteString))
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeMaybe(E.encodeBytes, E.decodeMaybe(E.decodeBytes, d))"
actual `shouldBe` encoded
it "decodeMaybe Nothing" $ do
let encoded = smpEncode (Nothing :: Maybe B.ByteString)
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeMaybe(E.encodeBytes, E.decodeMaybe(E.decodeBytes, d))"
actual `shouldBe` encoded
it "decodeList" $ do
let encoded = smpEncodeList (["ab", "cd", "ef"] :: [B.ByteString])
actual <-
callNode $
impEnc
<> "const d = new E.Decoder("
<> jsUint8 encoded
<> ");"
<> jsOut "E.encodeList(E.encodeBytes, E.decodeList(E.decodeBytes, d))"
actual `shouldBe` encoded
-- ── crypto/padding ─────────────────────────────────────────────────
tsPaddingTests :: Spec
tsPaddingTests = describe "crypto/padding" $ do
it "pad" $ do
let msg = "hello" :: B.ByteString
paddedLen = 256 :: Int
expected = either (error . show) id $ C.pad msg paddedLen
actual <- callNode $ impPad <> jsOut ("P.pad(" <> jsUint8 msg <> ", " <> show paddedLen <> ")")
actual `shouldBe` expected
it "pad minimal" $ do
let msg = "ab" :: B.ByteString
paddedLen = 16 :: Int
expected = either (error . show) id $ C.pad msg paddedLen
actual <- callNode $ impPad <> jsOut ("P.pad(" <> jsUint8 msg <> ", " <> show paddedLen <> ")")
actual `shouldBe` expected
it "Haskell pad -> TS unPad" $ do
let msg = "cross-language test" :: B.ByteString
paddedLen = 128 :: Int
padded = either (error . show) id $ C.pad msg paddedLen
actual <- callNode $ impPad <> jsOut ("P.unPad(" <> jsUint8 padded <> ")")
actual `shouldBe` msg
it "TS pad -> Haskell unPad" $ do
let msg = "ts to haskell" :: B.ByteString
paddedLen = 64 :: Int
tsPadded <- callNode $ impPad <> jsOut ("P.pad(" <> jsUint8 msg <> ", " <> show paddedLen <> ")")
let actual = either (error . show) id $ C.unPad tsPadded
actual `shouldBe` msg
it "padLazy" $ do
let msg = "hello" :: B.ByteString
msgLen = fromIntegral (B.length msg) :: Int64
paddedLen = 64 :: Int64
expected = either (error . show) id $ LC.pad (LB.fromStrict msg) msgLen paddedLen
actual <-
callNode $
impPad <> jsOut ("P.padLazy(" <> jsUint8 msg <> ", " <> show msgLen <> "n, " <> show paddedLen <> "n)")
actual `shouldBe` LB.toStrict expected
it "Haskell padLazy -> TS unPadLazy" $ do
let msg = "cross-language lazy" :: B.ByteString
msgLen = fromIntegral (B.length msg) :: Int64
paddedLen = 64 :: Int64
padded = either (error . show) id $ LC.pad (LB.fromStrict msg) msgLen paddedLen
actual <- callNode $ impPad <> jsOut ("P.unPadLazy(" <> jsUint8 (LB.toStrict padded) <> ")")
actual `shouldBe` msg
it "TS padLazy -> Haskell unPadLazy" $ do
let msg = "ts to haskell lazy" :: B.ByteString
msgLen = fromIntegral (B.length msg) :: Int64
paddedLen = 128 :: Int64
tsPadded <-
callNode $
impPad <> jsOut ("P.padLazy(" <> jsUint8 msg <> ", " <> show msgLen <> "n, " <> show paddedLen <> "n)")
let actual = either (error . show) id $ LC.unPad (LB.fromStrict tsPadded)
actual `shouldBe` LB.fromStrict msg
it "splitLen" $ do
let msg = "test content" :: B.ByteString
msgLen = fromIntegral (B.length msg) :: Int64
paddedLen = 64 :: Int64
padded = either (error . show) id $ LC.pad (LB.fromStrict msg) msgLen paddedLen
actual <-
callNode $
impEnc
<> impPad
<> "const r = P.splitLen("
<> jsUint8 (LB.toStrict padded)
<> ");"
<> "const len = E.encodeInt64(r.len);"
<> jsOut "E.concatBytes(len, r.content)"
let (expectedLen, expectedContent) = either (error . show) id $ LC.splitLen padded
expectedBytes = smpEncode expectedLen <> LB.toStrict expectedContent
actual `shouldBe` expectedBytes
-- ── crypto/digest ──────────────────────────────────────────────────
tsDigestTests :: Spec
tsDigestTests = describe "crypto/digest" $ do
it "sha256" $ do
let input = "hello world" :: B.ByteString
actual <- callNode $ impDig <> jsOut ("D.sha256(" <> jsUint8 input <> ")")
actual `shouldBe` C.sha256Hash input
it "sha256 empty" $ do
let input = "" :: B.ByteString
actual <- callNode $ impDig <> jsOut ("D.sha256(" <> jsUint8 input <> ")")
actual `shouldBe` C.sha256Hash input
it "sha512" $ do
let input = "hello world" :: B.ByteString
actual <- callNode $ impDig <> jsOut ("D.sha512(" <> jsUint8 input <> ")")
actual `shouldBe` C.sha512Hash input
it "sha512 empty" $ do
let input = "" :: B.ByteString
actual <- callNode $ impDig <> jsOut ("D.sha512(" <> jsUint8 input <> ")")
actual `shouldBe` C.sha512Hash input
it "sha256 binary" $ do
let input = B.pack [0, 1, 2, 255, 254, 128]
actual <- callNode $ impDig <> jsOut ("D.sha256(" <> jsUint8 input <> ")")
actual `shouldBe` C.sha256Hash input
-- ── crypto/keys ──────────────────────────────────────────────────
tsKeyTests :: Spec
tsKeyTests = describe "crypto/keys" $ do
describe "DER encoding" $ do
it "encodePubKeyEd25519" $ do
let rawPub = B.pack [1 .. 32]
derPrefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]
expectedDer = derPrefix <> rawPub
actual <- callNode $ impKey <> jsOut ("K.encodePubKeyEd25519(" <> jsUint8 rawPub <> ")")
actual `shouldBe` expectedDer
it "decodePubKeyEd25519" $ do
let rawPub = B.pack [1 .. 32]
derPrefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]
der = derPrefix <> rawPub
actual <- callNode $ impKey <> jsOut ("K.decodePubKeyEd25519(" <> jsUint8 der <> ")")
actual `shouldBe` rawPub
it "encodePubKeyX25519" $ do
let rawPub = B.pack [1 .. 32]
derPrefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
expectedDer = derPrefix <> rawPub
actual <- callNode $ impKey <> jsOut ("K.encodePubKeyX25519(" <> jsUint8 rawPub <> ")")
actual `shouldBe` expectedDer
it "encodePrivKeyEd25519" $ do
let seed = B.pack [1 .. 32]
derPrefix = B.pack [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]
expectedDer = derPrefix <> seed
actual <-
callNode $
impKey
<> "const kp = K.ed25519KeyPairFromSeed("
<> jsUint8 seed
<> ");"
<> jsOut "K.encodePrivKeyEd25519(kp.privateKey)"
actual `shouldBe` expectedDer
it "encodePrivKeyX25519" $ do
let rawPriv = B.pack [1 .. 32]
derPrefix = B.pack [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20]
expectedDer = derPrefix <> rawPriv
actual <- callNode $ impKey <> jsOut ("K.encodePrivKeyX25519(" <> jsUint8 rawPriv <> ")")
actual `shouldBe` expectedDer
it "DER round-trip Ed25519 pubkey" $ do
actual <-
callNode $
impKey
<> "const kp = K.generateEd25519KeyPair();"
<> "const der = K.encodePubKeyEd25519(kp.publicKey);"
<> "const decoded = K.decodePubKeyEd25519(der);"
<> "const match = decoded.length === kp.publicKey.length && decoded.every((b,i) => b === kp.publicKey[i]);"
<> jsOut "new Uint8Array([match ? 1 : 0])"
actual `shouldBe` B.pack [1]
it "encodePubKeyEd25519 matches Haskell" $ do
let seed = B.pack [1 .. 32]
sk = throwCryptoError $ Ed25519.secretKey seed
pk = Ed25519.toPublic sk
rawPub = BA.convert pk :: B.ByteString
haskellDer = C.encodePubKey (C.PublicKeyEd25519 pk)
tsDer <- callNode $ impKey <> jsOut ("K.encodePubKeyEd25519(" <> jsUint8 rawPub <> ")")
tsDer `shouldBe` haskellDer
it "encodePubKeyX25519 matches Haskell" $ do
let rawPriv = B.pack [1 .. 32]
sk = throwCryptoError $ X25519.secretKey rawPriv
pk = X25519.toPublic sk
rawPub = BA.convert pk :: B.ByteString
haskellDer = C.encodePubKey (C.PublicKeyX25519 pk)
tsDer <- callNode $ impKey <> jsOut ("K.encodePubKeyX25519(" <> jsUint8 rawPub <> ")")
tsDer `shouldBe` haskellDer
it "encodePrivKeyEd25519 matches Haskell" $ do
let seed = B.pack [1 .. 32]
sk = throwCryptoError $ Ed25519.secretKey seed
haskellDer = C.encodePrivKey (C.PrivateKeyEd25519 sk)
tsDer <-
callNode $
impKey
<> "const kp = K.ed25519KeyPairFromSeed("
<> jsUint8 seed
<> ");"
<> jsOut "K.encodePrivKeyEd25519(kp.privateKey)"
tsDer `shouldBe` haskellDer
it "encodePrivKeyX25519 matches Haskell" $ do
let rawPriv = B.pack [1 .. 32]
sk = throwCryptoError $ X25519.secretKey rawPriv
haskellDer = C.encodePrivKey (C.PrivateKeyX25519 sk)
tsDer <- callNode $ impKey <> jsOut ("K.encodePrivKeyX25519(" <> jsUint8 rawPriv <> ")")
tsDer `shouldBe` haskellDer
describe "Ed25519 sign/verify" $ do
it "sign determinism" $ do
let seed = B.pack [1 .. 32]
sk = throwCryptoError $ Ed25519.secretKey seed
pk = Ed25519.toPublic sk
msg = "deterministic test" :: B.ByteString
sig = Ed25519.sign sk pk msg
rawSig = BA.convert sig :: B.ByteString
actual <-
callNode $
impKey
<> "const kp = K.ed25519KeyPairFromSeed("
<> jsUint8 seed
<> ");"
<> jsOut ("K.sign(kp.privateKey, " <> jsUint8 msg <> ")")
actual `shouldBe` rawSig
it "Haskell sign -> TS verify" $ do
let seed = B.pack [1 .. 32]
sk = throwCryptoError $ Ed25519.secretKey seed
pk = Ed25519.toPublic sk
msg = "cross-language sign test" :: B.ByteString
sig = Ed25519.sign sk pk msg
rawPub = BA.convert pk :: B.ByteString
rawSig = BA.convert sig :: B.ByteString
actual <-
callNode $
impKey
<> "const ok = K.verify("
<> jsUint8 rawPub
<> ", "
<> jsUint8 rawSig
<> ", "
<> jsUint8 msg
<> ");"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
actual `shouldBe` B.pack [1]
it "TS sign -> Haskell verify" $ do
let seed = B.pack [1 .. 32]
sk = throwCryptoError $ Ed25519.secretKey seed
pk = Ed25519.toPublic sk
msg = "ts-to-haskell sign" :: B.ByteString
rawSig <-
callNode $
impKey
<> "const kp = K.ed25519KeyPairFromSeed("
<> jsUint8 seed
<> ");"
<> jsOut ("K.sign(kp.privateKey, " <> jsUint8 msg <> ")")
let sig = throwCryptoError $ Ed25519.signature rawSig
Ed25519.verify pk msg sig `shouldBe` True
it "verify rejects wrong message" $ do
let seed = B.pack [1 .. 32]
sk = throwCryptoError $ Ed25519.secretKey seed
pk = Ed25519.toPublic sk
msg = "original message" :: B.ByteString
wrongMsg = "wrong message" :: B.ByteString
sig = Ed25519.sign sk pk msg
rawPub = BA.convert pk :: B.ByteString
rawSig = BA.convert sig :: B.ByteString
actual <-
callNode $
impKey
<> "const ok = K.verify("
<> jsUint8 rawPub
<> ", "
<> jsUint8 rawSig
<> ", "
<> jsUint8 wrongMsg
<> ");"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
actual `shouldBe` B.pack [0]
describe "X25519 DH" $ do
it "DH cross-language" $ do
let seed1 = B.pack [1 .. 32]
seed2 = B.pack [33 .. 64]
sk1 = throwCryptoError $ X25519.secretKey seed1
sk2 = throwCryptoError $ X25519.secretKey seed2
pk2 = X25519.toPublic sk2
dhHs = X25519.dh pk2 sk1
rawPk2 = BA.convert pk2 :: B.ByteString
rawDh = BA.convert dhHs :: B.ByteString
actual <-
callNode $
impKey <> jsOut ("K.dh(" <> jsUint8 rawPk2 <> ", " <> jsUint8 seed1 <> ")")
actual `shouldBe` rawDh
it "DH commutativity" $ do
let seed1 = B.pack [1 .. 32]
seed2 = B.pack [33 .. 64]
sk1 = throwCryptoError $ X25519.secretKey seed1
pk1 = X25519.toPublic sk1
sk2 = throwCryptoError $ X25519.secretKey seed2
pk2 = X25519.toPublic sk2
rawPk1 = BA.convert pk1 :: B.ByteString
rawPk2 = BA.convert pk2 :: B.ByteString
dh1 <-
callNode $
impKey <> jsOut ("K.dh(" <> jsUint8 rawPk2 <> ", " <> jsUint8 seed1 <> ")")
dh2 <-
callNode $
impKey <> jsOut ("K.dh(" <> jsUint8 rawPk1 <> ", " <> jsUint8 seed2 <> ")")
dh1 `shouldBe` dh2
describe "keyHash" $ do
it "keyHash matches Haskell sha256Hash of DER" $ do
let rawPub = B.pack [1 .. 32]
derPrefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]
der = derPrefix <> rawPub
expectedHash = C.sha256Hash der
actual <-
callNode $
impKey
<> "const der = K.encodePubKeyEd25519("
<> jsUint8 rawPub
<> ");"
<> jsOut "K.keyHash(der)"
actual `shouldBe` expectedHash
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+21
View File
@@ -0,0 +1,21 @@
{
"name": "xftp-web",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs",
"build": "tsc",
"test": "node --experimental-vm-modules node_modules/.bin/jest"
},
"devDependencies": {
"@types/libsodium-wrappers-sumo": "^0.7.8",
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
},
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.13"
}
}
+13
View File
@@ -0,0 +1,13 @@
// Cryptographic hash functions matching Simplex.Messaging.Crypto (sha256Hash, sha512Hash).
import sodium from "libsodium-wrappers-sumo"
// SHA-256 digest (32 bytes) — Crypto.hs:1006
export function sha256(data: Uint8Array): Uint8Array {
return sodium.crypto_hash_sha256(data)
}
// SHA-512 digest (64 bytes) — Crypto.hs:1011
export function sha512(data: Uint8Array): Uint8Array {
return sodium.crypto_hash_sha512(data)
}
+142
View File
@@ -0,0 +1,142 @@
// Key generation, signing, DH — Simplex.Messaging.Crypto (Ed25519/X25519 functions).
import sodium from "libsodium-wrappers-sumo"
import {sha256} from "./digest.js"
import {concatBytes} from "../protocol/encoding.js"
// -- Ed25519 key generation (Crypto.hs:726 generateAuthKeyPair)
export interface Ed25519KeyPair {
publicKey: Uint8Array // 32 bytes raw
privateKey: Uint8Array // 64 bytes (libsodium: seed || pubkey)
}
export function generateEd25519KeyPair(): Ed25519KeyPair {
const kp = sodium.crypto_sign_keypair()
return {publicKey: kp.publicKey, privateKey: kp.privateKey}
}
// Generate from known 32-byte seed (deterministic, for testing/interop).
export function ed25519KeyPairFromSeed(seed: Uint8Array): Ed25519KeyPair {
const kp = sodium.crypto_sign_seed_keypair(seed)
return {publicKey: kp.publicKey, privateKey: kp.privateKey}
}
// -- X25519 key generation (Crypto.hs via generateKeyPair)
export interface X25519KeyPair {
publicKey: Uint8Array // 32 bytes
privateKey: Uint8Array // 32 bytes
}
export function generateX25519KeyPair(): X25519KeyPair {
const kp = sodium.crypto_box_keypair()
return {publicKey: kp.publicKey, privateKey: kp.privateKey}
}
// Derive X25519 keypair from raw 32-byte private key.
export function x25519KeyPairFromPrivate(privateKey: Uint8Array): X25519KeyPair {
const publicKey = sodium.crypto_scalarmult_base(privateKey)
return {publicKey, privateKey}
}
// -- Ed25519 signing (Crypto.hs:1175 sign')
export function sign(privateKey: Uint8Array, msg: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(msg, privateKey)
}
// -- Ed25519 verification (Crypto.hs:1270 verify')
export function verify(publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean {
try {
return sodium.crypto_sign_verify_detached(sig, msg, publicKey)
} catch {
return false
}
}
// -- X25519 Diffie-Hellman (Crypto.hs:1280 dh')
export function dh(publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array {
return sodium.crypto_scalarmult(privateKey, publicKey)
}
// -- DER encoding for Ed25519 public keys (RFC 8410, SubjectPublicKeyInfo)
// SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING { 0x00 <32 bytes> } }
const ED25519_PUBKEY_DER_PREFIX = new Uint8Array([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
const X25519_PUBKEY_DER_PREFIX = new Uint8Array([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00,
])
export function encodePubKeyEd25519(rawPubKey: Uint8Array): Uint8Array {
return concatBytes(ED25519_PUBKEY_DER_PREFIX, rawPubKey)
}
export function decodePubKeyEd25519(der: Uint8Array): Uint8Array {
if (der.length !== 44) throw new Error("decodePubKeyEd25519: invalid length")
for (let i = 0; i < ED25519_PUBKEY_DER_PREFIX.length; i++) {
if (der[i] !== ED25519_PUBKEY_DER_PREFIX[i]) throw new Error("decodePubKeyEd25519: invalid DER prefix")
}
return der.subarray(12)
}
export function encodePubKeyX25519(rawPubKey: Uint8Array): Uint8Array {
return concatBytes(X25519_PUBKEY_DER_PREFIX, rawPubKey)
}
export function decodePubKeyX25519(der: Uint8Array): Uint8Array {
if (der.length !== 44) throw new Error("decodePubKeyX25519: invalid length")
for (let i = 0; i < X25519_PUBKEY_DER_PREFIX.length; i++) {
if (der[i] !== X25519_PUBKEY_DER_PREFIX[i]) throw new Error("decodePubKeyX25519: invalid DER prefix")
}
return der.subarray(12)
}
// -- DER encoding for private keys (PKCS8 OneAsymmetricKey, RFC 8410)
// SEQUENCE { INTEGER 0, SEQUENCE { OID }, OCTET STRING { OCTET STRING { <32 bytes> } } }
const ED25519_PRIVKEY_DER_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
const X25519_PRIVKEY_DER_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20,
])
export function encodePrivKeyEd25519(privateKey: Uint8Array): Uint8Array {
// privateKey is 64 bytes (libsodium: seed || pubkey), seed is first 32 bytes
const seed = privateKey.subarray(0, 32)
return concatBytes(ED25519_PRIVKEY_DER_PREFIX, seed)
}
export function decodePrivKeyEd25519(der: Uint8Array): Uint8Array {
if (der.length !== 48) throw new Error("decodePrivKeyEd25519: invalid length")
for (let i = 0; i < ED25519_PRIVKEY_DER_PREFIX.length; i++) {
if (der[i] !== ED25519_PRIVKEY_DER_PREFIX[i]) throw new Error("decodePrivKeyEd25519: invalid DER prefix")
}
// Returns 32-byte seed; call ed25519KeyPairFromSeed to get full keypair.
return der.subarray(16)
}
export function encodePrivKeyX25519(privateKey: Uint8Array): Uint8Array {
return concatBytes(X25519_PRIVKEY_DER_PREFIX, privateKey)
}
export function decodePrivKeyX25519(der: Uint8Array): Uint8Array {
if (der.length !== 48) throw new Error("decodePrivKeyX25519: invalid length")
for (let i = 0; i < X25519_PRIVKEY_DER_PREFIX.length; i++) {
if (der[i] !== X25519_PRIVKEY_DER_PREFIX[i]) throw new Error("decodePrivKeyX25519: invalid DER prefix")
}
return der.subarray(16)
}
// -- KeyHash: SHA-256 of DER-encoded public key (Crypto.hs:981)
export function keyHash(derPubKey: Uint8Array): Uint8Array {
return sha256(derPubKey)
}
+61
View File
@@ -0,0 +1,61 @@
// Block padding matching Simplex.Messaging.Crypto (strict) and Simplex.Messaging.Crypto.Lazy.
// Strict: 2-byte BE length prefix + message + '#' fill.
// Lazy: 8-byte Int64 length prefix + message + '#' fill.
import {encodeWord16, decodeWord16, encodeInt64, decodeInt64, Decoder} from "../protocol/encoding.js"
const HASH = 0x23 // '#'
// -- Strict pad/unPad (protocol messages) — Crypto.hs:1077
export function pad(msg: Uint8Array, paddedLen: number): Uint8Array {
const len = msg.length
if (len > 65535) throw new Error("pad: message too large for Word16 length")
const fillLen = paddedLen - len - 2
if (fillLen < 0) throw new Error("pad: message exceeds padded size")
const result = new Uint8Array(paddedLen)
const lenBytes = encodeWord16(len)
result.set(lenBytes, 0)
result.set(msg, 2)
result.fill(HASH, 2 + len)
return result
}
export function unPad(padded: Uint8Array): Uint8Array {
if (padded.length < 2) throw new Error("unPad: input too short")
const d = new Decoder(padded)
const len = decodeWord16(d)
if (padded.length - 2 < len) throw new Error("unPad: invalid length")
return padded.subarray(2, 2 + len)
}
// -- Lazy pad/unPad (file encryption) — Crypto/Lazy.hs:70
export function padLazy(msg: Uint8Array, msgLen: bigint, padLen: bigint): Uint8Array {
const fillLen = padLen - msgLen - 8n
if (fillLen < 0n) throw new Error("padLazy: message exceeds padded size")
const totalLen = Number(padLen)
const result = new Uint8Array(totalLen)
const lenBytes = encodeInt64(msgLen)
result.set(lenBytes, 0)
result.set(msg.subarray(0, Number(msgLen)), 8)
result.fill(HASH, 8 + Number(msgLen))
return result
}
export function unPadLazy(padded: Uint8Array): Uint8Array {
return splitLen(padded).content
}
// splitLen: extract 8-byte Int64 length and content — Crypto/Lazy.hs:96
// Does not fail if content is shorter than declared length (for chunked decryption).
export function splitLen(data: Uint8Array): {len: bigint; content: Uint8Array} {
if (data.length < 8) throw new Error("splitLen: input too short")
const d = new Decoder(data)
const len = decodeInt64(d)
if (len < 0n) throw new Error("splitLen: negative length")
const numLen = Number(len)
const available = data.length - 8
const takeLen = Math.min(numLen, available)
return {len, content: data.subarray(8, 8 + takeLen)}
}
+230
View File
@@ -0,0 +1,230 @@
// Binary encoding/decoding matching Haskell Simplex.Messaging.Encoding module.
// All multi-byte integers are big-endian (network byte order).
// -- Decoder: sequential parser over a Uint8Array (equivalent to Attoparsec parser)
export class Decoder {
readonly buf: Uint8Array
private readonly view: DataView
private pos: number
constructor(buf: Uint8Array) {
this.buf = buf
this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
this.pos = 0
}
take(n: number): Uint8Array {
if (this.pos + n > this.buf.length) throw new Error("Decoder: unexpected end of input")
const slice = this.buf.subarray(this.pos, this.pos + n)
this.pos += n
return slice
}
takeAll(): Uint8Array {
const slice = this.buf.subarray(this.pos)
this.pos = this.buf.length
return slice
}
anyByte(): number {
if (this.pos >= this.buf.length) throw new Error("Decoder: unexpected end of input")
return this.buf[this.pos++]
}
remaining(): number {
return this.buf.length - this.pos
}
offset(): number {
return this.pos
}
}
// -- Utility
export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
let totalLen = 0
for (const a of arrays) totalLen += a.length
const result = new Uint8Array(totalLen)
let offset = 0
for (const a of arrays) {
result.set(a, offset)
offset += a.length
}
return result
}
// -- Word16: 2-byte big-endian (Encoding.hs:70)
export function encodeWord16(n: number): Uint8Array {
const buf = new Uint8Array(2)
const view = new DataView(buf.buffer)
view.setUint16(0, n, false)
return buf
}
export function decodeWord16(d: Decoder): number {
const bytes = d.take(2)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return view.getUint16(0, false)
}
// -- Word32: 4-byte big-endian (Encoding.hs:76)
export function encodeWord32(n: number): Uint8Array {
const buf = new Uint8Array(4)
const view = new DataView(buf.buffer)
view.setUint32(0, n, false)
return buf
}
export function decodeWord32(d: Decoder): number {
const bytes = d.take(4)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return view.getUint32(0, false)
}
// -- Int64: two Word32s, high then low (Encoding.hs:82)
// Uses BigInt because JS numbers lose precision beyond 2^53.
export function encodeInt64(n: bigint): Uint8Array {
const high = Number((n >> 32n) & 0xFFFFFFFFn)
const low = Number(n & 0xFFFFFFFFn)
return concatBytes(encodeWord32(high), encodeWord32(low))
}
export function decodeInt64(d: Decoder): bigint {
const high = BigInt(decodeWord32(d))
const low = BigInt(decodeWord32(d))
const unsigned = (high << 32n) | low
// Convert to signed Int64: if bit 63 is set, value is negative
return unsigned >= 0x8000000000000000n ? unsigned - 0x10000000000000000n : unsigned
}
// -- ByteString: 1-byte length prefix + bytes (Encoding.hs:100)
// Max 255 bytes.
export function encodeBytes(bs: Uint8Array): Uint8Array {
if (bs.length > 255) throw new Error("encodeBytes: length exceeds 255")
const result = new Uint8Array(1 + bs.length)
result[0] = bs.length
result.set(bs, 1)
return result
}
export function decodeBytes(d: Decoder): Uint8Array {
const len = d.anyByte()
return d.take(len)
}
// -- Large: 2-byte big-endian length prefix + bytes (Encoding.hs:133)
// Max 65535 bytes.
export function encodeLarge(bs: Uint8Array): Uint8Array {
if (bs.length > 65535) throw new Error("encodeLarge: length exceeds 65535")
return concatBytes(encodeWord16(bs.length), bs)
}
export function decodeLarge(d: Decoder): Uint8Array {
const len = decodeWord16(d)
return d.take(len)
}
// -- Tail: raw bytes, no prefix (Encoding.hs:124)
export function encodeTail(bs: Uint8Array): Uint8Array {
return bs
}
export function decodeTail(d: Decoder): Uint8Array {
return d.takeAll()
}
// -- Bool: 'T' (0x54) or 'F' (0x46) (Encoding.hs:58)
const CHAR_T = 0x54
const CHAR_F = 0x46
export function encodeBool(b: boolean): Uint8Array {
return new Uint8Array([b ? CHAR_T : CHAR_F])
}
export function decodeBool(d: Decoder): boolean {
const byte = d.anyByte()
if (byte === CHAR_T) return true
if (byte === CHAR_F) return false
throw new Error("decodeBool: invalid tag " + byte)
}
// -- String: encode as ByteString via Latin-1 (Encoding.hs:159)
// Haskell's B.pack converts String (list of Char) to ByteString using Latin-1.
export function encodeString(s: string): Uint8Array {
const bytes = new Uint8Array(s.length)
for (let i = 0; i < s.length; i++) {
bytes[i] = s.charCodeAt(i) & 0xFF
}
return encodeBytes(bytes)
}
export function decodeString(d: Decoder): string {
const bs = decodeBytes(d)
let s = ""
for (let i = 0; i < bs.length; i++) {
s += String.fromCharCode(bs[i])
}
return s
}
// -- Maybe: '0' for Nothing, '1' + encoded value for Just (Encoding.hs:114)
const CHAR_0 = 0x30
const CHAR_1 = 0x31
export function encodeMaybe<T>(encode: (v: T) => Uint8Array, v: T | null): Uint8Array {
if (v === null) return new Uint8Array([CHAR_0])
return concatBytes(new Uint8Array([CHAR_1]), encode(v))
}
export function decodeMaybe<T>(decode: (d: Decoder) => T, d: Decoder): T | null {
const tag = d.anyByte()
if (tag === CHAR_0) return null
if (tag === CHAR_1) return decode(d)
throw new Error("decodeMaybe: invalid tag " + tag)
}
// -- NonEmpty: 1-byte length + encoded elements (Encoding.hs:165)
// Fails on empty list (matches Haskell behavior).
export function encodeNonEmpty<T>(encode: (v: T) => Uint8Array, xs: T[]): Uint8Array {
if (xs.length === 0) throw new Error("encodeNonEmpty: empty list")
if (xs.length > 255) throw new Error("encodeNonEmpty: length exceeds 255")
const parts: Uint8Array[] = [new Uint8Array([xs.length])]
for (const x of xs) parts.push(encode(x))
return concatBytes(...parts)
}
export function decodeNonEmpty<T>(decode: (d: Decoder) => T, d: Decoder): T[] {
const len = d.anyByte()
if (len === 0) throw new Error("decodeNonEmpty: empty list")
const result: T[] = []
for (let i = 0; i < len; i++) result.push(decode(d))
return result
}
// -- List encoding (smpEncodeList / smpListP, Encoding.hs:153)
export function encodeList<T>(encode: (v: T) => Uint8Array, xs: T[]): Uint8Array {
if (xs.length > 255) throw new Error("encodeList: length exceeds 255")
const parts: Uint8Array[] = [new Uint8Array([xs.length])]
for (const x of xs) parts.push(encode(x))
return concatBytes(...parts)
}
export function decodeList<T>(decode: (d: Decoder) => T, d: Decoder): T[] {
const len = d.anyByte()
const result: T[] = []
for (let i = 0; i < len; i++) result.push(decode(d))
return result
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}