mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-27 22:34:59 +00:00
xftp: implementation of XFTP client as web page (rfc, low level functions)
This commit is contained in:
+12
-1
@@ -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
@@ -497,6 +497,7 @@ test-suite simplexmq-test
|
||||
XFTPCLI
|
||||
XFTPClient
|
||||
XFTPServerTests
|
||||
XFTPWebTests
|
||||
Static
|
||||
Static.Embedded
|
||||
Paths_simplexmq
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user