mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-24 03:20:41 +00:00
support description "redirect" in agent.ts and cross-platform compatibility tests (Haskell <> TypeScript)
This commit is contained in:
+300
-3
@@ -1,4 +1,8 @@
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PatternSynonyms #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | Per-function tests for the xftp-web TypeScript XFTP client library.
|
||||
@@ -10,7 +14,7 @@
|
||||
module XFTPWebTests (xftpWebTests) where
|
||||
|
||||
import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
|
||||
import Control.Monad (when)
|
||||
import Control.Monad (replicateM, when)
|
||||
import Crypto.Error (throwCryptoError)
|
||||
import qualified Crypto.PubKey.Curve25519 as X25519
|
||||
import qualified Crypto.PubKey.Ed25519 as Ed25519
|
||||
@@ -20,10 +24,12 @@ import qualified Data.ByteString.Lazy as LB
|
||||
import Data.Int (Int64)
|
||||
import Data.List (intercalate)
|
||||
import qualified Data.List.NonEmpty as NE
|
||||
import Data.Word (Word16, Word32)
|
||||
import Data.Word (Word8, Word16, Word32)
|
||||
import System.Random (randomIO)
|
||||
import Data.X509.Validation (Fingerprint (..))
|
||||
import Simplex.FileTransfer.Client (prepareChunkSizes)
|
||||
import Simplex.FileTransfer.Description (FileSize (..))
|
||||
import Simplex.FileTransfer.Description (FileDescription (..), FileSize (..), ValidFileDescription, pattern ValidFileDescription)
|
||||
import Simplex.FileTransfer.Protocol (FileParty (..))
|
||||
import Simplex.FileTransfer.Transport (XFTPClientHello (..))
|
||||
import Simplex.FileTransfer.Types (FileHeader (..))
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
@@ -38,6 +44,12 @@ 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)
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
|
||||
xftpWebDir :: FilePath
|
||||
xftpWebDir = "xftp-web"
|
||||
@@ -2803,6 +2815,19 @@ tsIntegrationTests = describe "integration" $ do
|
||||
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
|
||||
@@ -2911,3 +2936,275 @@ fullRoundTripTest cfg caFile = do
|
||||
\closeXFTP(c);"
|
||||
<> 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 originalData = new Uint8Array(crypto.randomBytes(50000));\
|
||||
\const encrypted = Agent.encryptFileForUpload(originalData, 'test-file.bin');\
|
||||
\const {rcvDescription, sndDescription, uri} = await Agent.uploadFile(server, encrypted);\
|
||||
\const fd = Agent.decodeDescriptionURI(uri);\
|
||||
\const {header, content} = await Agent.downloadFile(fd);\
|
||||
\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 originalData = new Uint8Array(crypto.randomBytes(50000));\
|
||||
\const encrypted = Agent.encryptFileForUpload(originalData, 'del-test.bin');\
|
||||
\const {rcvDescription, sndDescription} = await Agent.uploadFile(server, encrypted);\
|
||||
\await Agent.deleteFile(sndDescription);\
|
||||
\let deleted = 0;\
|
||||
\try {\
|
||||
\ await Agent.downloadFile(rcvDescription);\
|
||||
\} catch (e) {\
|
||||
\ deleted = 1;\
|
||||
\};"
|
||||
<> 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 originalData = new Uint8Array(crypto.randomBytes(100000));\
|
||||
\const encrypted = Agent.encryptFileForUpload(originalData, 'redirect-test.bin');\
|
||||
\const {rcvDescription, uri} = await Agent.uploadFile(server, encrypted, null, 50);\
|
||||
\const fd = Agent.decodeDescriptionURI(uri);\
|
||||
\const hasRedirect = fd.redirect !== null ? 1 : 0;\
|
||||
\const {header, content} = await Agent.downloadFile(fd);\
|
||||
\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 originalData = new Uint8Array(crypto.randomBytes(50000));\
|
||||
\const encrypted = Agent.encryptFileForUpload(originalData, 'ts-to-hs.bin');\
|
||||
\const {rcvDescription} = await Agent.uploadFile(server, encrypted);\
|
||||
\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 originalData = new Uint8Array(crypto.randomBytes(100000));\
|
||||
\const encrypted = Agent.encryptFileForUpload(originalData, 'ts-redirect-to-hs.bin');\
|
||||
\const {rcvDescription} = await Agent.uploadFile(server, encrypted, null, 50);\
|
||||
\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
|
||||
result <-
|
||||
callNode $
|
||||
"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 = Buffer.from("
|
||||
<> jsUint8 yamlDesc
|
||||
<> ").toString();\
|
||||
\const fd = decodeFileDescription(yaml);\
|
||||
\const err = validateFileDescription(fd);\
|
||||
\if (err) throw new Error(err);\
|
||||
\const {header, content} = await Agent.downloadFile(fd);\
|
||||
\const nameMatch = header.fileName === 'hs-to-ts.bin' ? 1 : 0;\
|
||||
\const sizeMatch = content.length === 50000 ? 1 : 0;\
|
||||
\const expected = "
|
||||
<> jsUint8 originalData
|
||||
<> ";\
|
||||
\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);"
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// XFTP upload/download orchestration + URI encoding — Simplex.FileTransfer.Client.Main
|
||||
//
|
||||
// Combines all building blocks: encryption, chunking, XFTP client commands,
|
||||
// file descriptions, and DEFLATE-compressed URI encoding.
|
||||
|
||||
import crypto from "node:crypto"
|
||||
import zlib from "node:zlib"
|
||||
import {encryptFile, encodeFileHeader} from "./crypto/file.js"
|
||||
import {generateEd25519KeyPair, encodePubKeyEd25519, encodePrivKeyEd25519, decodePrivKeyEd25519, ed25519KeyPairFromSeed} from "./crypto/keys.js"
|
||||
import {sha512} from "./crypto/digest.js"
|
||||
import {prepareChunkSizes, prepareChunkSpecs, getChunkDigest, fileSizeLen, authTagSize} from "./protocol/chunks.js"
|
||||
import {
|
||||
encodeFileDescription, decodeFileDescription, validateFileDescription,
|
||||
base64urlEncode, base64urlDecode,
|
||||
type FileDescription
|
||||
} from "./protocol/description.js"
|
||||
import type {FileInfo} from "./protocol/commands.js"
|
||||
import {
|
||||
connectXFTP, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk,
|
||||
ackXFTPChunk, deleteXFTPChunk, closeXFTP, type XFTPClient
|
||||
} from "./client.js"
|
||||
import {processDownloadedFile} from "./download.js"
|
||||
import type {XFTPServer} from "./protocol/address.js"
|
||||
import {formatXFTPServer} from "./protocol/address.js"
|
||||
import {concatBytes} from "./protocol/encoding.js"
|
||||
import type {FileHeader} from "./crypto/file.js"
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
interface SentChunk {
|
||||
chunkNo: number
|
||||
senderId: Uint8Array
|
||||
senderKey: Uint8Array // 64B libsodium Ed25519 private key
|
||||
recipientId: Uint8Array
|
||||
recipientKey: Uint8Array // 64B libsodium Ed25519 private key
|
||||
chunkSize: number
|
||||
digest: Uint8Array // SHA-256
|
||||
server: XFTPServer
|
||||
}
|
||||
|
||||
export interface EncryptedFileInfo {
|
||||
encData: Uint8Array
|
||||
digest: Uint8Array // SHA-512 of encData
|
||||
key: Uint8Array // 32B SbKey
|
||||
nonce: Uint8Array // 24B CbNonce
|
||||
chunkSizes: number[]
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
rcvDescription: FileDescription
|
||||
sndDescription: FileDescription
|
||||
uri: string // base64url-encoded compressed YAML (no leading #)
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
header: FileHeader
|
||||
content: Uint8Array
|
||||
}
|
||||
|
||||
// ── URI encoding/decoding (RFC §4.1: DEFLATE + base64url) ───────
|
||||
|
||||
export function encodeDescriptionURI(fd: FileDescription): string {
|
||||
const yaml = encodeFileDescription(fd)
|
||||
const compressed = zlib.deflateRawSync(Buffer.from(yaml))
|
||||
return base64urlEncode(new Uint8Array(compressed))
|
||||
}
|
||||
|
||||
export function decodeDescriptionURI(fragment: string): FileDescription {
|
||||
const compressed = base64urlDecode(fragment)
|
||||
const yaml = zlib.inflateRawSync(Buffer.from(compressed)).toString()
|
||||
const fd = decodeFileDescription(yaml)
|
||||
const err = validateFileDescription(fd)
|
||||
if (err) throw new Error("decodeDescriptionURI: " + err)
|
||||
return fd
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────────
|
||||
|
||||
export function encryptFileForUpload(source: Uint8Array, fileName: string): EncryptedFileInfo {
|
||||
const key = new Uint8Array(crypto.randomBytes(32))
|
||||
const nonce = new Uint8Array(crypto.randomBytes(24))
|
||||
const fileHdr = encodeFileHeader({fileName, fileExtra: null})
|
||||
const fileSize = BigInt(fileHdr.length + source.length)
|
||||
const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
|
||||
const chunkSizes = prepareChunkSizes(payloadSize)
|
||||
const encSize = BigInt(chunkSizes.reduce((a, b) => a + b, 0))
|
||||
const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)
|
||||
const digest = sha512(encData)
|
||||
return {encData, digest, key, nonce, chunkSizes}
|
||||
}
|
||||
|
||||
const DEFAULT_REDIRECT_THRESHOLD = 400
|
||||
|
||||
export async function uploadFile(
|
||||
server: XFTPServer,
|
||||
encrypted: EncryptedFileInfo,
|
||||
onProgress?: (uploaded: number, total: number) => void,
|
||||
redirectThreshold?: number
|
||||
): Promise<UploadResult> {
|
||||
const specs = prepareChunkSpecs(encrypted.chunkSizes)
|
||||
const client = await connectXFTP(server)
|
||||
const sentChunks: SentChunk[] = []
|
||||
let uploaded = 0
|
||||
try {
|
||||
for (let i = 0; i < specs.length; i++) {
|
||||
const spec = specs[i]
|
||||
const chunkNo = i + 1
|
||||
const sndKp = generateEd25519KeyPair()
|
||||
const rcvKp = generateEd25519KeyPair()
|
||||
const chunkData = encrypted.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize)
|
||||
const chunkDigest = getChunkDigest(chunkData)
|
||||
const fileInfo: FileInfo = {
|
||||
sndKey: encodePubKeyEd25519(sndKp.publicKey),
|
||||
size: spec.chunkSize,
|
||||
digest: chunkDigest
|
||||
}
|
||||
const {senderId, recipientIds} = await createXFTPChunk(
|
||||
client, sndKp.privateKey, fileInfo, [encodePubKeyEd25519(rcvKp.publicKey)]
|
||||
)
|
||||
await uploadXFTPChunk(client, sndKp.privateKey, senderId, chunkData)
|
||||
sentChunks.push({
|
||||
chunkNo, senderId, senderKey: sndKp.privateKey,
|
||||
recipientId: recipientIds[0], recipientKey: rcvKp.privateKey,
|
||||
chunkSize: spec.chunkSize, digest: chunkDigest, server
|
||||
})
|
||||
uploaded += spec.chunkSize
|
||||
onProgress?.(uploaded, encrypted.encData.length)
|
||||
}
|
||||
const rcvDescription = buildDescription("recipient", encrypted, sentChunks)
|
||||
const sndDescription = buildDescription("sender", encrypted, sentChunks)
|
||||
let uri = encodeDescriptionURI(rcvDescription)
|
||||
let finalRcvDescription = rcvDescription
|
||||
const threshold = redirectThreshold ?? DEFAULT_REDIRECT_THRESHOLD
|
||||
if (uri.length > threshold && sentChunks.length > 1) {
|
||||
finalRcvDescription = await uploadRedirectDescription(client, server, rcvDescription)
|
||||
uri = encodeDescriptionURI(finalRcvDescription)
|
||||
}
|
||||
return {rcvDescription: finalRcvDescription, sndDescription, uri}
|
||||
} finally {
|
||||
closeXFTP(client)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDescription(
|
||||
party: "recipient" | "sender",
|
||||
enc: EncryptedFileInfo,
|
||||
chunks: SentChunk[]
|
||||
): FileDescription {
|
||||
const defChunkSize = enc.chunkSizes[0]
|
||||
return {
|
||||
party,
|
||||
size: enc.chunkSizes.reduce((a, b) => a + b, 0),
|
||||
digest: enc.digest,
|
||||
key: enc.key,
|
||||
nonce: enc.nonce,
|
||||
chunkSize: defChunkSize,
|
||||
chunks: chunks.map(c => ({
|
||||
chunkNo: c.chunkNo,
|
||||
chunkSize: c.chunkSize,
|
||||
digest: c.digest,
|
||||
replicas: [{
|
||||
server: formatXFTPServer(c.server),
|
||||
replicaId: party === "recipient" ? c.recipientId : c.senderId,
|
||||
replicaKey: encodePrivKeyEd25519(party === "recipient" ? c.recipientKey : c.senderKey)
|
||||
}]
|
||||
})),
|
||||
redirect: null
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRedirectDescription(
|
||||
client: XFTPClient,
|
||||
server: XFTPServer,
|
||||
innerFd: FileDescription
|
||||
): Promise<FileDescription> {
|
||||
const yaml = encodeFileDescription(innerFd)
|
||||
const yamlBytes = new TextEncoder().encode(yaml)
|
||||
const enc = encryptFileForUpload(yamlBytes, "")
|
||||
const specs = prepareChunkSpecs(enc.chunkSizes)
|
||||
const sentChunks: SentChunk[] = []
|
||||
for (let i = 0; i < specs.length; i++) {
|
||||
const spec = specs[i]
|
||||
const chunkNo = i + 1
|
||||
const sndKp = generateEd25519KeyPair()
|
||||
const rcvKp = generateEd25519KeyPair()
|
||||
const chunkData = enc.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize)
|
||||
const chunkDigest = getChunkDigest(chunkData)
|
||||
const fileInfo: FileInfo = {
|
||||
sndKey: encodePubKeyEd25519(sndKp.publicKey),
|
||||
size: spec.chunkSize,
|
||||
digest: chunkDigest
|
||||
}
|
||||
const {senderId, recipientIds} = await createXFTPChunk(
|
||||
client, sndKp.privateKey, fileInfo, [encodePubKeyEd25519(rcvKp.publicKey)]
|
||||
)
|
||||
await uploadXFTPChunk(client, sndKp.privateKey, senderId, chunkData)
|
||||
sentChunks.push({
|
||||
chunkNo, senderId, senderKey: sndKp.privateKey,
|
||||
recipientId: recipientIds[0], recipientKey: rcvKp.privateKey,
|
||||
chunkSize: spec.chunkSize, digest: chunkDigest, server
|
||||
})
|
||||
}
|
||||
return {
|
||||
party: "recipient",
|
||||
size: enc.chunkSizes.reduce((a, b) => a + b, 0),
|
||||
digest: enc.digest,
|
||||
key: enc.key,
|
||||
nonce: enc.nonce,
|
||||
chunkSize: enc.chunkSizes[0],
|
||||
chunks: sentChunks.map(c => ({
|
||||
chunkNo: c.chunkNo,
|
||||
chunkSize: c.chunkSize,
|
||||
digest: c.digest,
|
||||
replicas: [{
|
||||
server: formatXFTPServer(c.server),
|
||||
replicaId: c.recipientId,
|
||||
replicaKey: encodePrivKeyEd25519(c.recipientKey)
|
||||
}]
|
||||
})),
|
||||
redirect: {size: innerFd.size, digest: innerFd.digest}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download ────────────────────────────────────────────────────
|
||||
|
||||
export async function downloadFile(
|
||||
fd: FileDescription,
|
||||
onProgress?: (downloaded: number, total: number) => void
|
||||
): Promise<DownloadResult> {
|
||||
const err = validateFileDescription(fd)
|
||||
if (err) throw new Error("downloadFile: " + err)
|
||||
if (fd.redirect !== null) {
|
||||
return downloadWithRedirect(fd, onProgress)
|
||||
}
|
||||
const connections = new Map<string, XFTPClient>()
|
||||
try {
|
||||
const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
|
||||
let downloaded = 0
|
||||
for (const chunk of fd.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) throw new Error("downloadFile: chunk has no replicas")
|
||||
const client = await getOrConnect(connections, replica.server)
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
const data = await downloadXFTPChunk(client, kp.privateKey, replica.replicaId, chunk.digest)
|
||||
plaintextChunks[chunk.chunkNo - 1] = data
|
||||
downloaded += chunk.chunkSize
|
||||
onProgress?.(downloaded, fd.size)
|
||||
}
|
||||
// Verify file size
|
||||
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
|
||||
if (totalSize !== fd.size) throw new Error("downloadFile: file size mismatch")
|
||||
// Verify file digest (SHA-512 of encrypted file data)
|
||||
const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks)
|
||||
const digest = sha512(combined)
|
||||
if (!digestEqual(digest, fd.digest)) throw new Error("downloadFile: file digest mismatch")
|
||||
// Decrypt
|
||||
const result = processDownloadedFile(fd, plaintextChunks)
|
||||
// ACK all chunks (best-effort)
|
||||
for (const chunk of fd.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) continue
|
||||
try {
|
||||
const client = connections.get(replica.server)
|
||||
if (!client) continue
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
|
||||
} catch (_) {}
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
for (const c of connections.values()) closeXFTP(c)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWithRedirect(
|
||||
fd: FileDescription,
|
||||
onProgress?: (downloaded: number, total: number) => void
|
||||
): Promise<DownloadResult> {
|
||||
const connections = new Map<string, XFTPClient>()
|
||||
try {
|
||||
const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
|
||||
for (const chunk of fd.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) throw new Error("downloadWithRedirect: chunk has no replicas")
|
||||
const client = await getOrConnect(connections, replica.server)
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
const data = await downloadXFTPChunk(client, kp.privateKey, replica.replicaId, chunk.digest)
|
||||
plaintextChunks[chunk.chunkNo - 1] = data
|
||||
}
|
||||
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
|
||||
if (totalSize !== fd.size) throw new Error("downloadWithRedirect: redirect file size mismatch")
|
||||
const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks)
|
||||
const digest = sha512(combined)
|
||||
if (!digestEqual(digest, fd.digest)) throw new Error("downloadWithRedirect: redirect file digest mismatch")
|
||||
const {content: yamlBytes} = processDownloadedFile(fd, plaintextChunks)
|
||||
const innerFd = decodeFileDescription(new TextDecoder().decode(yamlBytes))
|
||||
const innerErr = validateFileDescription(innerFd)
|
||||
if (innerErr) throw new Error("downloadWithRedirect: inner description invalid: " + innerErr)
|
||||
if (innerFd.size !== fd.redirect!.size) throw new Error("downloadWithRedirect: redirect size mismatch")
|
||||
if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("downloadWithRedirect: redirect digest mismatch")
|
||||
for (const chunk of fd.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) continue
|
||||
try {
|
||||
const client = connections.get(replica.server)
|
||||
if (!client) continue
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
|
||||
} catch (_) {}
|
||||
}
|
||||
for (const c of connections.values()) closeXFTP(c)
|
||||
return downloadFile(innerFd, onProgress)
|
||||
} catch (e) {
|
||||
for (const c of connections.values()) closeXFTP(c)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteFile(sndDescription: FileDescription): Promise<void> {
|
||||
const connections = new Map<string, XFTPClient>()
|
||||
try {
|
||||
for (const chunk of sndDescription.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) throw new Error("deleteFile: chunk has no replicas")
|
||||
const client = await getOrConnect(connections, replica.server)
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
await deleteXFTPChunk(client, kp.privateKey, replica.replicaId)
|
||||
}
|
||||
} finally {
|
||||
for (const c of connections.values()) closeXFTP(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
import {parseXFTPServer} from "./protocol/address.js"
|
||||
|
||||
async function getOrConnect(
|
||||
connections: Map<string, XFTPClient>,
|
||||
serverStr: string
|
||||
): Promise<XFTPClient> {
|
||||
let c = connections.get(serverStr)
|
||||
if (!c) {
|
||||
c = await connectXFTP(parseXFTPServer(serverStr))
|
||||
connections.set(serverStr, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
function digestEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
|
||||
return diff === 0
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// XFTP server address parsing — Simplex.Messaging.Protocol (ProtocolServer)
|
||||
// XFTP server address parsing/formatting — Simplex.Messaging.Protocol (ProtocolServer)
|
||||
//
|
||||
// Parses server address strings of the form:
|
||||
// Parses/formats server address strings of the form:
|
||||
// xftp://<keyhash>@<host>[,<host2>,...][:<port>]
|
||||
//
|
||||
// KeyHash is base64url-encoded SHA-256 fingerprint of the identity certificate.
|
||||
|
||||
import {base64urlEncode} from "./description.js"
|
||||
|
||||
export interface XFTPServer {
|
||||
keyHash: Uint8Array // 32-byte SHA-256 fingerprint (decoded from base64url)
|
||||
host: string // primary hostname
|
||||
@@ -45,3 +47,8 @@ export function parseXFTPServer(address: string): XFTPServer {
|
||||
}
|
||||
return {keyHash, host, port}
|
||||
}
|
||||
|
||||
// Format an XFTPServer back to its URI string representation.
|
||||
export function formatXFTPServer(srv: XFTPServer): string {
|
||||
return "xftp://" + base64urlEncode(srv.keyHash) + "@" + srv.host + ":" + srv.port
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user