support description "redirect" in agent.ts and cross-platform compatibility tests (Haskell <> TypeScript)

This commit is contained in:
Evgeny Poberezkin
2026-02-03 13:53:52 +00:00
parent 260380486a
commit 9a2279d4f0
3 changed files with 671 additions and 5 deletions
+300 -3
View File
@@ -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);"
+362
View File
@@ -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
}
+9 -2
View File
@@ -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
}