diff --git a/tests/XFTPWebTests.hs b/tests/XFTPWebTests.hs index f2b091fcb..a3ac4cd36 100644 --- a/tests/XFTPWebTests.hs +++ b/tests/XFTPWebTests.hs @@ -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);" diff --git a/xftp-web/src/agent.ts b/xftp-web/src/agent.ts new file mode 100644 index 000000000..48a85bb3c --- /dev/null +++ b/xftp-web/src/agent.ts @@ -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 { + 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 { + 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 { + const err = validateFileDescription(fd) + if (err) throw new Error("downloadFile: " + err) + if (fd.redirect !== null) { + return downloadWithRedirect(fd, onProgress) + } + const connections = new Map() + 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 { + const connections = new Map() + 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 { + const connections = new Map() + 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, + serverStr: string +): Promise { + 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 +} diff --git a/xftp-web/src/protocol/address.ts b/xftp-web/src/protocol/address.ts index f89709fd3..a66f23927 100644 --- a/xftp-web/src/protocol/address.ts +++ b/xftp-web/src/protocol/address.ts @@ -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 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 +}