mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-03-30 12:05:49 +00:00
Stale postgres schema leaked pending XFTP operations between cross-language tests, causing N-1 of N tests to fail.
3249 lines
128 KiB
Haskell
3249 lines
128 KiB
Haskell
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE PatternSynonyms #-}
|
|
{-# 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 Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
|
|
import Control.Monad (replicateM, when)
|
|
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 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 (Word8, Word16, Word32)
|
|
import System.Random (randomIO)
|
|
import Data.X509.Validation (Fingerprint (..))
|
|
import Simplex.FileTransfer.Client (prepareChunkSizes)
|
|
import Simplex.FileTransfer.Client.Main (decodeWebURI, encodeWebURI)
|
|
import Simplex.FileTransfer.Description (FileDescription (..), FileSize (..), ValidFileDescription, pattern ValidFileDescription)
|
|
import Simplex.FileTransfer.Protocol (FileParty (..), xftpBlockSize)
|
|
import Simplex.FileTransfer.Transport (XFTPClientHello (..))
|
|
import Simplex.FileTransfer.Types (FileHeader (..))
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import qualified Simplex.Messaging.Crypto.Lazy as LC
|
|
import Simplex.Messaging.Encoding
|
|
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
|
|
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
|
|
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive)
|
|
import System.Environment (getEnvironment)
|
|
import System.Exit (ExitCode (..))
|
|
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)
|
|
import Test.Hspec hiding (fit, it)
|
|
import Util
|
|
import Simplex.FileTransfer.Server.Env (XFTPServerConfig)
|
|
import XFTPClient (testXFTPServerConfigEd25519SNI, testXFTPServerConfigSNI, withXFTPServerCfg, xftpTestPort)
|
|
import AgentTests.FunctionalAPITests (rfGet, runRight, runRight_, sfGet, withAgent)
|
|
import Simplex.Messaging.Agent (AgentClient, xftpReceiveFile, xftpSendFile, xftpStartWorkers)
|
|
import Simplex.Messaging.Agent.Protocol (AEvent (..))
|
|
import SMPAgentClient (agentCfg, initAgentServers, testDB)
|
|
import XFTPCLI (recipientFiles, senderFiles, testBracket)
|
|
import qualified Simplex.Messaging.Crypto.File as CF
|
|
|
|
xftpWebDir :: FilePath
|
|
xftpWebDir = "xftp-web"
|
|
|
|
-- | Redirect console.log/warn to stderr so library debug output doesn't pollute stdout binary data.
|
|
redirectConsole :: String
|
|
redirectConsole = "console.log = console.warn = (...a) => process.stderr.write(a.map(String).join(' ') + '\\n');"
|
|
|
|
-- | Run an inline ES module script via node, return stdout as ByteString.
|
|
callNode :: String -> IO B.ByteString
|
|
callNode script = do
|
|
baseEnv <- getEnvironment
|
|
let nodeEnv = ("NODE_TLS_REJECT_UNAUTHORIZED", "0") : baseEnv
|
|
(_, Just hout, Just herr, ph) <-
|
|
createProcess
|
|
(proc "node" ["--input-type=module", "-e", redirectConsole <> script])
|
|
{ std_out = CreatePipe,
|
|
std_err = CreatePipe,
|
|
cwd = Just xftpWebDir,
|
|
env = Just nodeEnv
|
|
}
|
|
errVar <- newEmptyMVar
|
|
_ <- forkIO $ B.hGetContents herr >>= putMVar errVar
|
|
out <- B.hGetContents hout
|
|
err <- takeMVar errVar
|
|
ec <- waitForProcess ph
|
|
when (ec /= ExitSuccess) $
|
|
expectationFailure $
|
|
"node " <> show ec <> "\nstderr: " <> map (toEnum . fromIntegral) (B.unpack err)
|
|
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, impSb :: 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;"
|
|
impSb =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as S from './dist/crypto/secretbox.js';"
|
|
<> "await sodium.ready;"
|
|
impFile :: String
|
|
impFile =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as F from './dist/crypto/file.js';"
|
|
<> "await sodium.ready;"
|
|
impCmd :: String
|
|
impCmd =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as E from './dist/protocol/encoding.js';"
|
|
<> "import * as Cmd from './dist/protocol/commands.js';"
|
|
<> "await sodium.ready;"
|
|
impTx :: String
|
|
impTx =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as E from './dist/protocol/encoding.js';"
|
|
<> "import * as K from './dist/crypto/keys.js';"
|
|
<> "import * as Tx from './dist/protocol/transmission.js';"
|
|
<> "await sodium.ready;"
|
|
impHs :: String
|
|
impHs =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as E from './dist/protocol/encoding.js';"
|
|
<> "import * as K from './dist/crypto/keys.js';"
|
|
<> "import * as Hs from './dist/protocol/handshake.js';"
|
|
<> "await sodium.ready;"
|
|
impId :: String
|
|
impId =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as E from './dist/protocol/encoding.js';"
|
|
<> "import * as K from './dist/crypto/keys.js';"
|
|
<> "import * as Id from './dist/crypto/identity.js';"
|
|
<> "await sodium.ready;"
|
|
impDesc :: String
|
|
impDesc = "import * as Desc from './dist/protocol/description.js';"
|
|
impChk :: String
|
|
impChk =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as Desc from './dist/protocol/description.js';"
|
|
<> "import * as Chk from './dist/protocol/chunks.js';"
|
|
<> "await sodium.ready;"
|
|
impCli :: String
|
|
impCli =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as K from './dist/crypto/keys.js';"
|
|
<> "import * as Cli from './dist/protocol/client.js';"
|
|
<> "await sodium.ready;"
|
|
impDl :: String
|
|
impDl =
|
|
"import sodium from 'libsodium-wrappers-sumo';"
|
|
<> "import * as K from './dist/crypto/keys.js';"
|
|
<> "import * as F from './dist/crypto/file.js';"
|
|
<> "import * as Cli from './dist/protocol/client.js';"
|
|
<> "import * as Dl from './dist/download.js';"
|
|
<> "import * as Cmd from './dist/protocol/commands.js';"
|
|
<> "import * as Tx from './dist/protocol/transmission.js';"
|
|
<> "await sodium.ready;"
|
|
|
|
impAddr :: String
|
|
impAddr = "import * as Addr from './dist/protocol/address.js';"
|
|
|
|
-- | Wrap expression in process.stdout.write(Buffer.from(...)).
|
|
jsOut :: String -> String
|
|
jsOut expr = "process.stdout.write(Buffer.from(" <> expr <> "));"
|
|
|
|
xftpWebTests :: IO () -> Spec
|
|
xftpWebTests dbCleanup = do
|
|
distExists <- runIO $ doesDirectoryExist (xftpWebDir <> "/dist")
|
|
if distExists
|
|
then do
|
|
tsEncodingTests
|
|
tsPaddingTests
|
|
tsDigestTests
|
|
tsKeyTests
|
|
tsSecretboxTests
|
|
tsFileCryptoTests
|
|
tsCommandTests
|
|
tsTransmissionTests
|
|
tsHandshakeTests
|
|
tsIdentityTests
|
|
tsDescriptionTests
|
|
tsChunkTests
|
|
tsClientTests
|
|
tsDownloadTests
|
|
tsAddressTests
|
|
tsIntegrationTests dbCleanup
|
|
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
|
|
|
|
-- ── crypto/secretbox ──────────────────────────────────────────────
|
|
|
|
tsSecretboxTests :: Spec
|
|
tsSecretboxTests = describe "crypto/secretbox" $ do
|
|
let key32 = B.pack [1 .. 32]
|
|
nonce24 = B.pack [1 .. 24]
|
|
cbNonceVal = C.cbNonce nonce24
|
|
sbKeyVal = C.unsafeSbKey key32
|
|
|
|
describe "NaCl secretbox (tag prepended)" $ do
|
|
it "cbEncrypt matches Haskell sbEncrypt_" $ do
|
|
let msg = "hello NaCl secretbox" :: B.ByteString
|
|
paddedLen = 256 :: Int
|
|
hsResult = either (error . show) id $ C.sbEncrypt_ key32 cbNonceVal msg paddedLen
|
|
tsResult <-
|
|
callNode $
|
|
impSb <> jsOut ("S.cbEncrypt(" <> jsUint8 key32 <> "," <> jsUint8 nonce24 <> "," <> jsUint8 msg <> "," <> show paddedLen <> ")")
|
|
tsResult `shouldBe` hsResult
|
|
|
|
it "Haskell sbEncrypt_ -> TS cbDecrypt" $ do
|
|
let msg = "cross-language decrypt" :: B.ByteString
|
|
paddedLen = 128 :: Int
|
|
cipher = either (error . show) id $ C.sbEncrypt_ key32 cbNonceVal msg paddedLen
|
|
tsResult <-
|
|
callNode $
|
|
impSb <> jsOut ("S.cbDecrypt(" <> jsUint8 key32 <> "," <> jsUint8 nonce24 <> "," <> jsUint8 cipher <> ")")
|
|
tsResult `shouldBe` msg
|
|
|
|
it "TS cbEncrypt -> Haskell sbDecrypt_" $ do
|
|
let msg = "ts-to-haskell NaCl" :: B.ByteString
|
|
paddedLen = 64 :: Int
|
|
tsCipher <-
|
|
callNode $
|
|
impSb <> jsOut ("S.cbEncrypt(" <> jsUint8 key32 <> "," <> jsUint8 nonce24 <> "," <> jsUint8 msg <> "," <> show paddedLen <> ")")
|
|
let hsResult = either (error . show) id $ C.sbDecrypt_ key32 cbNonceVal tsCipher
|
|
hsResult `shouldBe` msg
|
|
|
|
describe "streaming tail-tag" $ do
|
|
it "sbEncryptTailTag matches Haskell" $ do
|
|
let msg = "hello streaming" :: B.ByteString
|
|
msgLen = fromIntegral (B.length msg) :: Int64
|
|
paddedLen = 64 :: Int64
|
|
hsResult =
|
|
either (error . show) id $
|
|
LC.sbEncryptTailTag sbKeyVal cbNonceVal (LB.fromStrict msg) msgLen paddedLen
|
|
tsResult <-
|
|
callNode $
|
|
impSb
|
|
<> jsOut
|
|
( "S.sbEncryptTailTag("
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 msg
|
|
<> ","
|
|
<> show msgLen
|
|
<> "n,"
|
|
<> show paddedLen
|
|
<> "n)"
|
|
)
|
|
tsResult `shouldBe` LB.toStrict hsResult
|
|
|
|
it "Haskell encrypt -> TS decrypt (tail tag)" $ do
|
|
let msg = "haskell-to-ts streaming" :: B.ByteString
|
|
msgLen = fromIntegral (B.length msg) :: Int64
|
|
paddedLen = 128 :: Int64
|
|
cipher =
|
|
either (error . show) id $
|
|
LC.sbEncryptTailTag sbKeyVal cbNonceVal (LB.fromStrict msg) msgLen paddedLen
|
|
tsResult <-
|
|
callNode $
|
|
impSb
|
|
<> "const r = S.sbDecryptTailTag("
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> show paddedLen
|
|
<> "n,"
|
|
<> jsUint8 (LB.toStrict cipher)
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([r.valid ? 1 : 0, ...r.content])"
|
|
let (validByte, content) = B.splitAt 1 tsResult
|
|
validByte `shouldBe` B.pack [1]
|
|
content `shouldBe` msg
|
|
|
|
it "TS encrypt -> Haskell decrypt (tail tag)" $ do
|
|
let msg = "ts-to-haskell streaming" :: B.ByteString
|
|
msgLen = fromIntegral (B.length msg) :: Int64
|
|
paddedLen = 64 :: Int64
|
|
tsCipher <-
|
|
callNode $
|
|
impSb
|
|
<> jsOut
|
|
( "S.sbEncryptTailTag("
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 msg
|
|
<> ","
|
|
<> show msgLen
|
|
<> "n,"
|
|
<> show paddedLen
|
|
<> "n)"
|
|
)
|
|
let (valid, plaintext) =
|
|
either (error . show) id $
|
|
LC.sbDecryptTailTag sbKeyVal cbNonceVal paddedLen (LB.fromStrict tsCipher)
|
|
valid `shouldBe` True
|
|
LB.toStrict plaintext `shouldBe` msg
|
|
|
|
it "tag tampering detection" $ do
|
|
let msg = "tamper test" :: B.ByteString
|
|
msgLen = fromIntegral (B.length msg) :: Int64
|
|
paddedLen = 64 :: Int64
|
|
tsResult <-
|
|
callNode $
|
|
impSb
|
|
<> "const enc = S.sbEncryptTailTag("
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 msg
|
|
<> ","
|
|
<> show msgLen
|
|
<> "n,"
|
|
<> show paddedLen
|
|
<> "n);"
|
|
<> "enc[enc.length - 1] ^= 1;"
|
|
<> "const r = S.sbDecryptTailTag("
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> show paddedLen
|
|
<> "n, enc);"
|
|
<> jsOut "new Uint8Array([r.valid ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
describe "internal consistency" $ do
|
|
it "streaming matches NaCl secretbox (TS-only)" $ do
|
|
let msg = "salsa20 validation" :: B.ByteString
|
|
msgLen = fromIntegral (B.length msg) :: Int64
|
|
paddedLen = 64 :: Int64
|
|
tsResult <-
|
|
callNode $
|
|
impPad
|
|
<> impSb
|
|
<> "const msg = "
|
|
<> jsUint8 msg
|
|
<> ";"
|
|
<> "const key = "
|
|
<> jsUint8 key32
|
|
<> ";"
|
|
<> "const nonce = "
|
|
<> jsUint8 nonce24
|
|
<> ";"
|
|
<> "const padded = P.padLazy(msg, "
|
|
<> show msgLen
|
|
<> "n, "
|
|
<> show paddedLen
|
|
<> "n);"
|
|
<> "const nacl = S.cryptoBox(key, nonce, padded);"
|
|
<> "const stream = S.sbEncryptTailTag(key, nonce, msg, "
|
|
<> show msgLen
|
|
<> "n, "
|
|
<> show paddedLen
|
|
<> "n);"
|
|
<> "const naclTag = nacl.subarray(0, 16);"
|
|
<> "const naclCipher = nacl.subarray(16);"
|
|
<> "const streamCipher = stream.subarray(0, "
|
|
<> show paddedLen
|
|
<> ");"
|
|
<> "const streamTag = stream.subarray("
|
|
<> show paddedLen
|
|
<> ");"
|
|
<> "const cipherMatch = naclCipher.length === streamCipher.length && naclCipher.every((b,i) => b === streamCipher[i]);"
|
|
<> "const tagMatch = naclTag.length === streamTag.length && naclTag.every((b,i) => b === streamTag[i]);"
|
|
<> jsOut "new Uint8Array([cipherMatch ? 1 : 0, tagMatch ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1, 1]
|
|
|
|
it "multi-chunk matches single-shot (TS-only)" $ do
|
|
let msg = B.pack [1 .. 200]
|
|
tsResult <-
|
|
callNode $
|
|
impSb
|
|
<> "const key = "
|
|
<> jsUint8 key32
|
|
<> ";"
|
|
<> "const nonce = "
|
|
<> jsUint8 nonce24
|
|
<> ";"
|
|
<> "const msg = "
|
|
<> jsUint8 msg
|
|
<> ";"
|
|
<> "const st1 = S.sbInit(key, nonce);"
|
|
<> "const c1 = S.sbEncryptChunk(st1, msg);"
|
|
<> "const t1 = S.sbAuth(st1);"
|
|
<> "const st2 = S.sbInit(key, nonce);"
|
|
<> "const parts = [msg.subarray(0,50), msg.subarray(50,100), msg.subarray(100,150), msg.subarray(150)];"
|
|
<> "const c2parts = parts.map(p => S.sbEncryptChunk(st2, p));"
|
|
<> "const c2 = new Uint8Array(200); let off = 0; c2parts.forEach(p => { c2.set(p, off); off += p.length; });"
|
|
<> "const t2 = S.sbAuth(st2);"
|
|
<> "const cipherMatch = c1.length === c2.length && c1.every((b,i) => b === c2[i]);"
|
|
<> "const tagMatch = t1.length === t2.length && t1.every((b,i) => b === t2[i]);"
|
|
<> jsOut "new Uint8Array([cipherMatch ? 1 : 0, tagMatch ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1, 1]
|
|
|
|
-- ── crypto/file ─────────────────────────────────────────────────
|
|
|
|
tsFileCryptoTests :: Spec
|
|
tsFileCryptoTests = describe "crypto/file" $ do
|
|
let key32 = B.pack [1 .. 32]
|
|
nonce24 = B.pack [1 .. 24]
|
|
cbNonceVal = C.cbNonce nonce24
|
|
sbKeyVal = C.unsafeSbKey key32
|
|
|
|
describe "FileHeader encoding" $ do
|
|
it "encodeFileHeader matches Haskell" $ do
|
|
let hdr = FileHeader "test.txt" Nothing
|
|
hsEncoded = smpEncode hdr
|
|
tsEncoded <- callNode $ impFile <> jsOut "F.encodeFileHeader({fileName: 'test.txt', fileExtra: null})"
|
|
tsEncoded `shouldBe` hsEncoded
|
|
|
|
it "encodeFileHeader with fileExtra" $ do
|
|
let hdr = FileHeader "document.pdf" (Just "v2")
|
|
hsEncoded = smpEncode hdr
|
|
tsEncoded <- callNode $ impFile <> jsOut "F.encodeFileHeader({fileName: 'document.pdf', fileExtra: 'v2'})"
|
|
tsEncoded `shouldBe` hsEncoded
|
|
|
|
it "Haskell encode -> TS parseFileHeader" $ do
|
|
let hdr = FileHeader "photo.jpg" (Just "extra")
|
|
encoded = smpEncode hdr
|
|
trailing = B.pack [10, 20, 30, 40, 50]
|
|
input = encoded <> trailing
|
|
tsResult <-
|
|
callNode $
|
|
impFile
|
|
<> "const r = F.parseFileHeader("
|
|
<> jsUint8 input
|
|
<> ");"
|
|
<> "const hdrBytes = F.encodeFileHeader(r.header);"
|
|
<> jsOut "new Uint8Array([...hdrBytes, ...r.rest])"
|
|
tsResult `shouldBe` input
|
|
|
|
describe "file encryption" $ do
|
|
it "encryptFile matches Haskell" $ do
|
|
let source = "Hello, this is test file content!" :: B.ByteString
|
|
hdr = FileHeader "test.txt" Nothing
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 256 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit sbKeyVal cbNonceVal
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
tag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
hsEncrypted = B.concat [hdrEnc, srcEnc, padEnc, tag]
|
|
tsEncrypted <-
|
|
callNode $
|
|
impFile
|
|
<> "const source = "
|
|
<> jsUint8 source
|
|
<> ";"
|
|
<> "const fileHdr = F.encodeFileHeader({fileName: 'test.txt', fileExtra: null});"
|
|
<> jsOut
|
|
( "F.encryptFile(source, fileHdr, "
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> show fileSize'
|
|
<> "n,"
|
|
<> show encSize
|
|
<> "n)"
|
|
)
|
|
tsEncrypted `shouldBe` hsEncrypted
|
|
|
|
it "Haskell encrypt -> TS decryptChunks" $ do
|
|
let source = "cross-language file test data" :: B.ByteString
|
|
hdr = FileHeader "data.bin" (Just "meta")
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 128 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit sbKeyVal cbNonceVal
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
tag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
encrypted = B.concat [hdrEnc, srcEnc, padEnc, tag]
|
|
tsResult <-
|
|
callNode $
|
|
impFile
|
|
<> "const r = F.decryptChunks("
|
|
<> show encSize
|
|
<> "n, ["
|
|
<> jsUint8 encrypted
|
|
<> "], "
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ");"
|
|
<> "const hdrBytes = F.encodeFileHeader(r.header);"
|
|
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
|
|
tsResult `shouldBe` (fileHdr <> source)
|
|
|
|
it "TS encryptFile -> Haskell decrypt" $ do
|
|
let source = "ts-to-haskell file" :: B.ByteString
|
|
hdr = FileHeader "note.txt" Nothing
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 128 :: Int64
|
|
paddedLen = encSize - 16
|
|
tsEncrypted <-
|
|
callNode $
|
|
impFile
|
|
<> "const source = "
|
|
<> jsUint8 source
|
|
<> ";"
|
|
<> "const fileHdr = F.encodeFileHeader({fileName: 'note.txt', fileExtra: null});"
|
|
<> jsOut
|
|
( "F.encryptFile(source, fileHdr, "
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> show fileSize'
|
|
<> "n,"
|
|
<> show encSize
|
|
<> "n)"
|
|
)
|
|
let (valid, plaintext) =
|
|
either (error . show) id $
|
|
LC.sbDecryptTailTag sbKeyVal cbNonceVal paddedLen (LB.fromStrict tsEncrypted)
|
|
valid `shouldBe` True
|
|
LB.toStrict plaintext `shouldBe` (fileHdr <> source)
|
|
|
|
it "multi-chunk decrypt" $ do
|
|
let source = "multi-chunk file content" :: B.ByteString
|
|
hdr = FileHeader "multi.bin" Nothing
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 128 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit sbKeyVal cbNonceVal
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
tag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
encrypted = B.concat [hdrEnc, srcEnc, padEnc, tag]
|
|
(chunk1, rest) = B.splitAt 50 encrypted
|
|
(chunk2, chunk3) = B.splitAt 50 rest
|
|
tsResult <-
|
|
callNode $
|
|
impFile
|
|
<> "const r = F.decryptChunks("
|
|
<> show encSize
|
|
<> "n, ["
|
|
<> jsUint8 chunk1
|
|
<> ","
|
|
<> jsUint8 chunk2
|
|
<> ","
|
|
<> jsUint8 chunk3
|
|
<> "], "
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ");"
|
|
<> "const hdrBytes = F.encodeFileHeader(r.header);"
|
|
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
|
|
tsResult `shouldBe` (fileHdr <> source)
|
|
|
|
it "auth tag tampering detection" $ do
|
|
let source = "tamper detection file" :: B.ByteString
|
|
hdr = FileHeader "secret.dat" Nothing
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 128 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit sbKeyVal cbNonceVal
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
tag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
encrypted = B.concat [hdrEnc, srcEnc, padEnc, tag]
|
|
tsResult <-
|
|
callNode $
|
|
impFile
|
|
<> "const enc = "
|
|
<> jsUint8 encrypted
|
|
<> ";"
|
|
<> "enc[enc.length - 1] ^= 1;"
|
|
<> "let ok = 0;"
|
|
<> "try { F.decryptChunks("
|
|
<> show encSize
|
|
<> "n, [enc], "
|
|
<> jsUint8 key32
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> "); ok = 1; } catch(e) { ok = 0; }"
|
|
<> jsOut "new Uint8Array([ok])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
-- ── protocol/commands ────────────────────────────────────────────
|
|
|
|
tsCommandTests :: Spec
|
|
tsCommandTests = describe "protocol/commands" $ do
|
|
let sndKey = B.pack [1 .. 8]
|
|
rcvKey1 = B.pack [11 .. 18]
|
|
rcvKey2 = B.pack [21 .. 28]
|
|
digest = B.pack [31 .. 38]
|
|
size32 = 12345 :: Word32
|
|
authKey = B.pack [41 .. 48]
|
|
dhKey = B.pack [51 .. 58]
|
|
|
|
describe "encode" $ do
|
|
it "encodeFileInfo" $ do
|
|
let expected = smpEncode sndKey <> smpEncode size32 <> smpEncode digest
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const fi = {sndKey: "
|
|
<> jsUint8 sndKey
|
|
<> ", size: "
|
|
<> show size32
|
|
<> ", digest: "
|
|
<> jsUint8 digest
|
|
<> "};"
|
|
<> jsOut "Cmd.encodeFileInfo(fi)"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodeFNEW with auth" $ do
|
|
let fileInfo = smpEncode sndKey <> smpEncode size32 <> smpEncode digest
|
|
rcvKeys = smpEncodeList [rcvKey1, rcvKey2]
|
|
auth = B.singleton 0x31 <> smpEncode authKey
|
|
expected = "FNEW " <> fileInfo <> rcvKeys <> auth
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const fi = {sndKey: "
|
|
<> jsUint8 sndKey
|
|
<> ", size: "
|
|
<> show size32
|
|
<> ", digest: "
|
|
<> jsUint8 digest
|
|
<> "};"
|
|
<> "const rks = ["
|
|
<> jsUint8 rcvKey1
|
|
<> ","
|
|
<> jsUint8 rcvKey2
|
|
<> "];"
|
|
<> jsOut ("Cmd.encodeFNEW(fi, rks, " <> jsUint8 authKey <> ")")
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodeFNEW without auth" $ do
|
|
let fileInfo = smpEncode sndKey <> smpEncode size32 <> smpEncode digest
|
|
rcvKeys = smpEncodeList [rcvKey1]
|
|
expected = "FNEW " <> fileInfo <> rcvKeys <> "0"
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const fi = {sndKey: "
|
|
<> jsUint8 sndKey
|
|
<> ", size: "
|
|
<> show size32
|
|
<> ", digest: "
|
|
<> jsUint8 digest
|
|
<> "};"
|
|
<> "const rks = ["
|
|
<> jsUint8 rcvKey1
|
|
<> "];"
|
|
<> jsOut "Cmd.encodeFNEW(fi, rks, null)"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodeFADD" $ do
|
|
let expected = "FADD " <> smpEncodeList [rcvKey1, rcvKey2]
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> jsOut ("Cmd.encodeFADD([" <> jsUint8 rcvKey1 <> "," <> jsUint8 rcvKey2 <> "])")
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodeFPUT" $ do
|
|
tsResult <- callNode $ impCmd <> jsOut "Cmd.encodeFPUT()"
|
|
tsResult `shouldBe` "FPUT"
|
|
|
|
it "encodeFDEL" $ do
|
|
tsResult <- callNode $ impCmd <> jsOut "Cmd.encodeFDEL()"
|
|
tsResult `shouldBe` "FDEL"
|
|
|
|
it "encodeFGET" $ do
|
|
let expected = "FGET " <> smpEncode dhKey
|
|
tsResult <-
|
|
callNode $
|
|
impCmd <> jsOut ("Cmd.encodeFGET(" <> jsUint8 dhKey <> ")")
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodePING" $ do
|
|
tsResult <- callNode $ impCmd <> jsOut "Cmd.encodePING()"
|
|
tsResult `shouldBe` "PING"
|
|
|
|
describe "decode" $ do
|
|
it "decodeResponse OK" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 ("OK" :: B.ByteString)
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([r.type === 'FROk' ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "decodeResponse PONG" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 ("PONG" :: B.ByteString)
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([r.type === 'FRPong' ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "decodeResponse ERR AUTH" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 ("ERR AUTH" :: B.ByteString)
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([r.type === 'FRErr' && r.err.type === 'AUTH' ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "decodeResponse ERR CMD SYNTAX" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 ("ERR CMD SYNTAX" :: B.ByteString)
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([r.type === 'FRErr' && r.err.type === 'CMD' && r.err.cmdErr === 'SYNTAX' ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "decodeResponse SIDS" $ do
|
|
let senderId = B.pack [1 .. 24]
|
|
rId1 = B.pack [25 .. 48]
|
|
rId2 = B.pack [49 .. 72]
|
|
sidsBytes = "SIDS " <> smpEncode senderId <> smpEncodeList [rId1, rId2]
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 sidsBytes
|
|
<> ");"
|
|
<> "if (r.type !== 'FRSndIds') throw new Error('wrong type');"
|
|
<> jsOut "E.concatBytes(r.senderId, ...r.recipientIds)"
|
|
tsResult `shouldBe` (senderId <> rId1 <> rId2)
|
|
|
|
it "decodeResponse RIDS" $ do
|
|
let rId1 = B.pack [1 .. 16]
|
|
rId2 = B.pack [17 .. 32]
|
|
ridsBytes = "RIDS " <> smpEncodeList [rId1, rId2]
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 ridsBytes
|
|
<> ");"
|
|
<> "if (r.type !== 'FRRcvIds') throw new Error('wrong type');"
|
|
<> jsOut "E.concatBytes(...r.recipientIds)"
|
|
tsResult `shouldBe` (rId1 <> rId2)
|
|
|
|
it "decodeResponse FILE" $ do
|
|
let rawPub = B.pack [1 .. 32]
|
|
x25519Der = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
derKey = x25519Der <> rawPub
|
|
nonce = B.pack [201 .. 224]
|
|
fileBytes = "FILE " <> smpEncode derKey <> nonce
|
|
tsResult <-
|
|
callNode $
|
|
impCmd
|
|
<> "const r = Cmd.decodeResponse("
|
|
<> jsUint8 fileBytes
|
|
<> ");"
|
|
<> "if (r.type !== 'FRFile') throw new Error('wrong type: ' + r.type);"
|
|
<> jsOut "E.concatBytes(r.rcvDhKey, r.nonce)"
|
|
tsResult `shouldBe` (rawPub <> nonce)
|
|
|
|
-- ── protocol/transmission ──────────────────────────────────────────
|
|
|
|
tsTransmissionTests :: Spec
|
|
tsTransmissionTests = describe "protocol/transmission" $ do
|
|
describe "blockPad / blockUnpad" $ do
|
|
it "blockPad matches C.pad" $ do
|
|
let msg = "hello pad test" :: B.ByteString
|
|
blockSize = 256 :: Int
|
|
hsPadded = either (error . show) id $ C.pad msg blockSize
|
|
tsPadded <-
|
|
callNode $
|
|
impTx <> jsOut ("Tx.blockPad(" <> jsUint8 msg <> ", " <> show blockSize <> ")")
|
|
tsPadded `shouldBe` hsPadded
|
|
|
|
it "Haskell C.pad -> TS blockUnpad" $ do
|
|
let msg = "cross-language unpad" :: B.ByteString
|
|
blockSize = 128 :: Int
|
|
hsPadded = either (error . show) id $ C.pad msg blockSize
|
|
tsResult <-
|
|
callNode $
|
|
impTx <> jsOut ("Tx.blockUnpad(" <> jsUint8 hsPadded <> ")")
|
|
tsResult `shouldBe` msg
|
|
|
|
it "TS blockPad -> Haskell C.unPad" $ do
|
|
let msg = "ts-to-haskell pad" :: B.ByteString
|
|
blockSize = 128 :: Int
|
|
tsPadded <-
|
|
callNode $
|
|
impTx <> jsOut ("Tx.blockPad(" <> jsUint8 msg <> ", " <> show blockSize <> ")")
|
|
let hsResult = either (error . show) id $ C.unPad tsPadded
|
|
hsResult `shouldBe` msg
|
|
|
|
describe "transmission encoding" $ do
|
|
it "encodeTransmission unsigned (PING)" $ do
|
|
let sessionId = B.pack [201 .. 232]
|
|
corrId = "abc" :: B.ByteString
|
|
entityId = "" :: B.ByteString
|
|
cmdBytes = "PING" :: B.ByteString
|
|
-- implySessId = False: sessionId on wire
|
|
tWire = smpEncode sessionId <> smpEncode corrId <> smpEncode entityId <> cmdBytes
|
|
authenticator = smpEncode ("" :: B.ByteString)
|
|
encoded = authenticator <> tWire
|
|
batch = B.singleton 1 <> smpEncode (Large encoded)
|
|
expected = either (error . show) id $ C.pad batch 16384
|
|
tsResult <-
|
|
callNode $
|
|
impTx
|
|
<> jsOut
|
|
( "Tx.encodeTransmission("
|
|
<> jsUint8 sessionId
|
|
<> ", "
|
|
<> jsUint8 corrId
|
|
<> ", "
|
|
<> jsUint8 entityId
|
|
<> ", "
|
|
<> jsUint8 cmdBytes
|
|
<> ")"
|
|
)
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodeAuthTransmission signed" $ do
|
|
let seed = B.pack [1 .. 32]
|
|
sk = throwCryptoError $ Ed25519.secretKey seed
|
|
pk = Ed25519.toPublic sk
|
|
sessionId = B.pack [101 .. 132]
|
|
corrId = "xyz" :: B.ByteString
|
|
entityId = B.pack [1 .. 24]
|
|
cmdBytes = "FPUT" :: B.ByteString
|
|
tInner = smpEncode corrId <> smpEncode entityId <> cmdBytes
|
|
tForAuth = smpEncode sessionId <> tInner
|
|
sig = Ed25519.sign sk pk tForAuth
|
|
rawSig = BA.convert sig :: B.ByteString
|
|
authenticator = smpEncode rawSig
|
|
-- implySessId = False: tToSend = tForAuth (sessionId on wire)
|
|
encoded = authenticator <> tForAuth
|
|
batch = B.singleton 1 <> smpEncode (Large encoded)
|
|
expected = either (error . show) id $ C.pad batch 16384
|
|
tsResult <-
|
|
callNode $
|
|
impTx
|
|
<> "const kp = K.ed25519KeyPairFromSeed("
|
|
<> jsUint8 seed
|
|
<> ");"
|
|
<> jsOut
|
|
( "Tx.encodeAuthTransmission("
|
|
<> jsUint8 sessionId
|
|
<> ", "
|
|
<> jsUint8 corrId
|
|
<> ", "
|
|
<> jsUint8 entityId
|
|
<> ", "
|
|
<> jsUint8 cmdBytes
|
|
<> ", kp.privateKey)"
|
|
)
|
|
tsResult `shouldBe` expected
|
|
|
|
it "decodeTransmission" $ do
|
|
let sessionId = B.pack [201 .. 232]
|
|
corrId = "r01" :: B.ByteString
|
|
entityId = B.pack [1 .. 16]
|
|
cmdBytes = "OK" :: B.ByteString
|
|
-- implySessId = False: sessionId on wire
|
|
tWire = smpEncode sessionId <> smpEncode corrId <> smpEncode entityId <> cmdBytes
|
|
authenticator = smpEncode ("" :: B.ByteString)
|
|
encoded = authenticator <> tWire
|
|
batch = B.singleton 1 <> smpEncode (Large encoded)
|
|
block = either (error . show) id $ C.pad batch 256
|
|
tsResult <-
|
|
callNode $
|
|
impTx
|
|
<> "const t = Tx.decodeTransmission("
|
|
<> jsUint8 sessionId
|
|
<> ", "
|
|
<> jsUint8 block
|
|
<> ");"
|
|
<> jsOut "E.concatBytes(t.corrId, t.entityId, t.command)"
|
|
tsResult `shouldBe` (corrId <> entityId <> cmdBytes)
|
|
|
|
-- ── protocol/handshake ────────────────────────────────────────────
|
|
|
|
tsHandshakeTests :: Spec
|
|
tsHandshakeTests = describe "protocol/handshake" $ do
|
|
describe "version range" $ do
|
|
it "encodeVersionRange" $ do
|
|
let expected = smpEncode (1 :: Word16) <> smpEncode (3 :: Word16)
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> jsOut "Hs.encodeVersionRange({minVersion: 1, maxVersion: 3})"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "decodeVersionRange" $ do
|
|
let vrBytes = smpEncode (2 :: Word16) <> smpEncode (5 :: Word16)
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const d = new E.Decoder("
|
|
<> jsUint8 vrBytes
|
|
<> ");"
|
|
<> "const vr = Hs.decodeVersionRange(d);"
|
|
<> jsOut "E.concatBytes(E.encodeWord16(vr.minVersion), E.encodeWord16(vr.maxVersion))"
|
|
tsResult `shouldBe` vrBytes
|
|
|
|
it "compatibleVRange (compatible)" $ do
|
|
-- intersection of [1,3] and [2,5] = [2,3]
|
|
let expected = smpEncode (2 :: Word16) <> smpEncode (3 :: Word16)
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const r = Hs.compatibleVRange({minVersion:1,maxVersion:3},{minVersion:2,maxVersion:5});"
|
|
<> "if (!r) throw new Error('expected compatible');"
|
|
<> jsOut "Hs.encodeVersionRange(r)"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "compatibleVRange (incompatible)" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const r = Hs.compatibleVRange({minVersion:1,maxVersion:2},{minVersion:3,maxVersion:5});"
|
|
<> jsOut "new Uint8Array([r === null ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
describe "client handshake" $ do
|
|
it "encodeClientHandshake" $ do
|
|
let kh = B.pack [1 .. 32]
|
|
body = smpEncode (3 :: Word16) <> smpEncode kh
|
|
expected = either (error . show) id $ C.pad body 16384
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> jsOut ("Hs.encodeClientHandshake({xftpVersion:3,keyHash:" <> jsUint8 kh <> "})")
|
|
tsResult `shouldBe` expected
|
|
|
|
describe "client hello" $ do
|
|
it "encodeClientHello (Nothing)" $ do
|
|
let expected = smpEncode (XFTPClientHello {webChallenge = Nothing})
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> jsOut "Hs.encodeClientHello({webChallenge: null})"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "encodeClientHello (Just challenge)" $ do
|
|
let challenge = B.pack [1 .. 32]
|
|
expected = either (error . show) id $ C.pad (smpEncode (XFTPClientHello {webChallenge = Just challenge})) xftpBlockSize
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> jsOut ("Hs.encodeClientHello({webChallenge:" <> jsUint8 challenge <> "})")
|
|
tsResult `shouldBe` expected
|
|
|
|
describe "server handshake" $ do
|
|
it "decodeServerHandshake" $ do
|
|
let sessId = B.pack [1 .. 32]
|
|
cert1 = B.pack [101 .. 200] -- 100 bytes
|
|
cert2 = B.pack [201 .. 232] -- 32 bytes
|
|
signedKeyBytes = B.pack [1 .. 120]
|
|
-- Encode server handshake body matching Haskell wire format:
|
|
-- smpEncode (versionRange, sessionId, certChainPubKey)
|
|
-- where certChainPubKey = (NonEmpty Large certChain, Large signedKey)
|
|
body =
|
|
smpEncode (1 :: Word16)
|
|
<> smpEncode (3 :: Word16)
|
|
<> smpEncode sessId
|
|
<> smpEncode (NE.fromList [Large cert1, Large cert2])
|
|
<> smpEncode (Large signedKeyBytes)
|
|
serverBlock = either (error . show) id $ C.pad body 16384
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const hs = Hs.decodeServerHandshake("
|
|
<> jsUint8 serverBlock
|
|
<> ");"
|
|
<> jsOut
|
|
( "E.concatBytes("
|
|
<> "E.encodeWord16(hs.xftpVersionRange.minVersion),"
|
|
<> "E.encodeWord16(hs.xftpVersionRange.maxVersion),"
|
|
<> "hs.sessionId,"
|
|
<> "...hs.certChainDer,"
|
|
<> "hs.signedKeyDer)"
|
|
)
|
|
-- Expected: vmin(2) + vmax(2) + sessId(32) + cert1(100) + cert2(32) + signedKey(120) = 288 bytes
|
|
tsResult
|
|
`shouldBe` ( smpEncode (1 :: Word16)
|
|
<> smpEncode (3 :: Word16)
|
|
<> sessId
|
|
<> cert1
|
|
<> cert2
|
|
<> signedKeyBytes
|
|
)
|
|
|
|
it "decodeServerHandshake with webIdentityProof" $ do
|
|
let sessId = B.pack [1 .. 32]
|
|
cert1 = B.pack [101 .. 200]
|
|
cert2 = B.pack [201 .. 232]
|
|
signedKeyBytes = B.pack [1 .. 120]
|
|
sigBytes = B.pack [1 .. 64]
|
|
body =
|
|
smpEncode (1 :: Word16)
|
|
<> smpEncode (3 :: Word16)
|
|
<> smpEncode sessId
|
|
<> smpEncode (NE.fromList [Large cert1, Large cert2])
|
|
<> smpEncode (Large signedKeyBytes)
|
|
<> smpEncode sigBytes
|
|
serverBlock = either (error . show) id $ C.pad body 16384
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const hs = Hs.decodeServerHandshake("
|
|
<> jsUint8 serverBlock
|
|
<> ");"
|
|
<> jsOut "hs.webIdentityProof || new Uint8Array(0)"
|
|
tsResult `shouldBe` sigBytes
|
|
|
|
it "decodeServerHandshake without webIdentityProof" $ do
|
|
let sessId = B.pack [1 .. 32]
|
|
cert1 = B.pack [101 .. 200]
|
|
cert2 = B.pack [201 .. 232]
|
|
signedKeyBytes = B.pack [1 .. 120]
|
|
body =
|
|
smpEncode (1 :: Word16)
|
|
<> smpEncode (3 :: Word16)
|
|
<> smpEncode sessId
|
|
<> smpEncode (NE.fromList [Large cert1, Large cert2])
|
|
<> smpEncode (Large signedKeyBytes)
|
|
<> smpEncode ("" :: B.ByteString)
|
|
serverBlock = either (error . show) id $ C.pad body 16384
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const hs = Hs.decodeServerHandshake("
|
|
<> jsUint8 serverBlock
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([hs.webIdentityProof === null ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
describe "certificate utilities" $ do
|
|
it "caFingerprint" $ do
|
|
let cert1 = B.pack [101 .. 200]
|
|
cert2 = B.pack [201 .. 232]
|
|
expected = C.sha256Hash cert2
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const chain = ["
|
|
<> jsUint8 cert1
|
|
<> ","
|
|
<> jsUint8 cert2
|
|
<> "];"
|
|
<> jsOut "Hs.caFingerprint(chain)"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "caFingerprint 3 certs" $ do
|
|
let cert1 = B.pack [1 .. 10]
|
|
cert2 = B.pack [11 .. 20]
|
|
cert3 = B.pack [21 .. 30]
|
|
expected = C.sha256Hash cert2
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const chain = ["
|
|
<> jsUint8 cert1
|
|
<> ","
|
|
<> jsUint8 cert2
|
|
<> ","
|
|
<> jsUint8 cert3
|
|
<> "];"
|
|
<> jsOut "Hs.caFingerprint(chain)"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "chainIdCaCerts 2 certs" $ do
|
|
let cert1 = B.pack [1 .. 10]
|
|
cert2 = B.pack [11 .. 20]
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const cc = Hs.chainIdCaCerts(["
|
|
<> jsUint8 cert1
|
|
<> ","
|
|
<> jsUint8 cert2
|
|
<> "]);"
|
|
<> "if (cc.type !== 'valid') throw new Error('expected valid');"
|
|
<> jsOut "E.concatBytes(cc.leafCert, cc.idCert, cc.caCert)"
|
|
tsResult `shouldBe` (cert1 <> cert2 <> cert2)
|
|
|
|
it "chainIdCaCerts 3 certs" $ do
|
|
let cert1 = B.pack [1 .. 10]
|
|
cert2 = B.pack [11 .. 20]
|
|
cert3 = B.pack [21 .. 30]
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const cc = Hs.chainIdCaCerts(["
|
|
<> jsUint8 cert1
|
|
<> ","
|
|
<> jsUint8 cert2
|
|
<> ","
|
|
<> jsUint8 cert3
|
|
<> "]);"
|
|
<> "if (cc.type !== 'valid') throw new Error('expected valid');"
|
|
<> jsOut "E.concatBytes(cc.leafCert, cc.idCert, cc.caCert)"
|
|
tsResult `shouldBe` (cert1 <> cert2 <> cert3)
|
|
|
|
it "chainIdCaCerts 4 certs" $ do
|
|
let cert1 = B.pack [1 .. 10]
|
|
cert2 = B.pack [11 .. 20]
|
|
cert3 = B.pack [21 .. 30]
|
|
cert4 = B.pack [31 .. 40]
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const cc = Hs.chainIdCaCerts(["
|
|
<> jsUint8 cert1
|
|
<> ","
|
|
<> jsUint8 cert2
|
|
<> ","
|
|
<> jsUint8 cert3
|
|
<> ","
|
|
<> jsUint8 cert4
|
|
<> "]);"
|
|
<> "if (cc.type !== 'valid') throw new Error('expected valid');"
|
|
<> jsOut "E.concatBytes(cc.leafCert, cc.idCert, cc.caCert)"
|
|
tsResult `shouldBe` (cert1 <> cert2 <> cert4)
|
|
|
|
describe "SignedExact parsing" $ do
|
|
it "extractSignedKey" $ do
|
|
-- Generate signing key (Ed25519)
|
|
let signSeed = B.pack [1 .. 32]
|
|
signSk = throwCryptoError $ Ed25519.secretKey signSeed
|
|
signPk = Ed25519.toPublic signSk
|
|
signPkRaw = BA.convert signPk :: B.ByteString
|
|
-- Generate DH key (X25519)
|
|
dhSeed = B.pack [41 .. 72]
|
|
dhSk = throwCryptoError $ X25519.secretKey dhSeed
|
|
dhPk = X25519.toPublic dhSk
|
|
dhPkRaw = BA.convert dhPk :: B.ByteString
|
|
-- SubjectPublicKeyInfo DER for X25519 (44 bytes)
|
|
x25519Prefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
spkiDer = x25519Prefix <> dhPkRaw
|
|
-- Sign the SPKI with Ed25519
|
|
sig = Ed25519.sign signSk signPk spkiDer
|
|
sigRaw = BA.convert sig :: B.ByteString
|
|
-- AlgorithmIdentifier for Ed25519 (7 bytes)
|
|
algId = B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]
|
|
-- BIT STRING wrapper (3 + 64 = 67 bytes)
|
|
bitString = B.pack [0x03, 0x41, 0x00] <> sigRaw
|
|
-- Outer SEQUENCE: content = 44 + 7 + 67 = 118 = 0x76
|
|
content = spkiDer <> algId <> bitString
|
|
signedExactDer = B.pack [0x30, 0x76] <> content
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const sk = Hs.extractSignedKey("
|
|
<> jsUint8 signedExactDer
|
|
<> ");"
|
|
<> jsOut "E.concatBytes(sk.dhKey, sk.signature)"
|
|
-- dhKey (32) + signature (64) = 96 bytes
|
|
tsResult `shouldBe` (dhPkRaw <> sigRaw)
|
|
|
|
it "extractSignedKey signature verifies" $ do
|
|
let signSeed = B.pack [1 .. 32]
|
|
signSk = throwCryptoError $ Ed25519.secretKey signSeed
|
|
signPk = Ed25519.toPublic signSk
|
|
signPkRaw = BA.convert signPk :: B.ByteString
|
|
dhSeed = B.pack [41 .. 72]
|
|
dhSk = throwCryptoError $ X25519.secretKey dhSeed
|
|
dhPk = X25519.toPublic dhSk
|
|
dhPkRaw = BA.convert dhPk :: B.ByteString
|
|
x25519Prefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
spkiDer = x25519Prefix <> dhPkRaw
|
|
sig = Ed25519.sign signSk signPk spkiDer
|
|
sigRaw = BA.convert sig :: B.ByteString
|
|
algId = B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]
|
|
bitString = B.pack [0x03, 0x41, 0x00] <> sigRaw
|
|
content = spkiDer <> algId <> bitString
|
|
signedExactDer = B.pack [0x30, 0x76] <> content
|
|
tsResult <-
|
|
callNode $
|
|
impHs
|
|
<> "const sk = Hs.extractSignedKey("
|
|
<> jsUint8 signedExactDer
|
|
<> ");"
|
|
<> "const ok = K.verify("
|
|
<> jsUint8 signPkRaw
|
|
<> ", sk.signature, sk.objectDer);"
|
|
<> jsOut "new Uint8Array([ok ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
-- ── crypto/identity ──────────────────────────────────────────────
|
|
|
|
-- Construct a minimal X.509 certificate DER with an Ed25519 public key.
|
|
-- Structurally valid for DER navigation but not a real certificate.
|
|
mkFakeCertDer :: B.ByteString -> B.ByteString
|
|
mkFakeCertDer pubKey32 =
|
|
let spki = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00] <> pubKey32
|
|
tbsContents =
|
|
B.concat
|
|
[ B.pack [0xa0, 0x03, 0x02, 0x01, 0x02],
|
|
B.pack [0x02, 0x01, 0x01],
|
|
B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70],
|
|
B.pack [0x30, 0x00],
|
|
B.pack [0x30, 0x00],
|
|
B.pack [0x30, 0x00],
|
|
spki
|
|
]
|
|
tbs = B.pack [0x30, fromIntegral $ B.length tbsContents] <> tbsContents
|
|
certContents =
|
|
B.concat
|
|
[ tbs,
|
|
B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70],
|
|
B.pack [0x03, 0x41, 0x00] <> B.replicate 64 0
|
|
]
|
|
certLen = B.length certContents
|
|
in B.pack [0x30, 0x81, fromIntegral certLen] <> certContents
|
|
|
|
tsIdentityTests :: Spec
|
|
tsIdentityTests = describe "crypto/identity" $ do
|
|
describe "extractCertPublicKeyInfo" $ do
|
|
it "extracts SPKI from X.509 DER" $ do
|
|
let pubKey = B.pack [1 .. 32]
|
|
certDer = mkFakeCertDer pubKey
|
|
expectedSpki = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00] <> pubKey
|
|
tsResult <-
|
|
callNode $
|
|
impId
|
|
<> jsOut ("Id.extractCertPublicKeyInfo(" <> jsUint8 certDer <> ")")
|
|
tsResult `shouldBe` expectedSpki
|
|
|
|
it "extractCertPublicKeyInfo + decodePubKey returns raw 32-byte key" $ do
|
|
let pubKey = B.pack [1 .. 32]
|
|
certDer = mkFakeCertDer pubKey
|
|
tsResult <-
|
|
callNode $
|
|
impId
|
|
<> jsOut ("K.decodePubKeyEd25519(Id.extractCertPublicKeyInfo(" <> jsUint8 certDer <> "))")
|
|
tsResult `shouldBe` pubKey
|
|
|
|
describe "verifyIdentityProof" $ do
|
|
it "valid proof returns true" $ do
|
|
let signSeed = B.pack [1 .. 32]
|
|
signSk = throwCryptoError $ Ed25519.secretKey signSeed
|
|
signPk = Ed25519.toPublic signSk
|
|
signPkRaw = BA.convert signPk :: B.ByteString
|
|
leafCertDer = mkFakeCertDer signPkRaw
|
|
idCertDer = B.pack [1 .. 50]
|
|
keyHash = C.sha256Hash idCertDer
|
|
-- DH key SignedExact
|
|
dhSeed = B.pack [41 .. 72]
|
|
dhSk = throwCryptoError $ X25519.secretKey dhSeed
|
|
dhPk = X25519.toPublic dhSk
|
|
dhPkRaw = BA.convert dhPk :: B.ByteString
|
|
x25519Prefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
spkiDer = x25519Prefix <> dhPkRaw
|
|
dhSig = Ed25519.sign signSk signPk spkiDer
|
|
dhSigRaw = BA.convert dhSig :: B.ByteString
|
|
algId = B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]
|
|
bitString = B.pack [0x03, 0x41, 0x00] <> dhSigRaw
|
|
signedKeyDer = B.pack [0x30, 0x76] <> spkiDer <> algId <> bitString
|
|
-- Challenge signature
|
|
challenge = B.pack [101 .. 132]
|
|
sessionId = B.pack [201 .. 232]
|
|
challengeSig = Ed25519.sign signSk signPk (challenge <> sessionId)
|
|
challengeSigRaw = BA.convert challengeSig :: B.ByteString
|
|
tsResult <-
|
|
callNode $
|
|
impId
|
|
<> "const ok = Id.verifyIdentityProof({"
|
|
<> "certChainDer: ["
|
|
<> jsUint8 leafCertDer
|
|
<> ","
|
|
<> jsUint8 idCertDer
|
|
<> "],"
|
|
<> "signedKeyDer: "
|
|
<> jsUint8 signedKeyDer
|
|
<> ","
|
|
<> "sigBytes: "
|
|
<> jsUint8 challengeSigRaw
|
|
<> ","
|
|
<> "challenge: "
|
|
<> jsUint8 challenge
|
|
<> ","
|
|
<> "sessionId: "
|
|
<> jsUint8 sessionId
|
|
<> ","
|
|
<> "keyHash: "
|
|
<> jsUint8 keyHash
|
|
<> "});"
|
|
<> jsOut "new Uint8Array([ok ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "wrong keyHash returns false" $ do
|
|
let signSeed = B.pack [1 .. 32]
|
|
signSk = throwCryptoError $ Ed25519.secretKey signSeed
|
|
signPk = Ed25519.toPublic signSk
|
|
signPkRaw = BA.convert signPk :: B.ByteString
|
|
leafCertDer = mkFakeCertDer signPkRaw
|
|
idCertDer = B.pack [1 .. 50]
|
|
wrongKeyHash = B.replicate 32 0xff
|
|
-- DH key SignedExact
|
|
dhSeed = B.pack [41 .. 72]
|
|
dhSk = throwCryptoError $ X25519.secretKey dhSeed
|
|
dhPk = X25519.toPublic dhSk
|
|
dhPkRaw = BA.convert dhPk :: B.ByteString
|
|
x25519Prefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
spkiDer = x25519Prefix <> dhPkRaw
|
|
dhSig = Ed25519.sign signSk signPk spkiDer
|
|
dhSigRaw = BA.convert dhSig :: B.ByteString
|
|
algId = B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]
|
|
bitString = B.pack [0x03, 0x41, 0x00] <> dhSigRaw
|
|
signedKeyDer = B.pack [0x30, 0x76] <> spkiDer <> algId <> bitString
|
|
challenge = B.pack [101 .. 132]
|
|
sessionId = B.pack [201 .. 232]
|
|
challengeSig = Ed25519.sign signSk signPk (challenge <> sessionId)
|
|
challengeSigRaw = BA.convert challengeSig :: B.ByteString
|
|
tsResult <-
|
|
callNode $
|
|
impId
|
|
<> "const ok = Id.verifyIdentityProof({"
|
|
<> "certChainDer: ["
|
|
<> jsUint8 leafCertDer
|
|
<> ","
|
|
<> jsUint8 idCertDer
|
|
<> "],"
|
|
<> "signedKeyDer: "
|
|
<> jsUint8 signedKeyDer
|
|
<> ","
|
|
<> "sigBytes: "
|
|
<> jsUint8 challengeSigRaw
|
|
<> ","
|
|
<> "challenge: "
|
|
<> jsUint8 challenge
|
|
<> ","
|
|
<> "sessionId: "
|
|
<> jsUint8 sessionId
|
|
<> ","
|
|
<> "keyHash: "
|
|
<> jsUint8 wrongKeyHash
|
|
<> "});"
|
|
<> jsOut "new Uint8Array([ok ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
it "wrong challenge sig returns false" $ do
|
|
let signSeed = B.pack [1 .. 32]
|
|
signSk = throwCryptoError $ Ed25519.secretKey signSeed
|
|
signPk = Ed25519.toPublic signSk
|
|
signPkRaw = BA.convert signPk :: B.ByteString
|
|
leafCertDer = mkFakeCertDer signPkRaw
|
|
idCertDer = B.pack [1 .. 50]
|
|
keyHash = C.sha256Hash idCertDer
|
|
-- DH key SignedExact
|
|
dhSeed = B.pack [41 .. 72]
|
|
dhSk = throwCryptoError $ X25519.secretKey dhSeed
|
|
dhPk = X25519.toPublic dhSk
|
|
dhPkRaw = BA.convert dhPk :: B.ByteString
|
|
x25519Prefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
spkiDer = x25519Prefix <> dhPkRaw
|
|
dhSig = Ed25519.sign signSk signPk spkiDer
|
|
dhSigRaw = BA.convert dhSig :: B.ByteString
|
|
algId = B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]
|
|
bitString = B.pack [0x03, 0x41, 0x00] <> dhSigRaw
|
|
signedKeyDer = B.pack [0x30, 0x76] <> spkiDer <> algId <> bitString
|
|
challenge = B.pack [101 .. 132]
|
|
sessionId = B.pack [201 .. 232]
|
|
wrongChallenge = B.pack [1 .. 32]
|
|
wrongSig = Ed25519.sign signSk signPk (wrongChallenge <> sessionId)
|
|
wrongSigRaw = BA.convert wrongSig :: B.ByteString
|
|
tsResult <-
|
|
callNode $
|
|
impId
|
|
<> "const ok = Id.verifyIdentityProof({"
|
|
<> "certChainDer: ["
|
|
<> jsUint8 leafCertDer
|
|
<> ","
|
|
<> jsUint8 idCertDer
|
|
<> "],"
|
|
<> "signedKeyDer: "
|
|
<> jsUint8 signedKeyDer
|
|
<> ","
|
|
<> "sigBytes: "
|
|
<> jsUint8 wrongSigRaw
|
|
<> ","
|
|
<> "challenge: "
|
|
<> jsUint8 challenge
|
|
<> ","
|
|
<> "sessionId: "
|
|
<> jsUint8 sessionId
|
|
<> ","
|
|
<> "keyHash: "
|
|
<> jsUint8 keyHash
|
|
<> "});"
|
|
<> jsOut "new Uint8Array([ok ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
it "wrong DH key sig returns false" $ do
|
|
let signSeed = B.pack [1 .. 32]
|
|
signSk = throwCryptoError $ Ed25519.secretKey signSeed
|
|
signPk = Ed25519.toPublic signSk
|
|
signPkRaw = BA.convert signPk :: B.ByteString
|
|
leafCertDer = mkFakeCertDer signPkRaw
|
|
idCertDer = B.pack [1 .. 50]
|
|
keyHash = C.sha256Hash idCertDer
|
|
-- DH key signed by a DIFFERENT key
|
|
otherSeed = B.pack [51 .. 82]
|
|
otherSk = throwCryptoError $ Ed25519.secretKey otherSeed
|
|
otherPk = Ed25519.toPublic otherSk
|
|
dhSeed = B.pack [41 .. 72]
|
|
dhSk = throwCryptoError $ X25519.secretKey dhSeed
|
|
dhPk = X25519.toPublic dhSk
|
|
dhPkRaw = BA.convert dhPk :: B.ByteString
|
|
x25519Prefix = B.pack [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]
|
|
spkiDer = x25519Prefix <> dhPkRaw
|
|
dhSig = Ed25519.sign otherSk otherPk spkiDer
|
|
dhSigRaw = BA.convert dhSig :: B.ByteString
|
|
algId = B.pack [0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]
|
|
bitString = B.pack [0x03, 0x41, 0x00] <> dhSigRaw
|
|
signedKeyDer = B.pack [0x30, 0x76] <> spkiDer <> algId <> bitString
|
|
challenge = B.pack [101 .. 132]
|
|
sessionId = B.pack [201 .. 232]
|
|
challengeSig = Ed25519.sign signSk signPk (challenge <> sessionId)
|
|
challengeSigRaw = BA.convert challengeSig :: B.ByteString
|
|
tsResult <-
|
|
callNode $
|
|
impId
|
|
<> "const ok = Id.verifyIdentityProof({"
|
|
<> "certChainDer: ["
|
|
<> jsUint8 leafCertDer
|
|
<> ","
|
|
<> jsUint8 idCertDer
|
|
<> "],"
|
|
<> "signedKeyDer: "
|
|
<> jsUint8 signedKeyDer
|
|
<> ","
|
|
<> "sigBytes: "
|
|
<> jsUint8 challengeSigRaw
|
|
<> ","
|
|
<> "challenge: "
|
|
<> jsUint8 challenge
|
|
<> ","
|
|
<> "sessionId: "
|
|
<> jsUint8 sessionId
|
|
<> ","
|
|
<> "keyHash: "
|
|
<> jsUint8 keyHash
|
|
<> "});"
|
|
<> jsOut "new Uint8Array([ok ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
-- ── protocol/description ──────────────────────────────────────────
|
|
|
|
tsDescriptionTests :: Spec
|
|
tsDescriptionTests = describe "protocol/description" $ do
|
|
describe "base64url" $ do
|
|
it "encode matches Haskell strEncode" $ do
|
|
let bs = B.pack [0 .. 31]
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> jsOut ("new TextEncoder().encode(Desc.base64urlEncode(" <> jsUint8 bs <> "))")
|
|
tsResult `shouldBe` strEncode bs
|
|
|
|
it "decode recovers original" $ do
|
|
let bs = B.pack [0 .. 31]
|
|
encoded = strEncode bs
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const s = new TextDecoder().decode("
|
|
<> jsUint8 encoded
|
|
<> ");"
|
|
<> jsOut "Desc.base64urlDecode(s)"
|
|
tsResult `shouldBe` bs
|
|
|
|
it "round-trip 256 bytes" $ do
|
|
let bs = B.pack [0 .. 255]
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const data = "
|
|
<> jsUint8 bs
|
|
<> ";"
|
|
<> "const encoded = Desc.base64urlEncode(data);"
|
|
<> jsOut "Desc.base64urlDecode(encoded)"
|
|
tsResult `shouldBe` bs
|
|
|
|
describe "FileSize" $ do
|
|
it "encodeFileSize" $ do
|
|
let sizes = [500, 1024, 2048, 1048576, 8388608, 1073741824, 27262976 :: Int64]
|
|
expected = B.intercalate "," $ map (strEncode . FileSize) sizes
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const sizes = [500, 1024, 2048, 1048576, 8388608, 1073741824, 27262976];"
|
|
<> jsOut "new TextEncoder().encode(sizes.map(Desc.encodeFileSize).join(','))"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "decodeFileSize" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const strs = ['500','1kb','2kb','1mb','8mb','1gb'];"
|
|
<> jsOut "new TextEncoder().encode(strs.map(s => String(Desc.decodeFileSize(s))).join(','))"
|
|
tsResult `shouldBe` "500,1024,2048,1048576,8388608,1073741824"
|
|
|
|
describe "FileDescription" $ do
|
|
it "fixture YAML round-trip" $ do
|
|
fixture <- B.readFile "tests/fixtures/file_description.yaml"
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const yaml = new TextDecoder().decode("
|
|
<> jsUint8 fixture
|
|
<> ");"
|
|
<> "const fd = Desc.decodeFileDescription(yaml);"
|
|
<> "const reEncoded = Desc.encodeFileDescription(fd);"
|
|
<> jsOut "new TextEncoder().encode(reEncoded)"
|
|
tsResult `shouldBe` fixture
|
|
|
|
it "fixture parsed structure" $ do
|
|
fixture <- B.readFile "tests/fixtures/file_description.yaml"
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const yaml = new TextDecoder().decode("
|
|
<> jsUint8 fixture
|
|
<> ");"
|
|
<> "const fd = Desc.decodeFileDescription(yaml);"
|
|
<> "const r = ["
|
|
<> "fd.party,"
|
|
<> "String(fd.size),"
|
|
<> "String(fd.chunkSize),"
|
|
<> "String(fd.chunks.length),"
|
|
<> "String(fd.chunks[0].replicas.length),"
|
|
<> "String(fd.chunks[3].chunkSize),"
|
|
<> "fd.redirect === null ? 'null' : 'redirect'"
|
|
<> "].join(',');"
|
|
<> jsOut "new TextEncoder().encode(r)"
|
|
tsResult `shouldBe` "recipient,27262976,8388608,4,2,2097152,null"
|
|
|
|
it "encode with redirect round-trips" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const fd = {"
|
|
<> " party: 'sender',"
|
|
<> " size: 1024,"
|
|
<> " digest: new Uint8Array([1,2,3]),"
|
|
<> " key: new Uint8Array(32),"
|
|
<> " nonce: new Uint8Array(24),"
|
|
<> " chunkSize: 1024,"
|
|
<> " chunks: [{chunkNo: 1, chunkSize: 1024, digest: new Uint8Array([4,5,6]),"
|
|
<> " replicas: [{server: 'xftp://abc=@example.com', replicaId: new Uint8Array([7,8,9]),"
|
|
<> " replicaKey: new Uint8Array([10,11,12])}]}],"
|
|
<> " redirect: {size: 512, digest: new Uint8Array([13,14,15])}"
|
|
<> "};"
|
|
<> "const yaml = Desc.encodeFileDescription(fd);"
|
|
<> "const fd2 = Desc.decodeFileDescription(yaml);"
|
|
<> "const r = ["
|
|
<> "fd2.party,"
|
|
<> "String(fd2.redirect !== null),"
|
|
<> "String(fd2.redirect?.size),"
|
|
<> "Desc.base64urlEncode(fd2.redirect?.digest || new Uint8Array())"
|
|
<> "].join(',');"
|
|
<> jsOut "new TextEncoder().encode(r)"
|
|
tsResult `shouldBe` "sender,true,512,DQ4P"
|
|
|
|
it "fdSeparator" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> jsOut "new TextEncoder().encode(Desc.fdSeparator)"
|
|
tsResult `shouldBe` "################################\n"
|
|
|
|
it "web URI encoding matches Haskell" $ do
|
|
yaml <- B.readFile "tests/fixtures/file_description.yaml"
|
|
fd <- either fail pure (strDecode yaml :: Either String (FileDescription 'FRecipient))
|
|
let hsEncoded = encodeWebURI fd
|
|
-- Haskell round-trip
|
|
ValidFileDescription fd' <- either (fail . ("decode: " <>)) pure $ decodeWebURI hsEncoded
|
|
fd `shouldBe` fd'
|
|
-- Cross-language: TS reads same fixture, encodes, should match
|
|
tsEncoded <-
|
|
callNode $
|
|
"import {readFileSync} from 'node:fs';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\import * as Desc from './dist/protocol/description.js';\
|
|
\const yaml = readFileSync('../tests/fixtures/file_description.yaml', 'utf8');\
|
|
\const fd = Desc.decodeFileDescription(yaml);\
|
|
\const uri = Agent.encodeDescriptionURI(fd);\
|
|
\process.stdout.write(Buffer.from(uri));"
|
|
hsEncoded `shouldBe` tsEncoded
|
|
|
|
describe "validation" $ do
|
|
it "valid description" $ do
|
|
fixture <- B.readFile "tests/fixtures/file_description.yaml"
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const yaml = new TextDecoder().decode("
|
|
<> jsUint8 fixture
|
|
<> ");"
|
|
<> "const fd = Desc.decodeFileDescription(yaml);"
|
|
<> "const r = Desc.validateFileDescription(fd);"
|
|
<> jsOut "new TextEncoder().encode(r === null ? 'ok' : r)"
|
|
tsResult `shouldBe` "ok"
|
|
|
|
it "non-sequential chunks" $ do
|
|
fixture <- B.readFile "tests/fixtures/file_description.yaml"
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const yaml = new TextDecoder().decode("
|
|
<> jsUint8 fixture
|
|
<> ");"
|
|
<> "const fd = Desc.decodeFileDescription(yaml);"
|
|
<> "fd.chunks[1].chunkNo = 5;"
|
|
<> "const r = Desc.validateFileDescription(fd);"
|
|
<> jsOut "new TextEncoder().encode(r || 'ok')"
|
|
tsResult `shouldBe` "chunk numbers are not sequential"
|
|
|
|
it "mismatched size" $ do
|
|
fixture <- B.readFile "tests/fixtures/file_description.yaml"
|
|
tsResult <-
|
|
callNode $
|
|
impDesc
|
|
<> "const yaml = new TextDecoder().decode("
|
|
<> jsUint8 fixture
|
|
<> ");"
|
|
<> "const fd = Desc.decodeFileDescription(yaml);"
|
|
<> "fd.size = 999;"
|
|
<> "const r = Desc.validateFileDescription(fd);"
|
|
<> jsOut "new TextEncoder().encode(r || 'ok')"
|
|
tsResult `shouldBe` "chunks total size is different than file size"
|
|
|
|
-- ── protocol/chunks ───────────────────────────────────────────────
|
|
|
|
tsChunkTests :: Spec
|
|
tsChunkTests = describe "protocol/chunks" $ do
|
|
describe "prepareChunkSizes" $ do
|
|
it "matches Haskell for various sizes" $ do
|
|
let sizes = [100, 65536, 130000, 200000, 500000, 800000, 5000000, 27262976 :: Int64]
|
|
hsResults = map prepareChunkSizes sizes
|
|
expected = B.intercalate "|" $ map (\cs -> B.intercalate "," $ map (strEncode . FileSize) cs) hsResults
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> "const sizes = [100, 65536, 130000, 200000, 500000, 800000, 5000000, 27262976];"
|
|
<> "const results = sizes.map(s => Chk.prepareChunkSizes(s).map(Desc.encodeFileSize).join(','));"
|
|
<> jsOut "new TextEncoder().encode(results.join('|'))"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "zero size" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> jsOut "new TextEncoder().encode(Chk.prepareChunkSizes(0).join(','))"
|
|
tsResult `shouldBe` ""
|
|
|
|
describe "singleChunkSize" $ do
|
|
it "finds smallest fitting chunk size" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> "const sizes = [100, 65536, 262144, 300000, 1048576, 4194304, 5000000];"
|
|
<> "const results = sizes.map(s => {"
|
|
<> " const r = Chk.singleChunkSize(s);"
|
|
<> " return r === null ? 'null' : Desc.encodeFileSize(r);"
|
|
<> "});"
|
|
<> jsOut "new TextEncoder().encode(results.join(','))"
|
|
tsResult `shouldBe` "64kb,64kb,256kb,1mb,1mb,4mb,null"
|
|
|
|
describe "prepareChunkSpecs" $ do
|
|
it "generates correct offsets" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> "const specs = Chk.prepareChunkSpecs([4194304, 4194304, 1048576]);"
|
|
<> "const r = specs.map(s => s.chunkOffset + ':' + s.chunkSize).join(',');"
|
|
<> jsOut "new TextEncoder().encode(r)"
|
|
tsResult `shouldBe` "0:4194304,4194304:4194304,8388608:1048576"
|
|
|
|
describe "getChunkDigest" $ do
|
|
it "matches Haskell sha256Hash" $ do
|
|
let chunk = B.pack [0 .. 63]
|
|
expected = C.sha256Hash chunk
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> jsOut ("Chk.getChunkDigest(" <> jsUint8 chunk <> ")")
|
|
tsResult `shouldBe` expected
|
|
|
|
describe "constants" $ do
|
|
it "serverChunkSizes" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> jsOut "new TextEncoder().encode(Chk.serverChunkSizes.map(Desc.encodeFileSize).join(','))"
|
|
tsResult `shouldBe` "64kb,256kb,1mb,4mb"
|
|
|
|
it "fileSizeLen and authTagSize" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impChk
|
|
<> jsOut "new TextEncoder().encode(Chk.fileSizeLen + ',' + Chk.authTagSize)"
|
|
tsResult `shouldBe` "8,16"
|
|
|
|
-- ── protocol/client ─────────────────────────────────────────────
|
|
|
|
tsClientTests :: Spec
|
|
tsClientTests = describe "protocol/client" $ do
|
|
-- Fixed X25519 key pairs for deterministic tests
|
|
let privARaw = B.pack [1 .. 32]
|
|
privA = throwCryptoError $ X25519.secretKey privARaw
|
|
pubA = X25519.toPublic privA
|
|
pubARaw = BA.convert pubA :: B.ByteString
|
|
privBRaw = B.pack [33 .. 64]
|
|
privB = throwCryptoError $ X25519.secretKey privBRaw
|
|
pubB = X25519.toPublic privB
|
|
pubBRaw = BA.convert pubB :: B.ByteString
|
|
nonce24 = B.pack [0 .. 23]
|
|
|
|
describe "cbAuthenticate" $ do
|
|
it "matches Haskell output" $ do
|
|
let msg = "hello world authenticator test"
|
|
C.CbAuthenticator expected =
|
|
C.cbAuthenticate
|
|
(C.PublicKeyX25519 pubA)
|
|
(C.PrivateKeyX25519 privB)
|
|
(C.cbNonce nonce24)
|
|
msg
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const auth = Cli.cbAuthenticate("
|
|
<> jsUint8 pubARaw
|
|
<> ","
|
|
<> jsUint8 privBRaw
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 msg
|
|
<> ");"
|
|
<> jsOut "auth"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "is 80 bytes" $ do
|
|
let msg = "size test"
|
|
C.CbAuthenticator expected =
|
|
C.cbAuthenticate
|
|
(C.PublicKeyX25519 pubA)
|
|
(C.PrivateKeyX25519 privB)
|
|
(C.cbNonce nonce24)
|
|
msg
|
|
B.length expected `shouldBe` 80
|
|
|
|
describe "cbVerify" $ do
|
|
it "validates Haskell authenticator" $ do
|
|
let msg = "test message for verify"
|
|
C.CbAuthenticator authBytes_ =
|
|
C.cbAuthenticate
|
|
(C.PublicKeyX25519 pubA)
|
|
(C.PrivateKeyX25519 privB)
|
|
(C.cbNonce nonce24)
|
|
msg
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const valid = Cli.cbVerify("
|
|
<> jsUint8 pubBRaw
|
|
<> ","
|
|
<> jsUint8 privARaw
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 authBytes_
|
|
<> ","
|
|
<> jsUint8 msg
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([valid ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "rejects wrong message" $ do
|
|
let msg = "correct message"
|
|
wrongMsg = "wrong message"
|
|
C.CbAuthenticator authBytes_ =
|
|
C.cbAuthenticate
|
|
(C.PublicKeyX25519 pubA)
|
|
(C.PrivateKeyX25519 privB)
|
|
(C.cbNonce nonce24)
|
|
msg
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const valid = Cli.cbVerify("
|
|
<> jsUint8 pubBRaw
|
|
<> ","
|
|
<> jsUint8 privARaw
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 authBytes_
|
|
<> ","
|
|
<> jsUint8 wrongMsg
|
|
<> ");"
|
|
<> jsOut "new Uint8Array([valid ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
it "round-trip: TS authenticate, Haskell verify" $ do
|
|
let msg = "round trip test"
|
|
tsAuth <-
|
|
callNode $
|
|
impCli
|
|
<> "const auth = Cli.cbAuthenticate("
|
|
<> jsUint8 pubARaw
|
|
<> ","
|
|
<> jsUint8 privBRaw
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 msg
|
|
<> ");"
|
|
<> jsOut "auth"
|
|
let hsValid =
|
|
C.cbVerify
|
|
(C.PublicKeyX25519 pubB)
|
|
(C.PrivateKeyX25519 privA)
|
|
(C.cbNonce nonce24)
|
|
(C.CbAuthenticator tsAuth)
|
|
msg
|
|
hsValid `shouldBe` True
|
|
|
|
describe "transport chunk encryption" $ do
|
|
let dhSecret = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
dhSecretBytes = case dhSecret of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
|
|
it "encryptTransportChunk matches Haskell" $ do
|
|
let plaintext = B.pack [100 .. 199]
|
|
state0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(cipher, state1) = LC.sbEncryptChunk state0 plaintext
|
|
tag = BA.convert $ LC.sbAuth state1 :: B.ByteString
|
|
expected = cipher <> tag
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const enc = Cli.encryptTransportChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 plaintext
|
|
<> ");"
|
|
<> jsOut "enc"
|
|
tsResult `shouldBe` expected
|
|
|
|
it "decryptTransportChunk decrypts Haskell-encrypted data" $ do
|
|
let plaintext = B.pack ([200 .. 255] <> [0 .. 99])
|
|
state0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(cipher, state1) = LC.sbEncryptChunk state0 plaintext
|
|
tag = BA.convert $ LC.sbAuth state1 :: B.ByteString
|
|
encData = cipher <> tag
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const r = Cli.decryptTransportChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 encData
|
|
<> ");"
|
|
<> "if (!r.valid) throw new Error('invalid');"
|
|
<> jsOut "r.content"
|
|
tsResult `shouldBe` plaintext
|
|
|
|
it "round-trip encrypt then decrypt" $ do
|
|
let plaintext = B.pack [42, 42, 42, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const plain = "
|
|
<> jsUint8 plaintext
|
|
<> ";"
|
|
<> "const enc = Cli.encryptTransportChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ",plain);"
|
|
<> "const r = Cli.decryptTransportChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ",enc);"
|
|
<> "if (!r.valid) throw new Error('invalid');"
|
|
<> jsOut "r.content"
|
|
tsResult `shouldBe` plaintext
|
|
|
|
it "rejects tampered ciphertext" $ do
|
|
let plaintext = B.pack [10 .. 40]
|
|
tsResult <-
|
|
callNode $
|
|
impCli
|
|
<> "const enc = Cli.encryptTransportChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 plaintext
|
|
<> ");"
|
|
<> "enc[0] ^= 0xff;"
|
|
<> "const r = Cli.decryptTransportChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ",enc);"
|
|
<> jsOut "new Uint8Array([r.valid ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [0]
|
|
|
|
describe "constants" $ do
|
|
it "cbAuthenticatorSize" $ do
|
|
tsResult <-
|
|
callNode $
|
|
impCli <> jsOut "new TextEncoder().encode(String(Cli.cbAuthenticatorSize))"
|
|
tsResult `shouldBe` "80"
|
|
|
|
-- ── download (integration) ──────────────────────────────────────────
|
|
|
|
tsDownloadTests :: Spec
|
|
tsDownloadTests = describe "download" $ do
|
|
-- Fixed X25519 key pairs (same as client tests)
|
|
let privARaw = B.pack [1 .. 32]
|
|
privA = throwCryptoError $ X25519.secretKey privARaw
|
|
pubA = X25519.toPublic privA
|
|
pubARaw = BA.convert pubA :: B.ByteString
|
|
privBRaw = B.pack [33 .. 64]
|
|
privB = throwCryptoError $ X25519.secretKey privBRaw
|
|
pubB = X25519.toPublic privB
|
|
pubBRaw = BA.convert pubB :: B.ByteString
|
|
nonce24 = B.pack [0 .. 23]
|
|
-- File-level key/nonce (different from transport)
|
|
fileKey32 = B.pack [1 .. 32]
|
|
fileNonce24 = B.pack [1 .. 24]
|
|
fileCbNonce = C.cbNonce fileNonce24
|
|
fileSbKey = C.unsafeSbKey fileKey32
|
|
|
|
describe "processFileResponse" $ do
|
|
it "derives DH secret matching Haskell" $ do
|
|
-- Simulate: client has privA, server sends pubB
|
|
let hsDhSecret = C.dh' (C.PublicKeyX25519 pubB) (C.PrivateKeyX25519 privA)
|
|
hsDhBytes = case hsDhSecret of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
tsDhSecret <-
|
|
callNode $
|
|
impDl
|
|
<> "const dh = Dl.processFileResponse("
|
|
<> jsUint8 privARaw
|
|
<> ","
|
|
<> jsUint8 pubBRaw
|
|
<> ");"
|
|
<> jsOut "dh"
|
|
tsDhSecret `shouldBe` hsDhBytes
|
|
|
|
describe "decryptReceivedChunk" $ do
|
|
it "transport decrypt with digest verification" $ do
|
|
-- Haskell: transport-encrypt a chunk
|
|
let dhSecret = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
dhSecretBytes = case dhSecret of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
chunkData = B.pack [50 .. 149]
|
|
chunkDigest = C.sha256Hash chunkData
|
|
state0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(cipher, state1) = LC.sbEncryptChunk state0 chunkData
|
|
tag = BA.convert (LC.sbAuth state1) :: B.ByteString
|
|
encData = cipher <> tag
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "const r = Dl.decryptReceivedChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 encData
|
|
<> ","
|
|
<> jsUint8 chunkDigest
|
|
<> ");"
|
|
<> jsOut "r"
|
|
tsResult `shouldBe` chunkData
|
|
|
|
it "rejects wrong digest" $ do
|
|
let dhSecret = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
dhSecretBytes = case dhSecret of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
chunkData = B.pack [50 .. 149]
|
|
wrongDigest = B.replicate 32 0xff
|
|
state0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(cipher, state1) = LC.sbEncryptChunk state0 chunkData
|
|
tag = BA.convert (LC.sbAuth state1) :: B.ByteString
|
|
encData = cipher <> tag
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "let ok = false; try { Dl.decryptReceivedChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 encData
|
|
<> ","
|
|
<> jsUint8 wrongDigest
|
|
<> "); } catch(e) { ok = e.message.includes('digest'); }"
|
|
<> jsOut "new Uint8Array([ok ? 1 : 0])"
|
|
tsResult `shouldBe` B.pack [1]
|
|
|
|
it "allows null digest (skip verification)" $ do
|
|
let dhSecret = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
dhSecretBytes = case dhSecret of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
chunkData = B.pack [10 .. 50]
|
|
state0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(cipher, state1) = LC.sbEncryptChunk state0 chunkData
|
|
tag = BA.convert (LC.sbAuth state1) :: B.ByteString
|
|
encData = cipher <> tag
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "const r = Dl.decryptReceivedChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 encData
|
|
<> ",null);"
|
|
<> jsOut "r"
|
|
tsResult `shouldBe` chunkData
|
|
|
|
describe "full pipeline" $ do
|
|
it "Haskell file-encrypt + transport-encrypt -> TS transport-decrypt + file-decrypt" $ do
|
|
-- Step 1: file-level encryption (matches Haskell encryptFile)
|
|
let source = "Integration test: full download pipeline!" :: B.ByteString
|
|
hdr = FileHeader "pipeline.txt" Nothing
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 256 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit fileSbKey fileCbNonce
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
fileTag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
fileEncrypted = B.concat [hdrEnc, srcEnc, padEnc, fileTag]
|
|
-- Step 2: transport-level encryption (simulates server sending chunk)
|
|
let dhSecret = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
dhSecretBytes = case dhSecret of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
ts0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(transportCipher, ts1) = LC.sbEncryptChunk ts0 fileEncrypted
|
|
transportTag = BA.convert (LC.sbAuth ts1) :: B.ByteString
|
|
transportEncData = transportCipher <> transportTag
|
|
-- Step 3: TS decrypts transport, then file-level
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "const chunk = Dl.decryptReceivedChunk("
|
|
<> jsUint8 dhSecretBytes
|
|
<> ","
|
|
<> jsUint8 nonce24
|
|
<> ","
|
|
<> jsUint8 transportEncData
|
|
<> ",null);"
|
|
<> "const r = F.decryptChunks("
|
|
<> show encSize
|
|
<> "n,[chunk],"
|
|
<> jsUint8 fileKey32
|
|
<> ","
|
|
<> jsUint8 fileNonce24
|
|
<> ");"
|
|
<> "const hdrBytes = F.encodeFileHeader(r.header);"
|
|
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
|
|
tsResult `shouldBe` (fileHdr <> source)
|
|
|
|
it "multi-chunk file: Haskell encrypt -> TS decrypt" $ do
|
|
-- File content that spans two chunks when file-encrypted
|
|
let source = B.pack (take 200 $ cycle [0 .. 255])
|
|
hdr = FileHeader "multi.bin" Nothing
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 512 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit fileSbKey fileCbNonce
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
fileTag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
fileEncrypted = B.concat [hdrEnc, srcEnc, padEnc, fileTag]
|
|
-- Split file-encrypted data into two "chunks" and transport-encrypt each
|
|
let splitPt = B.length fileEncrypted `div` 2
|
|
fileChunk1 = B.take splitPt fileEncrypted
|
|
fileChunk2 = B.drop splitPt fileEncrypted
|
|
-- Transport encrypt chunk 1 (with separate DH / nonce per chunk)
|
|
dhSecret1 = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
dhSecret1Bytes = case dhSecret1 of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
nonce1 = nonce24
|
|
t1s0 = either (error . show) id $ LC.cbInit dhSecret1 (C.cbNonce nonce1)
|
|
(t1cipher, t1s1) = LC.sbEncryptChunk t1s0 fileChunk1
|
|
t1tag = BA.convert (LC.sbAuth t1s1) :: B.ByteString
|
|
transportEnc1 = t1cipher <> t1tag
|
|
-- Transport encrypt chunk 2 (different nonce)
|
|
nonce2 = B.pack [24 .. 47]
|
|
dhSecret2 = C.dh' (C.PublicKeyX25519 pubB) (C.PrivateKeyX25519 privA)
|
|
dhSecret2Bytes = case dhSecret2 of C.DhSecretX25519 k -> BA.convert k :: B.ByteString
|
|
t2s0 = either (error . show) id $ LC.cbInit dhSecret2 (C.cbNonce nonce2)
|
|
(t2cipher, t2s1) = LC.sbEncryptChunk t2s0 fileChunk2
|
|
t2tag = BA.convert (LC.sbAuth t2s1) :: B.ByteString
|
|
transportEnc2 = t2cipher <> t2tag
|
|
-- TS: transport-decrypt each chunk, then file-level decrypt the concatenation
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "const c1 = Dl.decryptReceivedChunk("
|
|
<> jsUint8 dhSecret1Bytes
|
|
<> ","
|
|
<> jsUint8 nonce1
|
|
<> ","
|
|
<> jsUint8 transportEnc1
|
|
<> ",null);"
|
|
<> "const c2 = Dl.decryptReceivedChunk("
|
|
<> jsUint8 dhSecret2Bytes
|
|
<> ","
|
|
<> jsUint8 nonce2
|
|
<> ","
|
|
<> jsUint8 transportEnc2
|
|
<> ",null);"
|
|
<> "const r = F.decryptChunks("
|
|
<> show encSize
|
|
<> "n,[c1,c2],"
|
|
<> jsUint8 fileKey32
|
|
<> ","
|
|
<> jsUint8 fileNonce24
|
|
<> ");"
|
|
<> "const hdrBytes = F.encodeFileHeader(r.header);"
|
|
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
|
|
tsResult `shouldBe` (fileHdr <> source)
|
|
|
|
describe "FGET + FRFile round-trip" $ do
|
|
it "encode FGET -> decode FRFile -> process -> transport decrypt" $ do
|
|
-- Client side: generate FGET command
|
|
let dhSecret = C.dh' (C.PublicKeyX25519 pubA) (C.PrivateKeyX25519 privB)
|
|
chunkData = "FGET round-trip test data" :: B.ByteString
|
|
state0 = either (error . show) id $ LC.cbInit dhSecret (C.cbNonce nonce24)
|
|
(cipher, state1) = LC.sbEncryptChunk state0 chunkData
|
|
tag = BA.convert (LC.sbAuth state1) :: B.ByteString
|
|
encData = cipher <> tag
|
|
-- Simulate server response: FILE <serverPubKey> <nonce>
|
|
-- Server sends pubA (client has privB to do DH)
|
|
serverPubDer = C.encodePubKey (C.PublicKeyX25519 pubA)
|
|
fileResponseBytes = "FILE " <> smpEncode serverPubDer <> nonce24
|
|
-- TS: parse FRFile response, derive DH secret, decrypt transport chunk
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "const resp = Cmd.decodeResponse("
|
|
<> jsUint8 fileResponseBytes
|
|
<> ");"
|
|
<> "if (resp.type !== 'FRFile') throw new Error('expected FRFile');"
|
|
<> "const dhSecret = Dl.processFileResponse("
|
|
<> jsUint8 privBRaw
|
|
<> ",resp.rcvDhKey);"
|
|
<> "const r = Dl.decryptReceivedChunk(dhSecret,"
|
|
<> "resp.nonce,"
|
|
<> jsUint8 encData
|
|
<> ",null);"
|
|
<> jsOut "r"
|
|
tsResult `shouldBe` chunkData
|
|
|
|
describe "processDownloadedFile" $ do
|
|
it "decrypts file from transport-decrypted chunks" $ do
|
|
let source = "processDownloadedFile test" :: B.ByteString
|
|
hdr = FileHeader "download.txt" (Just "v1")
|
|
fileHdr = smpEncode hdr
|
|
fileSize' = fromIntegral (B.length fileHdr + B.length source) :: Int64
|
|
encSize = 256 :: Int64
|
|
sb = either (error . show) id $ LC.sbInit fileSbKey fileCbNonce
|
|
lenStr = smpEncode fileSize'
|
|
(hdrEnc, sb1) = LC.sbEncryptChunk sb (lenStr <> fileHdr)
|
|
(srcEnc, sb2) = LC.sbEncryptChunk sb1 source
|
|
padLen = encSize - 16 - fileSize' - 8
|
|
padding = B.replicate (fromIntegral padLen) 0x23
|
|
(padEnc, sb3) = LC.sbEncryptChunk sb2 padding
|
|
fileTag = BA.convert (LC.sbAuth sb3) :: B.ByteString
|
|
fileEncrypted = B.concat [hdrEnc, srcEnc, padEnc, fileTag]
|
|
-- TS: call processDownloadedFile with a minimal FileDescription-like object
|
|
tsResult <-
|
|
callNode $
|
|
impDl
|
|
<> "const fd = {size: "
|
|
<> show encSize
|
|
<> ","
|
|
<> "key: "
|
|
<> jsUint8 fileKey32
|
|
<> ","
|
|
<> "nonce: "
|
|
<> jsUint8 fileNonce24
|
|
<> "};"
|
|
<> "const r = Dl.processDownloadedFile(fd, ["
|
|
<> jsUint8 fileEncrypted
|
|
<> "]);"
|
|
<> "const hdrBytes = F.encodeFileHeader(r.header);"
|
|
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
|
|
tsResult `shouldBe` (fileHdr <> source)
|
|
|
|
-- ── protocol/address ──────────────────────────────────────────────
|
|
|
|
tsAddressTests :: Spec
|
|
tsAddressTests = describe "protocol/address" $ do
|
|
it "parseXFTPServer with port" $ do
|
|
let addr = "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:8000" :: String
|
|
expectedKH :: B.ByteString
|
|
expectedKH = either error id $ strDecode "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI="
|
|
result <-
|
|
callNode $
|
|
impAddr
|
|
<> "const s = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');"
|
|
<> jsOut "new Uint8Array([...s.keyHash, ...new TextEncoder().encode(s.host + ':' + s.port)])"
|
|
let (kh, hostPort) = B.splitAt 32 result
|
|
kh `shouldBe` expectedKH
|
|
hostPort `shouldBe` "localhost:8000"
|
|
|
|
it "parseXFTPServer default port" $ do
|
|
result <-
|
|
callNode $
|
|
impAddr
|
|
<> "const s = Addr.parseXFTPServer('xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@example.com');"
|
|
<> jsOut "new TextEncoder().encode(s.host + ':' + s.port)"
|
|
result `shouldBe` "example.com:443"
|
|
|
|
it "parseXFTPServer multi-host takes first" $ do
|
|
result <-
|
|
callNode $
|
|
impAddr
|
|
<> "const s = Addr.parseXFTPServer('xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@host1.com:5000,host2.com');"
|
|
<> jsOut "new TextEncoder().encode(s.host + ':' + s.port)"
|
|
result `shouldBe` "host1.com:5000"
|
|
|
|
-- ── integration ───────────────────────────────────────────────────
|
|
|
|
tsIntegrationTests :: IO () -> Spec
|
|
tsIntegrationTests dbCleanup = describe "integration" $
|
|
around_ testBracket . after_ dbCleanup $ do
|
|
it "web handshake with Ed25519 identity verification" $
|
|
webHandshakeTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
|
it "web handshake with Ed448 identity verification" $
|
|
webHandshakeTest testXFTPServerConfigSNI "tests/fixtures/ca.crt"
|
|
it "connectXFTP + pingXFTP" $
|
|
pingTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
|
it "full round-trip: create, upload, download, ack, addRecipients, delete" $
|
|
fullRoundTripTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
|
it "agent URI round-trip" agentURIRoundTripTest
|
|
it "agent upload + download round-trip" $
|
|
agentUploadDownloadTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
|
it "agent delete + verify gone" $
|
|
agentDeleteTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
|
it "agent redirect: upload with redirect, download" $
|
|
agentRedirectTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
|
it "cross-language: TS upload, Haskell download" $
|
|
tsUploadHaskellDownloadTest testXFTPServerConfigSNI "tests/fixtures/ca.crt"
|
|
it "cross-language: TS upload with redirect, Haskell download" $
|
|
tsUploadRedirectHaskellDownloadTest testXFTPServerConfigSNI "tests/fixtures/ca.crt"
|
|
it "cross-language: Haskell upload, TS download" $
|
|
haskellUploadTsDownloadTest testXFTPServerConfigSNI
|
|
|
|
webHandshakeTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
webHandshakeTest cfg caFile = do
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
result <-
|
|
callNode $
|
|
"import http2 from 'node:http2';\
|
|
\import crypto from 'node:crypto';\
|
|
\import sodium from 'libsodium-wrappers-sumo';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as Hs from './dist/protocol/handshake.js';\
|
|
\import * as Id from './dist/crypto/identity.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const readBody = s => new Promise((ok, err) => {\
|
|
\const c = [];\
|
|
\s.on('data', d => c.push(d));\
|
|
\s.on('end', () => ok(Buffer.concat(c)));\
|
|
\s.on('error', err);\
|
|
\});\
|
|
\const client = http2.connect('https://' + server.host + ':' + server.port, {rejectUnauthorized: false});\
|
|
\const challenge = new Uint8Array(crypto.randomBytes(32));\
|
|
\const s1 = client.request({':method': 'POST', ':path': '/', 'xftp-web-hello': '1'});\
|
|
\s1.end(Buffer.from(Hs.encodeClientHello({webChallenge: challenge})));\
|
|
\const hs = Hs.decodeServerHandshake(new Uint8Array(await readBody(s1)));\
|
|
\const idOk = hs.webIdentityProof\
|
|
\ ? Id.verifyIdentityProof({certChainDer: hs.certChainDer, signedKeyDer: hs.signedKeyDer,\
|
|
\sigBytes: hs.webIdentityProof, challenge, sessionId: hs.sessionId, keyHash: server.keyHash})\
|
|
\ : false;\
|
|
\const ver = hs.xftpVersionRange.maxVersion;\
|
|
\const s2 = client.request({':method': 'POST', ':path': '/', 'xftp-handshake': '1'});\
|
|
\s2.end(Buffer.from(Hs.encodeClientHandshake({xftpVersion: ver, keyHash: server.keyHash})));\
|
|
\const ack = await readBody(s2);\
|
|
\client.close();"
|
|
<> jsOut "new Uint8Array([idOk ? 1 : 0, ack.length === 0 ? 1 : 0])"
|
|
result `shouldBe` B.pack [1, 1]
|
|
|
|
pingTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
pingTest cfg caFile = do
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
result <-
|
|
callNode $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import {newXFTPAgent, closeXFTPAgent} from './dist/client.js';\
|
|
\import {pingXFTP} from './dist/client.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = newXFTPAgent();\
|
|
\await pingXFTP(agent, server);\
|
|
\closeXFTPAgent(agent);"
|
|
<> jsOut "new Uint8Array([1])"
|
|
result `shouldBe` B.pack [1]
|
|
|
|
fullRoundTripTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
fullRoundTripTest cfg caFile = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
result <-
|
|
callNode $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import crypto from 'node:crypto';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as K from './dist/crypto/keys.js';\
|
|
\import {sha256} from './dist/crypto/digest.js';\
|
|
\import {newXFTPAgent, closeXFTPAgent, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk,\
|
|
\ addXFTPRecipients, deleteXFTPChunk} from './dist/client.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = newXFTPAgent();\
|
|
\const sndKp = K.generateEd25519KeyPair();\
|
|
\const rcvKp1 = K.generateEd25519KeyPair();\
|
|
\const rcvKp2 = K.generateEd25519KeyPair();\
|
|
\const chunkData = new Uint8Array(crypto.randomBytes(65536));\
|
|
\const digest = sha256(chunkData);\
|
|
\const file = {\
|
|
\ sndKey: K.encodePubKeyEd25519(sndKp.publicKey),\
|
|
\ size: chunkData.length,\
|
|
\ digest\
|
|
\};\
|
|
\const rcvKeys = [K.encodePubKeyEd25519(rcvKp1.publicKey)];\
|
|
\const {senderId, recipientIds} = await createXFTPChunk(agent, server, sndKp.privateKey, file, rcvKeys, null);\
|
|
\await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData);\
|
|
\const dl1 = await downloadXFTPChunk(agent, server, rcvKp1.privateKey, recipientIds[0], digest);\
|
|
\const match1 = dl1.length === chunkData.length && dl1.every((b, i) => b === chunkData[i]);\
|
|
\const newIds = await addXFTPRecipients(agent, server, sndKp.privateKey, senderId,\
|
|
\ [K.encodePubKeyEd25519(rcvKp2.publicKey)]);\
|
|
\const dl2 = await downloadXFTPChunk(agent, server, rcvKp2.privateKey, newIds[0], digest);\
|
|
\const match2 = dl2.length === chunkData.length && dl2.every((b, i) => b === chunkData[i]);\
|
|
\await deleteXFTPChunk(agent, server, sndKp.privateKey, senderId);\
|
|
\closeXFTPAgent(agent);"
|
|
<> jsOut "new Uint8Array([match1 ? 1 : 0, match2 ? 1 : 0])"
|
|
result `shouldBe` B.pack [1, 1]
|
|
|
|
agentURIRoundTripTest :: Expectation
|
|
agentURIRoundTripTest = do
|
|
result <-
|
|
callNode $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\import * as Desc from './dist/protocol/description.js';\
|
|
\await sodium.ready;\
|
|
\const fd = {\
|
|
\ party: 'recipient',\
|
|
\ size: 65536,\
|
|
\ digest: new Uint8Array(64).fill(0xab),\
|
|
\ key: new Uint8Array(32).fill(0x01),\
|
|
\ nonce: new Uint8Array(24).fill(0x02),\
|
|
\ chunkSize: 65536,\
|
|
\ chunks: [{\
|
|
\ chunkNo: 1,\
|
|
\ chunkSize: 65536,\
|
|
\ digest: new Uint8Array(32).fill(0xcd),\
|
|
\ replicas: [{\
|
|
\ server: 'xftp://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=@example.com:443',\
|
|
\ replicaId: new Uint8Array([1,2,3]),\
|
|
\ replicaKey: new Uint8Array([48,46,2,1,0,48,5,6,3,43,101,112,4,34,4,32,\
|
|
\ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32])\
|
|
\ }]\
|
|
\ }],\
|
|
\ redirect: null\
|
|
\};\
|
|
\const uri = Agent.encodeDescriptionURI(fd);\
|
|
\const fd2 = Agent.decodeDescriptionURI(uri);\
|
|
\const yaml1 = Desc.encodeFileDescription(fd);\
|
|
\const yaml2 = Desc.encodeFileDescription(fd2);\
|
|
\const match = yaml1 === yaml2 ? 1 : 0;"
|
|
<> jsOut "new Uint8Array([match])"
|
|
result `shouldBe` B.pack [1]
|
|
|
|
agentUploadDownloadTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
agentUploadDownloadTest cfg caFile = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
result <-
|
|
callNode $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import crypto from 'node:crypto';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = Agent.newXFTPAgent();\
|
|
\const originalData = new Uint8Array(crypto.randomBytes(50000));\
|
|
\const encrypted = Agent.encryptFileForUpload(originalData, 'test-file.bin');\
|
|
\const {rcvDescription, sndDescription, uri} = await Agent.uploadFile(agent, [server], encrypted);\
|
|
\const fd = Agent.decodeDescriptionURI(uri);\
|
|
\const {header, content} = await Agent.downloadFile(agent, fd);\
|
|
\Agent.closeXFTPAgent(agent);\
|
|
\const nameMatch = header.fileName === 'test-file.bin' ? 1 : 0;\
|
|
\const sizeMatch = content.length === originalData.length ? 1 : 0;\
|
|
\let dataMatch = 1;\
|
|
\for (let i = 0; i < content.length; i++) {\
|
|
\ if (content[i] !== originalData[i]) { dataMatch = 0; break; }\
|
|
\};"
|
|
<> jsOut "new Uint8Array([nameMatch, sizeMatch, dataMatch])"
|
|
result `shouldBe` B.pack [1, 1, 1]
|
|
|
|
agentDeleteTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
agentDeleteTest cfg caFile = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
result <-
|
|
callNode $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import crypto from 'node:crypto';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = Agent.newXFTPAgent();\
|
|
\const originalData = new Uint8Array(crypto.randomBytes(50000));\
|
|
\const encrypted = Agent.encryptFileForUpload(originalData, 'del-test.bin');\
|
|
\const {rcvDescription, sndDescription} = await Agent.uploadFile(agent, [server], encrypted);\
|
|
\await Agent.deleteFile(agent, sndDescription);\
|
|
\let deleted = 0;\
|
|
\try {\
|
|
\ await Agent.downloadFile(agent, rcvDescription);\
|
|
\} catch (e) {\
|
|
\ deleted = 1;\
|
|
\}\
|
|
\Agent.closeXFTPAgent(agent);"
|
|
<> jsOut "new Uint8Array([deleted])"
|
|
result `shouldBe` B.pack [1]
|
|
|
|
agentRedirectTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
agentRedirectTest cfg caFile = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
result <-
|
|
callNode $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import crypto from 'node:crypto';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = Agent.newXFTPAgent();\
|
|
\const originalData = new Uint8Array(crypto.randomBytes(100000));\
|
|
\const encrypted = Agent.encryptFileForUpload(originalData, 'redirect-test.bin');\
|
|
\const {rcvDescription, uri} = await Agent.uploadFile(agent, [server], encrypted, {redirectThreshold: 50});\
|
|
\const fd = Agent.decodeDescriptionURI(uri);\
|
|
\const hasRedirect = fd.redirect !== null ? 1 : 0;\
|
|
\const {header, content} = await Agent.downloadFile(agent, fd);\
|
|
\Agent.closeXFTPAgent(agent);\
|
|
\const nameMatch = header.fileName === 'redirect-test.bin' ? 1 : 0;\
|
|
\const sizeMatch = content.length === originalData.length ? 1 : 0;\
|
|
\let dataMatch = 1;\
|
|
\for (let i = 0; i < content.length; i++) {\
|
|
\ if (content[i] !== originalData[i]) { dataMatch = 0; break; }\
|
|
\};"
|
|
<> jsOut "new Uint8Array([hasRedirect, nameMatch, sizeMatch, dataMatch])"
|
|
result `shouldBe` B.pack [1, 1, 1, 1]
|
|
|
|
tsUploadHaskellDownloadTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
tsUploadHaskellDownloadTest cfg caFile = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
createDirectoryIfMissing False recipientFiles
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
(yamlDesc, originalData) <-
|
|
callNode2 $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import crypto from 'node:crypto';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\import {encodeFileDescription} from './dist/protocol/description.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = Agent.newXFTPAgent();\
|
|
\const originalData = new Uint8Array(crypto.randomBytes(50000));\
|
|
\const encrypted = Agent.encryptFileForUpload(originalData, 'ts-to-hs.bin');\
|
|
\const {rcvDescription} = await Agent.uploadFile(agent, [server], encrypted);\
|
|
\Agent.closeXFTPAgent(agent);\
|
|
\const yaml = encodeFileDescription(rcvDescription);"
|
|
<> jsOut2 "Buffer.from(yaml)" "Buffer.from(originalData)"
|
|
let vfd :: ValidFileDescription 'FRecipient = either error id $ strDecode yamlDesc
|
|
withAgent 1 agentCfg initAgentServers testDB $ \rcp -> do
|
|
runRight_ $ xftpStartWorkers rcp (Just recipientFiles)
|
|
_ <- runRight $ xftpReceiveFile rcp 1 vfd Nothing True
|
|
rfProgress rcp 50000
|
|
(_, _, RFDONE outPath) <- rfGet rcp
|
|
downloadedData <- B.readFile outPath
|
|
downloadedData `shouldBe` originalData
|
|
|
|
tsUploadRedirectHaskellDownloadTest :: XFTPServerConfig -> FilePath -> Expectation
|
|
tsUploadRedirectHaskellDownloadTest cfg caFile = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
createDirectoryIfMissing False recipientFiles
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
Fingerprint fp <- loadFileFingerprint caFile
|
|
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
|
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
|
(yamlDesc, originalData) <-
|
|
callNode2 $
|
|
"import sodium from 'libsodium-wrappers-sumo';\
|
|
\import crypto from 'node:crypto';\
|
|
\import * as Addr from './dist/protocol/address.js';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\import {encodeFileDescription} from './dist/protocol/description.js';\
|
|
\await sodium.ready;\
|
|
\const server = Addr.parseXFTPServer('"
|
|
<> addr
|
|
<> "');\
|
|
\const agent = Agent.newXFTPAgent();\
|
|
\const originalData = new Uint8Array(crypto.randomBytes(100000));\
|
|
\const encrypted = Agent.encryptFileForUpload(originalData, 'ts-redirect-to-hs.bin');\
|
|
\const {rcvDescription} = await Agent.uploadFile(agent, [server], encrypted, {redirectThreshold: 50});\
|
|
\Agent.closeXFTPAgent(agent);\
|
|
\const yaml = encodeFileDescription(rcvDescription);"
|
|
<> jsOut2 "Buffer.from(yaml)" "Buffer.from(originalData)"
|
|
let vfd@(ValidFileDescription fd) :: ValidFileDescription 'FRecipient = either error id $ strDecode yamlDesc
|
|
redirect fd `shouldSatisfy` (/= Nothing)
|
|
withAgent 1 agentCfg initAgentServers testDB $ \rcp -> do
|
|
runRight_ $ xftpStartWorkers rcp (Just recipientFiles)
|
|
_ <- runRight $ xftpReceiveFile rcp 1 vfd Nothing True
|
|
outPath <- waitRfDone rcp
|
|
downloadedData <- B.readFile outPath
|
|
downloadedData `shouldBe` originalData
|
|
|
|
haskellUploadTsDownloadTest :: XFTPServerConfig -> Expectation
|
|
haskellUploadTsDownloadTest cfg = do
|
|
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
|
createDirectoryIfMissing False senderFiles
|
|
let filePath = senderFiles <> "/hs-to-ts.bin"
|
|
originalData <- B.pack <$> replicateM 50000 (randomIO :: IO Word8)
|
|
B.writeFile filePath originalData
|
|
withXFTPServerCfg cfg $ \_ -> do
|
|
vfd <- withAgent 1 agentCfg initAgentServers testDB $ \sndr -> do
|
|
runRight_ $ xftpStartWorkers sndr (Just senderFiles)
|
|
_ <- runRight $ xftpSendFile sndr 1 (CF.plain filePath) 1
|
|
sfProgress sndr 50000
|
|
(_, _, SFDONE _ [rfd]) <- sfGet sndr
|
|
pure rfd
|
|
let yamlDesc = strEncode vfd
|
|
tmpYaml = "tests/tmp/hs-to-ts-desc.yaml"
|
|
tmpData = "tests/tmp/hs-to-ts-data.bin"
|
|
B.writeFile tmpYaml yamlDesc
|
|
B.writeFile tmpData originalData
|
|
result <-
|
|
callNode $
|
|
"import fs from 'node:fs';\
|
|
\import sodium from 'libsodium-wrappers-sumo';\
|
|
\import * as Agent from './dist/agent.js';\
|
|
\import {decodeFileDescription, validateFileDescription} from './dist/protocol/description.js';\
|
|
\await sodium.ready;\
|
|
\const yaml = fs.readFileSync('../tests/tmp/hs-to-ts-desc.yaml', 'utf-8');\
|
|
\const expected = new Uint8Array(fs.readFileSync('../tests/tmp/hs-to-ts-data.bin'));\
|
|
\const fd = decodeFileDescription(yaml);\
|
|
\const err = validateFileDescription(fd);\
|
|
\if (err) throw new Error(err);\
|
|
\const agent = Agent.newXFTPAgent();\
|
|
\const {header, content} = await Agent.downloadFile(agent, fd);\
|
|
\Agent.closeXFTPAgent(agent);\
|
|
\const nameMatch = header.fileName === 'hs-to-ts.bin' ? 1 : 0;\
|
|
\const sizeMatch = content.length === expected.length ? 1 : 0;\
|
|
\let dataMatch = 1;\
|
|
\for (let i = 0; i < content.length; i++) {\
|
|
\ if (content[i] !== expected[i]) { dataMatch = 0; break; }\
|
|
\};"
|
|
<> jsOut "new Uint8Array([nameMatch, sizeMatch, dataMatch])"
|
|
result `shouldBe` B.pack [1, 1, 1]
|
|
|
|
rfProgress :: AgentClient -> Int64 -> IO ()
|
|
rfProgress c _expected = loop 0
|
|
where
|
|
loop prev = do
|
|
(_, _, RFPROG rcvd total) <- rfGet c
|
|
when (rcvd < total && rcvd > prev) $ loop rcvd
|
|
|
|
sfProgress :: AgentClient -> Int64 -> IO ()
|
|
sfProgress c _expected = loop 0
|
|
where
|
|
loop prev = do
|
|
(_, _, SFPROG sent total) <- sfGet c
|
|
when (sent < total && sent > prev) $ loop sent
|
|
|
|
waitRfDone :: AgentClient -> IO FilePath
|
|
waitRfDone c = do
|
|
ev <- rfGet c
|
|
case ev of
|
|
(_, _, RFDONE outPath) -> pure outPath
|
|
(_, _, RFPROG _ _) -> waitRfDone c
|
|
(_, _, RFERR e) -> error $ "RFERR: " <> show e
|
|
_ -> error $ "Unexpected event: " <> show ev
|
|
|
|
callNode2 :: String -> IO (B.ByteString, B.ByteString)
|
|
callNode2 script = do
|
|
out <- callNode script
|
|
let (len1Bytes, rest1) = B.splitAt 4 out
|
|
len1 = fromIntegral (B.index len1Bytes 0) + fromIntegral (B.index len1Bytes 1) * 256 + fromIntegral (B.index len1Bytes 2) * 65536 + fromIntegral (B.index len1Bytes 3) * 16777216
|
|
(data1, rest2) = B.splitAt len1 rest1
|
|
(len2Bytes, rest3) = B.splitAt 4 rest2
|
|
len2 = fromIntegral (B.index len2Bytes 0) + fromIntegral (B.index len2Bytes 1) * 256 + fromIntegral (B.index len2Bytes 2) * 65536 + fromIntegral (B.index len2Bytes 3) * 16777216
|
|
data2 = B.take len2 rest3
|
|
pure (data1, data2)
|
|
|
|
jsOut2 :: String -> String -> String
|
|
jsOut2 a b = "const __a = " <> a <> "; const __b = " <> b <> "; const __buf = Buffer.alloc(8 + __a.length + __b.length); __buf.writeUInt32LE(__a.length, 0); __a.copy(__buf, 4); __buf.writeUInt32LE(__b.length, 4 + __a.length); __b.copy(__buf, 8 + __a.length); process.stdout.write(__buf);"
|