Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f0c2be45 | ||
|
|
8b7fc88cb8 | ||
|
|
5f1216bf07 | ||
|
|
7f95b4540d | ||
|
|
910a922e0d | ||
|
|
ad50ca8644 | ||
|
|
3bc7a8c0a8 | ||
|
|
e56c12ab3c | ||
|
|
34e3d30c78 | ||
|
|
a0af7377ab | ||
|
|
3f40febe60 | ||
|
|
fd298ae328 | ||
|
|
1f173abf6d | ||
|
|
21f4597dad | ||
|
|
ba6af65c54 | ||
|
|
858fac7f4f | ||
|
|
3536a156d1 | ||
|
|
90432a44b4 | ||
|
|
1e1f897c79 | ||
|
|
0dc2940eff | ||
|
|
8833e5c1b5 | ||
|
|
95b17ada27 | ||
|
|
43cdf55f3b | ||
|
|
bc5ea42bec | ||
|
|
0933cbcb9c | ||
|
|
f2dafd983b | ||
|
|
34c0909c1a | ||
|
|
97802a30fc | ||
|
|
b82cf7d001 | ||
|
|
9bc0c70fa0 | ||
|
|
0741583f78 | ||
|
|
f8f172f32f | ||
|
|
9c07ddff3c | ||
|
|
50b71d3e56 | ||
|
|
a1b762992b | ||
|
|
1e0093be9a | ||
|
|
01fe841e3c | ||
|
|
1a12ee0a5a | ||
|
|
efcef2d1fd | ||
|
|
963d7b2f75 | ||
|
|
4e4e0a4f42 | ||
|
|
082a6c6f22 | ||
|
|
dc2921e4ce | ||
|
|
5a32e729e0 | ||
|
|
782cacfb3c | ||
|
|
328d3b941a | ||
|
|
b6b87a6323 | ||
|
|
33454444a4 | ||
|
|
1449e6418c | ||
|
|
3a627a9599 | ||
|
|
d2d834ad16 | ||
|
|
437cdde4a5 | ||
|
|
eed1bf14c6 | ||
|
|
313e96513c | ||
|
|
c6e3a4d80f | ||
|
|
0e67647e90 | ||
|
|
f3408d9bb6 | ||
|
|
3ecb901e3e | ||
|
|
f6aca47604 | ||
|
|
50ae1e1c3e | ||
|
|
483ac674fb | ||
|
|
3dba9c2b1e | ||
|
|
8fdc0703bc | ||
|
|
d7b90b8415 | ||
|
|
9346b85c3f | ||
|
|
3c5ec8d9a1 | ||
|
|
d10e05b796 | ||
|
|
66cc06738e | ||
|
|
0f3b8a4a16 | ||
|
|
a1596ed234 | ||
|
|
89b81d151f | ||
|
|
3e5b654109 | ||
|
|
ca26c69937 | ||
|
|
58212c421a | ||
|
|
1000107259 | ||
|
|
6aadcf1f3f | ||
|
|
07604a146f | ||
|
|
4c782d3191 | ||
|
|
c4b687ba64 | ||
|
|
a7b43b1a3e | ||
|
|
d6df769799 | ||
|
|
5f73d1e629 | ||
|
|
70d1b99fb4 | ||
|
|
2ea98db9d8 | ||
|
|
bbe1c716e6 | ||
|
|
77ac452190 | ||
|
|
ea70575275 | ||
|
|
49e9ce1649 | ||
|
|
2ca440dd2d | ||
|
|
92a9579e69 | ||
|
|
cf9b7e5b6a |
@@ -173,7 +173,7 @@ jobs:
|
||||
-v ${{ github.workspace }}:/project \
|
||||
build/${{ matrix.os }}:latest
|
||||
|
||||
- name: Build smp-server (postgresql) and tests
|
||||
- name: Build smp-server, xftp-server (postgresql) and tests
|
||||
if: matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
run: |
|
||||
@@ -182,12 +182,12 @@ jobs:
|
||||
cabal update
|
||||
cabal build --jobs=$(nproc) --enable-tests -fserver_postgres
|
||||
mkdir -p /out
|
||||
for i in smp-server simplexmq-test; do
|
||||
for i in smp-server xftp-server simplexmq-test; do
|
||||
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
|
||||
chmod +x "$bin"
|
||||
mv "$bin" /out/
|
||||
done
|
||||
strip /out/smp-server
|
||||
strip /out/smp-server /out/xftp-server
|
||||
|
||||
- name: Copy simplexmq-test from container
|
||||
if: matrix.should_run == true
|
||||
@@ -195,19 +195,29 @@ jobs:
|
||||
run: |
|
||||
docker cp builder:/out/simplexmq-test .
|
||||
|
||||
- name: Copy smp-server (postgresql) from container and prepare it
|
||||
- name: Copy smp-server, xftp-server (postgresql) from container and prepare it
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
id: prepare-postgres
|
||||
shell: bash
|
||||
run: |
|
||||
name="smp-server-postgres-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
|
||||
docker cp builder:/out/smp-server $name
|
||||
printf 'bins<<EOF\n' > bins.output
|
||||
printf 'hashes<<EOF\n' > hashes.output
|
||||
|
||||
path="${{ github.workspace }}/$name"
|
||||
echo "bin=$path" >> $GITHUB_OUTPUT
|
||||
for i in smp-server xftp-server; do
|
||||
name="${i}-postgres-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
|
||||
docker cp builder:/out/$i $name
|
||||
|
||||
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
|
||||
printf 'hash=%s' "$hash" >> $GITHUB_OUTPUT
|
||||
path="${{ github.workspace }}/$name"
|
||||
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
|
||||
|
||||
printf '%s\n' "$path" >> bins.output
|
||||
printf '%s\n\n' "$hash" >> hashes.output
|
||||
done
|
||||
printf 'EOF\n' >> bins.output
|
||||
printf 'EOF\n' >> hashes.output
|
||||
|
||||
cat bins.output >> "$GITHUB_OUTPUT"
|
||||
cat hashes.output >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build everything else (standard)
|
||||
if: matrix.should_run == true
|
||||
@@ -257,10 +267,10 @@ jobs:
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
${{ steps.prepare-regular.outputs.hashes }}
|
||||
${{ steps.prepare-postgres.outputs.hash }}
|
||||
${{ steps.prepare-postgres.outputs.hashes }}
|
||||
files: |
|
||||
${{ steps.prepare-regular.outputs.bins }}
|
||||
${{ steps.prepare-postgres.outputs.bin }}
|
||||
${{ steps.prepare-postgres.outputs.bins }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -39,9 +39,17 @@ jobs:
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: simplex-chat/docker-build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
APP=${{ matrix.app }}
|
||||
|
||||
@@ -11,3 +11,4 @@ cabal.project.local~
|
||||
.hpc/
|
||||
*.tix
|
||||
.coverage
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# XFTPClientAgent Pattern
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Changes: client.ts
|
||||
3. Changes: agent.ts
|
||||
4. Changes: test/browser.test.ts
|
||||
5. Verification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Add `XFTPClientAgent` — a per-server connection pool matching the Haskell pattern. The agent caches `XFTPClient` instances by server URL. All orchestration functions (`uploadFile`, `downloadFile`, `deleteFile`) take `agent` as first parameter and use `getXFTPServerClient(agent, server)` instead of calling `connectXFTP` directly. Connections stay open on success; the caller creates and closes the agent.
|
||||
|
||||
`connectXFTP` and `closeXFTP` stay exported (used by `XFTPWebTests.hs` Haskell tests). The `browserClients` hack, per-function `connections: Map`, and `getOrConnect` are deleted.
|
||||
|
||||
## Changes: client.ts
|
||||
|
||||
**Add** after types section: `XFTPClientAgent` interface, `newXFTPAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `closeXFTPAgent`.
|
||||
|
||||
**Delete**: `browserClients` Map and all `isNode` browser-cache checks in `connectXFTP` and `closeXFTP`.
|
||||
|
||||
**Revert `closeXFTP`** to unconditional `c.transport.close()` (browser transport.close() is already a no-op).
|
||||
|
||||
`connectXFTP` stays exported (backward compat) but becomes a raw low-level function — no caching.
|
||||
|
||||
## Changes: agent.ts
|
||||
|
||||
**Imports**: replace `connectXFTP`/`closeXFTP` with `getXFTPServerClient`/`closeXFTPAgent` etc.
|
||||
|
||||
**Re-export** from agent.ts: `newXFTPAgent`, `closeXFTPAgent`, `XFTPClientAgent`.
|
||||
|
||||
**`uploadFile`**: add `agent: XFTPClientAgent` as first param. Replace `connectXFTP` → `getXFTPServerClient`. Remove `finally { closeXFTP }`. Pass `agent` to `uploadRedirectDescription`.
|
||||
|
||||
**`uploadRedirectDescription`**: change from `(client, server, innerFd)` to `(agent, server, innerFd)`. Get client via `getXFTPServerClient`.
|
||||
|
||||
**`downloadFile`**: add `agent` param. Delete local `connections: Map`. Replace `getOrConnect` → `getXFTPServerClient`. Remove finally cleanup. Pass `agent` to `downloadWithRedirect`.
|
||||
|
||||
**`downloadWithRedirect`**: add `agent` param. Same replacements. Remove try/catch cleanup. Recursive call passes `agent`.
|
||||
|
||||
**`deleteFile`**: add `agent` param. Same pattern.
|
||||
|
||||
**Delete**: `getOrConnect` function entirely.
|
||||
|
||||
## Changes: test/browser.test.ts
|
||||
|
||||
Create agent before operations, pass to upload/download, close in finally.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npx vitest --run` — browser round-trip test passes
|
||||
2. No remaining `browserClients`, `getOrConnect`, or per-function `connections: Map` locals
|
||||
3. `connectXFTP` and `closeXFTP` still exported (XFTPWebTests.hs compat)
|
||||
4. All orchestration functions take `agent` as first param
|
||||
@@ -1,3 +1,39 @@
|
||||
# 6.5.1
|
||||
|
||||
Version 6.5.1.0
|
||||
|
||||
XFTP client:
|
||||
- backwards compatible file header decoding.
|
||||
|
||||
# 6.5.0
|
||||
|
||||
Version 6.5.0.17
|
||||
|
||||
SMP agent:
|
||||
- improve subscriptions
|
||||
- reduce memory usage and retries during initial subscription (#1758)
|
||||
- fix race resulting in pending subscriptions never subscribed (#1756)
|
||||
- batch processing of subscription results and errors (#1652)
|
||||
- reduce memory usage of active subscriptions.
|
||||
- drop message after N reception attempts (#1762)
|
||||
- fix possible deadlocks of queue overloading when processing messages (#1713)
|
||||
- improved APIs for short link management and creation.
|
||||
- support multiple link owners in link data (#1701)
|
||||
|
||||
SMP server:
|
||||
- store messages in PostgreSQL (#1622).
|
||||
- reduce memory usage with PostgreSQL database - do not use queue cache (#1637)
|
||||
- fix in-memory server not restoring queue/service associations after 2+ restarts (#1618)
|
||||
|
||||
XFTP server:
|
||||
- support PostgreSQL database.
|
||||
- add server page.
|
||||
- support uploads from web clients.
|
||||
|
||||
Servers:
|
||||
- better socket leak prevention during TLS handshake, NetworkError type to bette diagnose connection errors (#1619)
|
||||
- use "=" as default INI key-value separator (#1767)
|
||||
|
||||
# 6.4.4
|
||||
|
||||
Servers:
|
||||
|
||||
@@ -33,7 +33,7 @@ To initialize the server use `smp-server init -n <fqdn>` (or `smp-server init --
|
||||
|
||||
SMP server uses in-memory persistence with an optional append-only log of created queues that allows to re-start the server without losing the connections. This log is compacted on every server restart, permanently removing suspended and removed queues.
|
||||
|
||||
To enable store log, initialize server using `smp-server -l` command, or modify `smp-server.ini` created during initialization (uncomment `enable: on` option in the store log section). Use `smp-server --help` for other usage tips.
|
||||
To enable store log, initialize server using `smp-server -l` command, or modify `smp-server.ini` created during initialization (uncomment `enable = on` option in the store log section). Use `smp-server --help` for other usage tips.
|
||||
|
||||
Starting from version 2.3.0, when store log is enabled, the server would also enable saving undelivered messages on exit and restoring them on start. This can be disabled via a separate setting `restore_messages` in `smp-server.ini` file. Saving messages would only work if the server is stopped with SIGINT signal (keyboard interrupt), if it is stopped with SIGTERM signal the messages would not be saved.
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Web.Embedded where
|
||||
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Simplex.Messaging.Server.Web (EmbeddedContent (..))
|
||||
|
||||
embeddedContent :: EmbeddedContent
|
||||
embeddedContent =
|
||||
EmbeddedContent
|
||||
{ indexHtml = $(embedFile "apps/common/Web/static/index.html"),
|
||||
linkHtml = $(embedFile "apps/common/Web/static/link.html"),
|
||||
mediaContent = $(embedDir "apps/common/Web/static/media/"),
|
||||
wellKnown = $(embedDir "apps/common/Web/static/.well-known/")
|
||||
}
|
||||
@@ -105,6 +105,13 @@
|
||||
class="text-[16px] leading-[26px] tracking-[0.01em] nav-link-text text-black dark:text-white before:bg-black dark:before:bg-white">Server
|
||||
information</span></a>
|
||||
</li>
|
||||
<!-- <x-xftpConfig>
|
||||
<li class="nav-link relative"><a href="/file"
|
||||
class="flex items-center justify-between gap-2 lg:py-5 whitespace-nowrap"><span
|
||||
class="text-[16px] leading-[26px] tracking-[0.01em] nav-link-text text-black dark:text-white before:bg-black dark:before:bg-white">File
|
||||
transfer</span></a>
|
||||
</li>
|
||||
</x-xftpConfig> -->
|
||||
</ul><a target="_blank" href="https://github.com/simplex-chat/simplex-chat#help-us-with-donations"
|
||||
class="whitespace-nowrap flex items-center gap-1 self-center text-white dark:text-black text-[16px] font-medium tracking-[0.02em] rounded-[34px] bg-primary-light dark:bg-primary-dark py-3 lg:py-2 px-20 lg:px-5 mb-16 lg:mb-0">Donate</a>
|
||||
</div>
|
||||
@@ -229,7 +236,7 @@
|
||||
<td>Source code:</td>
|
||||
<td>
|
||||
<x-sourceCode><a href="${sourceCode}" target="_blank">${sourceCode}</a></x-sourceCode>
|
||||
<x-noSourceCode>add to smp-server.ini (required by <a href="https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE" target="_blank">AGPLv3</a>)</x-noSourceCode>
|
||||
<x-noSourceCode>add to ${iniFileName} (required by <a href="https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE" target="_blank">AGPLv3</a>)</x-noSourceCode>
|
||||
</td>
|
||||
</tr>
|
||||
<x-website>
|
||||
@@ -317,6 +324,7 @@
|
||||
<h2 class="text-[30px] mb-[20px] leading-[28px] text-[#606C71] dark:text-white font-bold max-w-[475px]">
|
||||
Configuration</h2>
|
||||
<table id="config">
|
||||
<x-smpConfig>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Persistence:</td>
|
||||
<td>${persistence}</td>
|
||||
@@ -337,6 +345,25 @@
|
||||
<td>Basic auth enabled:</td>
|
||||
<td>${basicAuthEnabled}</td>
|
||||
</tr>
|
||||
</x-smpConfig>
|
||||
<x-xftpConfig>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>File expiration:</td>
|
||||
<td>${fileExpiration}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Stats enabled:</td>
|
||||
<td>${statsEnabled}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>New uploads allowed:</td>
|
||||
<td>${newUploadsAllowed}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Basic auth enabled:</td>
|
||||
<td>${basicAuthEnabled}</td>
|
||||
</tr>
|
||||
</x-xftpConfig>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -2,8 +2,9 @@ module Main where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Simplex.Messaging.Server.CLI (getEnvPath)
|
||||
import Simplex.Messaging.Server.Main
|
||||
import qualified Static
|
||||
import Simplex.Messaging.Server.Main (smpServerCLI_)
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticAndWS)
|
||||
import SMPWeb (smpGenerateSite)
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
defaultCfgPath = "/etc/opt/simplex"
|
||||
@@ -18,4 +19,4 @@ main :: IO ()
|
||||
main = do
|
||||
cfgPath <- getEnvPath "SMP_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "SMP_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ smpServerCLI_ Static.generateSite Static.serveStaticFiles Static.attachStaticFiles cfgPath logPath
|
||||
withGlobalLogging logCfg $ smpServerCLI_ smpGenerateSite serveStaticFiles attachStaticAndWS cfgPath logPath
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module SMPWeb
|
||||
( smpGenerateSite,
|
||||
serverInformation,
|
||||
) where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.String (fromString)
|
||||
import Web.Embedded (embeddedContent)
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
|
||||
smpGenerateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO ()
|
||||
smpGenerateSite si onionHost path =
|
||||
Web.generateSite embeddedContent (serverInformation si onionHost) smpLinkPages path
|
||||
|
||||
smpLinkPages :: [String]
|
||||
smpLinkPages = ["contact", "invitation", "a", "c", "g", "r", "i"]
|
||||
|
||||
serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString
|
||||
serverInformation ServerInformation {config, information} onionHost = render (Web.indexHtml embeddedContent) substs
|
||||
where
|
||||
substs = [("smpConfig", Just "y"), ("xftpConfig", Nothing)] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "smp-server.ini")]
|
||||
substConfig =
|
||||
[ ( "persistence",
|
||||
Just $ case persistence config of
|
||||
SPMMemoryOnly -> "In-memory only"
|
||||
SPMQueues -> "Queues"
|
||||
SPMMessages -> "Queues and messages"
|
||||
),
|
||||
("messageExpiration", Just $ maybe "Never" (fromString . timedTTLText) $ messageExpiration config),
|
||||
("statsEnabled", Just . yesNo $ statsEnabled config),
|
||||
("newQueuesAllowed", Just . yesNo $ newQueuesAllowed config),
|
||||
("basicAuthEnabled", Just . yesNo $ basicAuthEnabled config)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
@@ -1 +0,0 @@
|
||||
../link.html
|
||||
@@ -1 +0,0 @@
|
||||
../link.html
|
||||
@@ -1 +0,0 @@
|
||||
../link.html
|
||||
@@ -1 +0,0 @@
|
||||
../link.html
|
||||
@@ -1 +0,0 @@
|
||||
../link.html
|
||||
@@ -1,253 +0,0 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Static where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (toUpper)
|
||||
import Data.IORef (readIORef)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.String (fromString)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Network.Socket (getPeerName)
|
||||
import Network.Wai (Application, Request (..))
|
||||
import Network.Wai.Application.Static (StaticSettings (..))
|
||||
import qualified Network.Wai.Application.Static as S
|
||||
import qualified Network.Wai.Handler.Warp as W
|
||||
import qualified Network.Wai.Handler.Warp.Internal as WI
|
||||
import qualified Network.Wai.Handler.WarpTLS as WT
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server (AttachHTTP)
|
||||
import Simplex.Messaging.Server.CLI (simplexmqCommit)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Main (EmbeddedWebParams (..), WebHttpsParams (..), simplexmqSource)
|
||||
import Simplex.Messaging.Transport (simplexMQVersion)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
import Simplex.Messaging.Util (tshow)
|
||||
import Static.Embedded as E
|
||||
import System.Directory (createDirectoryIfMissing)
|
||||
import System.FilePath
|
||||
import UnliftIO.Concurrent (forkFinally)
|
||||
import UnliftIO.Exception (bracket, finally)
|
||||
import qualified WaiAppStatic.Types as WAT
|
||||
|
||||
serveStaticFiles :: EmbeddedWebParams -> IO ()
|
||||
serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams} = do
|
||||
forM_ webHttpPort $ \port -> flip forkFinally (\e -> logError $ "HTTP server crashed: " <> tshow e) $ do
|
||||
logInfo $ "Serving static site on port " <> tshow port
|
||||
W.runSettings (mkSettings port) app
|
||||
forM_ webHttpsParams $ \WebHttpsParams {port, cert, key} -> flip forkFinally (\e -> logError $ "HTTPS server crashed: " <> tshow e) $ do
|
||||
logInfo $ "Serving static site on port " <> tshow port <> " (TLS)"
|
||||
WT.runTLS (WT.tlsSettings cert key) (mkSettings port) app
|
||||
where
|
||||
app = staticFiles webStaticPath
|
||||
mkSettings port = W.setPort port warpSettings
|
||||
|
||||
-- | Prepare context and prepare HTTP handler for TLS connections that already passed TLS.handshake and ALPN check.
|
||||
attachStaticFiles :: FilePath -> (AttachHTTP -> IO ()) -> IO ()
|
||||
attachStaticFiles path action =
|
||||
-- Initialize global internal state for http server.
|
||||
WI.withII warpSettings $ \ii -> do
|
||||
action $ \socket cxt -> do
|
||||
-- Initialize internal per-connection resources.
|
||||
addr <- getPeerName socket
|
||||
withConnection addr cxt $ \(conn, transport) ->
|
||||
withTimeout ii conn $ \th ->
|
||||
-- Run Warp connection handler to process HTTP requests for static files.
|
||||
WI.serveConnection conn ii th addr transport warpSettings app
|
||||
where
|
||||
app = staticFiles path
|
||||
-- from warp-tls
|
||||
withConnection socket cxt = bracket (WT.attachConn socket cxt) (terminate . fst)
|
||||
-- from warp
|
||||
withTimeout ii conn =
|
||||
bracket
|
||||
(WI.registerKillThread (WI.timeoutManager ii) (WI.connClose conn))
|
||||
WI.cancel
|
||||
-- shared clean up
|
||||
terminate conn = WI.connClose conn `finally` (readIORef (WI.connWriteBuffer conn) >>= WI.bufFree)
|
||||
|
||||
warpSettings :: W.Settings
|
||||
warpSettings = W.setGracefulShutdownTimeout (Just 1) W.defaultSettings
|
||||
|
||||
staticFiles :: FilePath -> Application
|
||||
staticFiles root = S.staticApp settings . changeWellKnownPath
|
||||
where
|
||||
settings = defSettings {ssListing = Nothing, ssGetMimeType = getMimeType}
|
||||
defSettings = S.defaultFileServerSettings root
|
||||
getMimeType f
|
||||
| WAT.fromPiece (WAT.fileName f) == "apple-app-site-association" = pure "application/json"
|
||||
| otherwise = (ssGetMimeType defSettings) f
|
||||
changeWellKnownPath req = case pathInfo req of
|
||||
".well-known" : rest ->
|
||||
req
|
||||
{ pathInfo = "well-known" : rest,
|
||||
rawPathInfo = "/well-known/" <> B.drop pfxLen (rawPathInfo req)
|
||||
}
|
||||
_ -> req
|
||||
pfxLen = B.length "/.well-known/"
|
||||
|
||||
generateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO ()
|
||||
generateSite si onionHost sitePath = do
|
||||
createDirectoryIfMissing True sitePath
|
||||
B.writeFile (sitePath </> "index.html") $ serverInformation si onionHost
|
||||
copyDir "media" E.mediaContent
|
||||
-- `.well-known` path is re-written in changeWellKnownPath,
|
||||
-- staticApp does not allow hidden folders.
|
||||
copyDir "well-known" E.wellKnown
|
||||
createLinkPage "contact"
|
||||
createLinkPage "invitation"
|
||||
createLinkPage "a"
|
||||
createLinkPage "c"
|
||||
createLinkPage "g"
|
||||
createLinkPage "r"
|
||||
createLinkPage "i"
|
||||
logInfo $ "Generated static site contents at " <> tshow sitePath
|
||||
where
|
||||
copyDir dir content = do
|
||||
createDirectoryIfMissing True $ sitePath </> dir
|
||||
forM_ content $ \(path, s) -> B.writeFile (sitePath </> dir </> path) s
|
||||
createLinkPage path = do
|
||||
createDirectoryIfMissing True $ sitePath </> path
|
||||
B.writeFile (sitePath </> path </> "index.html") E.linkHtml
|
||||
|
||||
serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString
|
||||
serverInformation ServerInformation {config, information} onionHost = render E.indexHtml substs
|
||||
where
|
||||
substs = substConfig <> substInfo <> [("onionHost", strEncode <$> onionHost)]
|
||||
substConfig =
|
||||
[ ( "persistence",
|
||||
Just $ case persistence config of
|
||||
SPMMemoryOnly -> "In-memory only"
|
||||
SPMQueues -> "Queues"
|
||||
SPMMessages -> "Queues and messages"
|
||||
),
|
||||
("messageExpiration", Just $ maybe "Never" (fromString . timedTTLText) $ messageExpiration config),
|
||||
("statsEnabled", Just . yesNo $ statsEnabled config),
|
||||
("newQueuesAllowed", Just . yesNo $ newQueuesAllowed config),
|
||||
("basicAuthEnabled", Just . yesNo $ basicAuthEnabled config)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
substInfo =
|
||||
concat
|
||||
[ basic,
|
||||
maybe [("usageConditions", Nothing), ("usageAmendments", Nothing)] conds (usageConditions spi),
|
||||
maybe [("operator", Nothing)] operatorE (operator spi),
|
||||
maybe [("admin", Nothing)] admin (adminContacts spi),
|
||||
maybe [("complaints", Nothing)] complaints (complaintsContacts spi),
|
||||
maybe [("hosting", Nothing)] hostingE (hosting spi),
|
||||
server
|
||||
]
|
||||
where
|
||||
basic =
|
||||
[ ("sourceCode", if T.null sc then Nothing else Just (encodeUtf8 sc)),
|
||||
("noSourceCode", if T.null sc then Just "none" else Nothing),
|
||||
("version", Just $ B.pack simplexMQVersion),
|
||||
("commitSourceCode", Just $ encodeUtf8 $ maybe (T.pack simplexmqSource) sourceCode information),
|
||||
("shortCommit", Just $ B.pack $ take 7 simplexmqCommit),
|
||||
("commit", Just $ B.pack simplexmqCommit),
|
||||
("website", encodeUtf8 <$> website spi)
|
||||
]
|
||||
spi = fromMaybe (emptyServerInfo "") information
|
||||
sc = sourceCode spi
|
||||
conds ServerConditions {conditions, amendments} =
|
||||
[ ("usageConditions", Just $ encodeUtf8 conditions),
|
||||
("usageAmendments", encodeUtf8 <$> amendments)
|
||||
]
|
||||
operatorE Entity {name, country} =
|
||||
[ ("operator", Just ""),
|
||||
("operatorEntity", Just $ encodeUtf8 name),
|
||||
("operatorCountry", encodeUtf8 <$> country)
|
||||
]
|
||||
admin ServerContactAddress {simplex, email, pgp} =
|
||||
[ ("admin", Just ""),
|
||||
("adminSimplex", strEncode <$> simplex),
|
||||
("adminEmail", encodeUtf8 <$> email),
|
||||
("adminPGP", encodeUtf8 . pkURI <$> pgp),
|
||||
("adminPGPFingerprint", encodeUtf8 . pkFingerprint <$> pgp)
|
||||
]
|
||||
complaints ServerContactAddress {simplex, email, pgp} =
|
||||
[ ("complaints", Just ""),
|
||||
("complaintsSimplex", strEncode <$> simplex),
|
||||
("complaintsEmail", encodeUtf8 <$> email),
|
||||
("complaintsPGP", encodeUtf8 . pkURI <$> pgp),
|
||||
("complaintsPGPFingerprint", encodeUtf8 . pkFingerprint <$> pgp)
|
||||
]
|
||||
hostingE Entity {name, country} =
|
||||
[ ("hosting", Just ""),
|
||||
("hostingEntity", Just $ encodeUtf8 name),
|
||||
("hostingCountry", encodeUtf8 <$> country)
|
||||
]
|
||||
server =
|
||||
[ ("serverCountry", encodeUtf8 <$> serverCountry spi),
|
||||
("hostingType", (\s -> maybe s (\(c, rest) -> toUpper c `B.cons` rest) $ B.uncons s) . strEncode <$> hostingType spi)
|
||||
]
|
||||
|
||||
-- Copy-pasted from simplex-chat Simplex.Chat.Types.Preferences
|
||||
{-# INLINE timedTTLText #-}
|
||||
timedTTLText :: (Integral i, Show i) => i -> String
|
||||
timedTTLText 0 = "0 sec"
|
||||
timedTTLText ttl = do
|
||||
let (m', s) = ttl `quotRem` 60
|
||||
(h', m) = m' `quotRem` 60
|
||||
(d', h) = h' `quotRem` 24
|
||||
(mm, d) = d' `quotRem` 30
|
||||
unwords $
|
||||
[mms mm | mm /= 0]
|
||||
<> [ds d | d /= 0]
|
||||
<> [hs h | h /= 0]
|
||||
<> [ms m | m /= 0]
|
||||
<> [ss s | s /= 0]
|
||||
where
|
||||
ss s = show s <> " sec"
|
||||
ms m = show m <> " min"
|
||||
hs 1 = "1 hour"
|
||||
hs h = show h <> " hours"
|
||||
ds 1 = "1 day"
|
||||
ds 7 = "1 week"
|
||||
ds 14 = "2 weeks"
|
||||
ds d = show d <> " days"
|
||||
mms 1 = "1 month"
|
||||
mms mm = show mm <> " months"
|
||||
|
||||
-- | Rewrite source with provided substitutions
|
||||
render :: ByteString -> [(ByteString, Maybe ByteString)] -> ByteString
|
||||
render src = \case
|
||||
[] -> src
|
||||
(label, content') : rest -> render (section_ label content' src) rest
|
||||
|
||||
-- | Rewrite section content inside @<x-label>...</x-label>@ markers.
|
||||
-- Markers are always removed when found. Closing marker is mandatory.
|
||||
-- If content is absent, whole section is removed.
|
||||
-- Section content is delegated to `item_`. If no sections found, the whole source is delegated.
|
||||
section_ :: ByteString -> Maybe ByteString -> ByteString -> ByteString
|
||||
section_ label content' src =
|
||||
case B.breakSubstring startMarker src of
|
||||
(_, "") -> item_ label (fromMaybe "" content') src -- no section, just replace items
|
||||
(before, afterStart') ->
|
||||
-- found section start, search for end too
|
||||
case B.breakSubstring endMarker $ B.drop (B.length startMarker) afterStart' of
|
||||
(_, "") -> error $ "missing section end: " <> show endMarker
|
||||
(inside, next') ->
|
||||
let next = B.drop (B.length endMarker) next'
|
||||
in case content' of
|
||||
Just content | not (B.null content) -> before <> item_ label content inside <> section_ label content' next
|
||||
_ -> before <> next -- collapse section
|
||||
where
|
||||
startMarker = "<x-" <> label <> ">"
|
||||
endMarker = "</x-" <> label <> ">"
|
||||
|
||||
-- | Replace all occurences of @${label}@ with provided content.
|
||||
item_ :: ByteString -> ByteString -> ByteString -> ByteString
|
||||
item_ label content' src =
|
||||
case B.breakSubstring marker src of
|
||||
(done, "") -> done
|
||||
(before, after') -> before <> content' <> item_ label content' (B.drop (B.length marker) after')
|
||||
where
|
||||
marker = "${" <> label <> "}"
|
||||
@@ -1,18 +0,0 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Static.Embedded where
|
||||
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Data.ByteString (ByteString)
|
||||
|
||||
indexHtml :: ByteString
|
||||
indexHtml = $(embedFile "apps/smp-server/static/index.html")
|
||||
|
||||
linkHtml :: ByteString
|
||||
linkHtml = $(embedFile "apps/smp-server/static/link.html")
|
||||
|
||||
mediaContent :: [(FilePath, ByteString)]
|
||||
mediaContent = $(embedDir "apps/smp-server/static/media/")
|
||||
|
||||
wellKnown :: [(FilePath, ByteString)]
|
||||
wellKnown = $(embedDir "apps/smp-server/static/.well-known/")
|
||||
@@ -1,8 +1,10 @@
|
||||
module Main where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Simplex.FileTransfer.Server.Main (xftpServerCLI_)
|
||||
import Simplex.Messaging.Server.CLI (getEnvPath)
|
||||
import Simplex.FileTransfer.Server.Main
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles)
|
||||
import XFTPWeb (xftpGenerateSite)
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
defaultCfgPath = "/etc/opt/simplex-xftp"
|
||||
@@ -18,4 +20,4 @@ main = do
|
||||
setLogLevel LogDebug -- change to LogError in production
|
||||
cfgPath <- getEnvPath "XFTP_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "XFTP_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ xftpServerCLI cfgPath logPath
|
||||
withGlobalLogging logCfg $ xftpServerCLI_ xftpGenerateSite serveStaticFiles cfgPath logPath
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module XFTPWeb
|
||||
( xftpGenerateSite,
|
||||
xftpServerInformation,
|
||||
) where
|
||||
|
||||
import Control.Monad (forM_)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.String (fromString)
|
||||
import Web.Embedded (embeddedContent)
|
||||
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Expiration (ExpirationConfig (..))
|
||||
import Simplex.Messaging.Server.Information (ServerPublicInfo)
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
import System.Directory (createDirectoryIfMissing)
|
||||
import System.FilePath ((</>))
|
||||
|
||||
xftpWebContent :: [(FilePath, ByteString)]
|
||||
xftpWebContent = $(embedDir "apps/xftp-server/static/xftp-web-bundle/")
|
||||
|
||||
xftpMediaContent :: [(FilePath, ByteString)]
|
||||
xftpMediaContent = $(embedDir "apps/xftp-server/static/media/")
|
||||
|
||||
xftpFilePageHtml :: ByteString
|
||||
xftpFilePageHtml = $(embedFile "apps/xftp-server/static/file.html")
|
||||
|
||||
xftpGenerateSite :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
|
||||
xftpGenerateSite cfg info onionHost path = do
|
||||
let substs = xftpSubsts cfg info onionHost
|
||||
Web.generateSite embeddedContent (render (Web.indexHtml embeddedContent) substs) [] path
|
||||
let xftpDir = path </> "xftp-web-bundle"
|
||||
mediaDir = path </> "media"
|
||||
fileDir = path </> "file"
|
||||
filePage xftpDir xftpWebContent
|
||||
filePage mediaDir xftpMediaContent
|
||||
createDirectoryIfMissing True fileDir
|
||||
B.writeFile (fileDir </> "index.html") $ render xftpFilePageHtml substs
|
||||
where
|
||||
filePage dir content_ = do
|
||||
createDirectoryIfMissing True dir
|
||||
forM_ content_ $ \(fp, content) -> B.writeFile (dir </> fp) content
|
||||
|
||||
xftpServerInformation :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
|
||||
xftpServerInformation cfg info onionHost = render (Web.indexHtml embeddedContent) (xftpSubsts cfg info onionHost)
|
||||
|
||||
xftpSubsts :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> [(ByteString, Maybe ByteString)]
|
||||
xftpSubsts XFTPServerConfig {fileExpiration, logStatsInterval, allowNewFiles, newFileBasicAuth} information onionHost =
|
||||
[("smpConfig", Nothing), ("xftpConfig", Just "y")] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "file-server.ini")]
|
||||
where
|
||||
substConfig =
|
||||
[ ("fileExpiration", Just $ maybe "Never" (fromString . timedTTLText . ttl) fileExpiration),
|
||||
("statsEnabled", Just . yesNo $ isJust logStatsInterval),
|
||||
("newUploadsAllowed", Just . yesNo $ allowNewFiles),
|
||||
("basicAuthEnabled", Just . yesNo $ isJust newFileBasicAuth)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
@@ -0,0 +1,115 @@
|
||||
<svg width="440" height="520" viewBox="-20 0 440 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sender browser -->
|
||||
<rect x="120" y="16" width="160" height="56" rx="10" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<text x="200" y="40" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#70F0F9">Sender's browser</text>
|
||||
<text x="200" y="56" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(112,240,249,0.7)">encrypts file</text>
|
||||
|
||||
<!-- Arrow down from sender to chunks -->
|
||||
<line x1="200" y1="72" x2="200" y2="120" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Chunks row -->
|
||||
<rect x="112" y="120" width="176" height="40" rx="8" fill="none" stroke="#70F0F9" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="145" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#70F0F9">encrypted chunks</text>
|
||||
|
||||
<!-- Arrows from chunks to routers -->
|
||||
<line x1="152" y1="160" x2="80" y2="220" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="200" y1="160" x2="200" y2="220" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="248" y1="160" x2="320" y2="220" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Router 1 (SimpleX) -->
|
||||
<rect x="20" y="220" width="120" height="56" rx="6" fill="none" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<g transform="translate(28, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#70F0F9"/>
|
||||
</g>
|
||||
<text x="80" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#70F0F9">SimpleX</text>
|
||||
<text x="80" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="rgba(112,240,249,0.7)">XFTP router</text>
|
||||
|
||||
<!-- Router 2 (Flux) -->
|
||||
<rect x="155" y="220" width="90" height="56" rx="6" fill="none" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<g transform="translate(163, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#70F0F9"/>
|
||||
</g>
|
||||
<text x="200" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#70F0F9">Flux</text>
|
||||
<text x="200" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="rgba(112,240,249,0.7)">XFTP router</text>
|
||||
|
||||
<!-- Router 3 (SimpleX) -->
|
||||
<rect x="260" y="220" width="120" height="56" rx="6" fill="none" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<g transform="translate(268, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#70F0F9"/>
|
||||
</g>
|
||||
<text x="320" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#70F0F9">SimpleX</text>
|
||||
<text x="320" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="rgba(112,240,249,0.7)">XFTP router</text>
|
||||
|
||||
<!-- Arrows from routers down -->
|
||||
<line x1="80" y1="276" x2="152" y2="336" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="200" y1="276" x2="200" y2="336" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="320" y1="276" x2="248" y2="336" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Re-encrypt label -->
|
||||
<text x="330" y="310" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="rgba(112,240,249,0.7)">re-encrypted</text>
|
||||
<text x="330" y="322" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="rgba(112,240,249,0.7)">per recipient</text>
|
||||
|
||||
<!-- Chunks row (download) -->
|
||||
<rect x="112" y="336" width="176" height="40" rx="8" fill="none" stroke="#70F0F9" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="361" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#70F0F9">encrypted chunks</text>
|
||||
|
||||
<!-- Arrow down to recipient -->
|
||||
<line x1="200" y1="376" x2="200" y2="424" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Recipient browser -->
|
||||
<rect x="120" y="424" width="160" height="56" rx="10" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<text x="200" y="448" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#70F0F9">Recipient's browser</text>
|
||||
<text x="200" y="464" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(112,240,249,0.7)">decrypts file</text>
|
||||
|
||||
<!-- Key path (dashed, side) -->
|
||||
<path d="M120 44 L8 44 L8 452 L120 452" stroke="#70F0F9" stroke-width="1.5" stroke-dasharray="6 4" fill="none" marker-end="url(#arrowC)"/>
|
||||
<text x="-6" y="240" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#70F0F9" transform="rotate(-90 -6 240)">key in URL fragment - never sent to page server or data router</text>
|
||||
|
||||
|
||||
<!-- Closed padlock: encryption (between sender and chunks) -->
|
||||
<g transform="translate(192, 88)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V7" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#60a5fa"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#0B2A59"/>
|
||||
</g>
|
||||
|
||||
<!-- Open padlock: decryption (between chunks and recipient) -->
|
||||
<g transform="translate(192, 392)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V2" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#60a5fa"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#0B2A59"/>
|
||||
</g>
|
||||
|
||||
<!-- Key icon on dashed line -->
|
||||
<g transform="translate(8, 410)">
|
||||
<circle cx="0" cy="0" r="6" stroke="#FBBF24" stroke-width="2" fill="#FBBF24"/>
|
||||
<circle cx="0" cy="0" r="2" fill="#0B2A59"/>
|
||||
<line x1="6" y1="0" x2="16" y2="0" stroke="#FBBF24" stroke-width="2"/>
|
||||
<line x1="14" y1="0" x2="14" y2="4" stroke="#FBBF24" stroke-width="2"/>
|
||||
<line x1="11" y1="0" x2="11" y2="3.5" stroke="#FBBF24" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Annotation: no shared IDs -->
|
||||
<text x="200" y="510" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="rgba(112,240,249,0.7)">Each file fragment uses unique anonymous credentials - no shared identifiers</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrowC" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#70F0F9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,130 @@
|
||||
<svg width="440" height="520" viewBox="-20 0 440 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sender browser -->
|
||||
<rect x="120" y="16" width="160" height="56" rx="10" fill="url(#gBox)" stroke="#606C71" stroke-width="1.5"/>
|
||||
<text x="200" y="40" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#fff">Sender's browser</text>
|
||||
<text x="200" y="56" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(255,255,255,0.8)">encrypts file</text>
|
||||
|
||||
<!-- Arrow down from sender to chunks -->
|
||||
<line x1="200" y1="72" x2="200" y2="120" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Chunks row -->
|
||||
<rect x="112" y="120" width="176" height="40" rx="8" fill="#f0f7ff" stroke="#0053D0" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="145" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#0053D0">encrypted chunks</text>
|
||||
|
||||
<!-- Arrows from chunks to routers -->
|
||||
<line x1="152" y1="160" x2="80" y2="220" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="200" y1="160" x2="200" y2="220" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="248" y1="160" x2="320" y2="220" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Router 1 (SimpleX) -->
|
||||
<rect x="20" y="220" width="120" height="56" rx="6" fill="#f0f4f8" stroke="#606C71" stroke-width="1.5"/>
|
||||
<g transform="translate(28, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#53C1FF"/>
|
||||
</g>
|
||||
<text x="80" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#3F484B">SimpleX</text>
|
||||
<text x="80" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="#606C71">XFTP router</text>
|
||||
|
||||
<!-- Router 2 (Flux) -->
|
||||
<rect x="155" y="220" width="90" height="56" rx="6" fill="#f0f4f8" stroke="#606C71" stroke-width="1.5"/>
|
||||
<g transform="translate(163, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#53C1FF"/>
|
||||
</g>
|
||||
<text x="200" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#3F484B">Flux</text>
|
||||
<text x="200" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="#606C71">XFTP router</text>
|
||||
|
||||
<!-- Router 3 (SimpleX) -->
|
||||
<rect x="260" y="220" width="120" height="56" rx="6" fill="#f0f4f8" stroke="#606C71" stroke-width="1.5"/>
|
||||
<g transform="translate(268, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#53C1FF"/>
|
||||
</g>
|
||||
<text x="320" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#3F484B">SimpleX</text>
|
||||
<text x="320" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="#606C71">XFTP router</text>
|
||||
|
||||
<!-- Arrows from routers down -->
|
||||
<line x1="80" y1="276" x2="152" y2="336" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="200" y1="276" x2="200" y2="336" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="320" y1="276" x2="248" y2="336" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Re-encrypt label -->
|
||||
<text x="330" y="310" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="#606C71">re-encrypted</text>
|
||||
<text x="330" y="322" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="#606C71">per recipient</text>
|
||||
|
||||
<!-- Chunks row (download) -->
|
||||
<rect x="112" y="336" width="176" height="40" rx="8" fill="#f0f7ff" stroke="#0053D0" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="361" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#0053D0">encrypted chunks</text>
|
||||
|
||||
<!-- Arrow down to recipient -->
|
||||
<line x1="200" y1="376" x2="200" y2="424" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Recipient browser -->
|
||||
<rect x="120" y="424" width="160" height="56" rx="10" fill="url(#gBox)" stroke="#606C71" stroke-width="1.5"/>
|
||||
<text x="200" y="448" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#fff">Recipient's browser</text>
|
||||
<text x="200" y="464" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(255,255,255,0.8)">decrypts file</text>
|
||||
|
||||
<!-- Key path (dashed, side) -->
|
||||
<path d="M120 44 L8 44 L8 452 L120 452" stroke="#0053D0" stroke-width="1.5" stroke-dasharray="6 4" fill="none" marker-end="url(#arrowB)"/>
|
||||
<text x="-6" y="240" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#0053D0" transform="rotate(-90 -6 240)">key in URL fragment - never sent to page server or data router</text>
|
||||
|
||||
|
||||
<!-- Closed padlock: encryption (between sender and chunks) -->
|
||||
<g transform="translate(192, 88)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V7" stroke="#0053D0" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#0053D0"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#fff"/>
|
||||
</g>
|
||||
|
||||
<!-- Open padlock: decryption (between chunks and recipient) -->
|
||||
<g transform="translate(192, 392)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V2" stroke="#0053D0" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#0053D0"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#fff"/>
|
||||
</g>
|
||||
|
||||
<!-- Key icon on dashed line -->
|
||||
<g transform="translate(8, 410)">
|
||||
<circle cx="0" cy="0" r="6" stroke="#D97706" stroke-width="2" fill="#D97706"/>
|
||||
<circle cx="0" cy="0" r="2" fill="#fff"/>
|
||||
<line x1="6" y1="0" x2="16" y2="0" stroke="#D97706" stroke-width="2"/>
|
||||
<line x1="14" y1="0" x2="14" y2="4" stroke="#D97706" stroke-width="2"/>
|
||||
<line x1="11" y1="0" x2="11" y2="3.5" stroke="#D97706" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Annotation: no shared IDs -->
|
||||
<text x="200" y="510" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#606C71">Each file fragment uses unique anonymous credentials - no shared identifiers</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="gBox" x1="120" y1="16" x2="280" y2="72" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0053D0"/>
|
||||
<stop offset="1" stop-color="#53C1FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gSrv1" x1="20" y1="220" x2="140" y2="276" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0053D0"/>
|
||||
<stop offset="1" stop-color="#53C1FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gSrv2" x1="155" y1="220" x2="245" y2="276" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0053D0"/>
|
||||
<stop offset="1" stop-color="#53C1FF"/>
|
||||
</linearGradient>
|
||||
<marker id="arrowG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#606C71"/>
|
||||
</marker>
|
||||
<marker id="arrowB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0053D0"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,145 @@
|
||||
#app, [data-xftp-app] {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #333;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
--xftp-ring-fg: #3b82f6;
|
||||
}
|
||||
|
||||
:is(#app, [data-xftp-app]) .card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:is(#app, [data-xftp-app]) h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:is(#app, [data-xftp-app]) .stage { margin-top: 16px; }
|
||||
|
||||
/* Drop zone */
|
||||
:is(#app, [data-xftp-app]) .drop-zone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 32px 16px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .drop-zone.drag-over {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
:is(#app, [data-xftp-app]) .btn {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: .9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .btn:hover { background: #2563eb; }
|
||||
:is(#app, [data-xftp-app]) .btn-secondary { background: #6b7280; }
|
||||
:is(#app, [data-xftp-app]) .btn-secondary:hover { background: #4b5563; }
|
||||
|
||||
/* Hints */
|
||||
:is(#app, [data-xftp-app]) .hint { color: #999; font-size: .85rem; margin-top: 8px; }
|
||||
:is(#app, [data-xftp-app]) .expiry { margin-top: 12px; }
|
||||
|
||||
/* Progress */
|
||||
:is(#app, [data-xftp-app]) .progress-ring { display: block; margin: 0 auto 12px; }
|
||||
:is(#app, [data-xftp-app]) #upload-status,
|
||||
:is(#app, [data-xftp-app]) #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
|
||||
|
||||
/* Share link row */
|
||||
:is(#app, [data-xftp-app]) .link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .link-row input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: .85rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* Upload link */
|
||||
:is(#app, [data-xftp-app]) .upload-link {
|
||||
margin-top: 12px;
|
||||
color: #3b82f6;
|
||||
font-size: .9rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .upload-link:not([hidden]) {
|
||||
display: inline-block;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .upload-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Messages */
|
||||
:is(#app, [data-xftp-app]) .success { color: #16a34a; font-weight: 600; }
|
||||
:is(#app, [data-xftp-app]) .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
|
||||
|
||||
/* Security note */
|
||||
:is(#app, [data-xftp-app]) .security-note {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: #f0fdf4;
|
||||
border-radius: 6px;
|
||||
font-size: .8rem;
|
||||
color: #555;
|
||||
text-align: left;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .security-note p + p { margin-top: 6px; }
|
||||
:is(#app, [data-xftp-app]) .security-note a { color: #3b82f6; text-decoration: none; }
|
||||
:is(#app, [data-xftp-app]) .security-note a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────── */
|
||||
.dark :is(#app, [data-xftp-app]) {
|
||||
color: #e5e7eb;
|
||||
--xftp-ring-bg: #374151;
|
||||
--xftp-ring-fg: #60a5fa;
|
||||
--xftp-ring-text: #e5e7eb;
|
||||
--xftp-ring-done: #4ade80;
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .card {
|
||||
background: #1f2937;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .drop-zone { border-color: #4b5563; }
|
||||
.dark :is(#app, [data-xftp-app]) .drop-zone.drag-over {
|
||||
border-color: #60a5fa;
|
||||
background: rgba(59,130,246,.15);
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .btn-secondary { background: #4b5563; }
|
||||
.dark :is(#app, [data-xftp-app]) .btn-secondary:hover { background: #374151; }
|
||||
.dark :is(#app, [data-xftp-app]) .hint { color: #9ca3af; }
|
||||
.dark :is(#app, [data-xftp-app]) #upload-status,
|
||||
.dark :is(#app, [data-xftp-app]) #dl-status { color: #9ca3af; }
|
||||
.dark :is(#app, [data-xftp-app]) .link-row input {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .success { color: #4ade80; }
|
||||
.dark :is(#app, [data-xftp-app]) .error { color: #f87171; }
|
||||
.dark :is(#app, [data-xftp-app]) .security-note {
|
||||
background: rgba(34,197,94,.1);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .upload-link { color: #60a5fa; }
|
||||
.dark :is(#app, [data-xftp-app]) .security-note a { color: #60a5fa; }
|
||||
@@ -0,0 +1,104 @@
|
||||
# Coding and building
|
||||
|
||||
This file provides guidance on coding style and approaches and on building the code.
|
||||
|
||||
## Code Security
|
||||
|
||||
When designing code and planning implementations:
|
||||
- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious.
|
||||
- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances.
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
Haskell client and server code serves as system specification, not just implementation — we use type-driven design to reflect the business domain in types. Quality, conciseness, and clarity of Haskell code are critical.
|
||||
|
||||
## Code Style, Formatting and Approaches
|
||||
|
||||
The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`.
|
||||
|
||||
**Key formatting rules:**
|
||||
- 2-space indentation
|
||||
- Trailing function arrows, commas, and import/export style
|
||||
- Record brace without space: `{field = value}`
|
||||
- Single newline between declarations
|
||||
- Never use unicode symbols
|
||||
- Inline `let` style with right-aligned `in`
|
||||
|
||||
**Format code before committing:**
|
||||
|
||||
```bash
|
||||
# Format a single file
|
||||
fourmolu -i src/Simplex/Messaging/Protocol.hs
|
||||
```
|
||||
|
||||
Some files that use CPP language extension cannot be formatted as a whole, so individual code fragments need to be formatted.
|
||||
|
||||
**Follow existing code patterns:**
|
||||
- Match the style of surrounding code
|
||||
- Use qualified imports with short aliases (e.g., `import qualified Data.ByteString.Char8 as B`)
|
||||
- Use record syntax for types with multiple fields
|
||||
- Prefer explicit pattern matching over partial functions
|
||||
|
||||
**Comments policy:**
|
||||
- Avoid redundant comments that restate what the code already says
|
||||
- Only comment on non-obvious design decisions or tricky implementation details
|
||||
- Function names and type signatures should be self-documenting
|
||||
- Do not add comments like "wire format encoding" (Encoding class is always wire format) or "check if X" when the function name already says that
|
||||
- Assume a competent Haskell reader
|
||||
|
||||
**Diff and refactoring:**
|
||||
- Avoid unnecessary changes and code movements
|
||||
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring
|
||||
- Aim to minimize the code changes - do what is minimally required to solve users' problems
|
||||
|
||||
**Document and code structure:**
|
||||
- **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure.
|
||||
- When adding new sections to documents, continue the existing numbering scheme.
|
||||
- Minimize diff size - prefer small, targeted changes over reorganization.
|
||||
|
||||
**Code analysis and review:**
|
||||
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug.
|
||||
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication.
|
||||
- Do not save time on analysis. Read every function in the data flow even when the interface seems clear — wrong assumptions about internals are the main source of missed bugs.
|
||||
|
||||
### Haskell Extensions
|
||||
- `StrictData` enabled by default
|
||||
- Use STM for safe concurrency
|
||||
- Assume concurrency in PostgreSQL queries
|
||||
- Comprehensive warning flags with strict pattern matching
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Standard build
|
||||
cabal build
|
||||
|
||||
# Fast build
|
||||
cabal build --ghc-options -O0
|
||||
|
||||
# Build specific executables
|
||||
cabal build exe:smp-server exe:xftp-server exe:ntf-server exe:xftp
|
||||
|
||||
# Build with PostgreSQL server support
|
||||
cabal build -fserver_postgres
|
||||
|
||||
# Client-only library build (no server code)
|
||||
cabal build -fclient_library
|
||||
|
||||
# Find binary location
|
||||
cabal list-bin exe:smp-server
|
||||
```
|
||||
|
||||
### Cabal Flags
|
||||
|
||||
- `swift`: Enable Swift JSON format
|
||||
- `client_library`: Build without server code
|
||||
- `client_postgres`: Use PostgreSQL instead of SQLite for agent persistence
|
||||
- `server_postgres`: PostgreSQL support for server queue/notification store
|
||||
|
||||
## External Dependencies
|
||||
|
||||
Custom forks specified in `cabal.project`:
|
||||
- `aeson`, `hs-socks` (SimpleX forks)
|
||||
- `direct-sqlcipher`, `sqlcipher-simple` (encrypted SQLite)
|
||||
- `warp`, `warp-tls` (HTTP server)
|
||||
@@ -0,0 +1,105 @@
|
||||
# SimpleXMQ repository
|
||||
|
||||
This file provides guidance on the project structure to help working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SimpleXMQ is a Haskell message broker implementing unidirectional (simplex) queues for privacy-preserving messaging.
|
||||
|
||||
Key components:
|
||||
|
||||
- **SimpleX Messaging Protocol**: SMP protocol definition and encodings ([code](../src/Simplex/Messaging/Protocol.hs), [transport code](../src/Simplex/Messaging/Transport.hs), [spec](../protocol/simplex-messaging.md)).
|
||||
- **SMP Server**: Message broker with TLS, in-memory queues, optional persistence ([main code](../src/Simplex/Messaging/Server.hs), [all code files](../src/Simplex/Messaging/Server/), [executable](../apps/smp-server/)). For proxying SMP commands the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
|
||||
- **SMP Client**: Functional API with STM-based message delivery ([code](../src/Simplex/Messaging/Client.hs)).
|
||||
- **SMP Agent**: High-level duplex connections via multiple simplex queues with E2E encryption ([code](../src/Simplex/Messaging/Agent.hs)). Implements Agent-to-agent protocol ([code](../src/Simplex/Messaging/Agent/Protocol.hs), [spec](../protocol/agent-protocol.md)) via intermediary agent client ([code](../src/Simplex/Messaging/Agent/Client.hs)).
|
||||
- **XFTP**: SimpleX File Transfer Protocol, server and CLI client ([code](../src/Simplex/FileTransfer/), [spec](../protocol/xftp.md)).
|
||||
- **XRCP**: SimpleX Remote Control Protocol ([code](`../src/Simplex/RemoteControl/`), [spec](../protocol/xrcp.md)).
|
||||
- **Notifications**: Push notifications server requires PostgreSQL ([code](../src/Simplex/Messaging/Notifications), [executable](../apps/ntf-server/)). Client protocol is used for clients to communicate with the server ([code](../src/Simplex/Messaging/Notifications/Protocol.hs), [spec](../protocol/push-notifications.md)). For subscribing to SMP notifications the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
|
||||
|
||||
## Architecture
|
||||
|
||||
For general overview see `../protocol/overview-tjr.md`.
|
||||
|
||||
SMP Protocol Layers:
|
||||
|
||||
```
|
||||
TLS Transport → SMP Protocol → Agent Protocol → Application protocol
|
||||
```
|
||||
|
||||
XFTP Protocol Layers:
|
||||
|
||||
```
|
||||
TLS Transport (HTTP2 encoding) → XFTP Protocol → Out-of-band file descriptions
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Persistence**: All queue state managed via Software Transactional Memory or via PostgreSQL
|
||||
- `Simplex.Messaging.Server.MsgStore.STM` - in-memory messages
|
||||
- `Simplex.Messaging.Server.QueueStore.STM` - in-memory queue state
|
||||
- `Simplex.Messaging.Server.MsgStore.Postgres` - message storage
|
||||
- `Simplex.Messaging.Server.QueueStore.Postgres` - queue storage
|
||||
|
||||
2. **Append-Only Store Log**: Optional persistence via journal for in-memory storage
|
||||
- `Simplex.Messaging.Server.StoreLog` - queue creation log
|
||||
- Compacted on restart
|
||||
|
||||
3. **Agent Storage**:
|
||||
- SQLite (default) or PostgreSQL
|
||||
- Migrations in `src/Simplex/Messaging/Agent/Store/{SQLite,Postgres}/Migrations/`
|
||||
|
||||
4. **Protocol Versioning**: All layers support version negotiation
|
||||
- `Simplex.Messaging.Version` - version range utilities
|
||||
|
||||
5. **Double Ratchet E2E**: Per-connection encryption
|
||||
- `Simplex.Messaging.Crypto.Ratchet`
|
||||
- SNTRUP761 post-quantum KEM (`src/Simplex/Messaging/Crypto/SNTRUP761/`)
|
||||
|
||||
## Source Layout
|
||||
|
||||
```
|
||||
src/Simplex/
|
||||
├── Messaging/
|
||||
│ ├── Agent.hs # Main agent (~210KB)
|
||||
│ ├── Server.hs # SMP server (~130KB)
|
||||
│ ├── Client.hs # Client API (~65KB)
|
||||
│ ├── Protocol.hs # Protocol types (~77KB)
|
||||
│ ├── Crypto.hs # E2E encryption (~52KB)
|
||||
│ ├── Transport.hs # Transport encoding over TLS
|
||||
│ ├── Agent/Store/ # SQLite/Postgres persistence
|
||||
│ ├── Server/ # Server internals (QueueStore, MsgStore, Control)
|
||||
│ └── Notifications/ # Push notification system
|
||||
├── FileTransfer/ # XFTP implementation for file transfers
|
||||
└── RemoteControl/ # XRCP implementation for device discovery & control
|
||||
```
|
||||
|
||||
## Protocol Documentation
|
||||
|
||||
- `protocol/overview-tjr.md`: SMP protocols stack overview
|
||||
- `protocol/simplex-messaging.md`: SMP protocol spec (v19)
|
||||
- `protocol/agent-protocol.md`: Agent protocol spec (v7)
|
||||
- `protocol/xftp.md`: File transfer protocol
|
||||
- `protocol/xrcp.md`: Remote control protocol
|
||||
- `rfcs/`: Design RFCs for features
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cabal test --test-show-details=streaming
|
||||
|
||||
# Run specific test group (uses HSpec)
|
||||
cabal test --test-option=--match="/Core tests/Encryption tests/"
|
||||
|
||||
# Run single test
|
||||
cabal test --test-option=--match="/SMP client agent/functional API/"
|
||||
```
|
||||
|
||||
Tests require PostgreSQL running on `localhost:5432` when using `-fserver_postgres` or `-fclient_postgres`.
|
||||
|
||||
Test files are in `tests/` with structure:
|
||||
- `Test.hs`: Main runner
|
||||
- `AgentTests/`: Agent protocol and connection tests
|
||||
- `CoreTests/`: Crypto, encoding, storage tests
|
||||
- `ServerTests.hs`: SMP server tests
|
||||
- `XFTPServerTests.hs`: File transfer tests
|
||||
@@ -0,0 +1,23 @@
|
||||
# Contributing to SimpleX repositories
|
||||
|
||||
## Focus on user problems
|
||||
|
||||
We do not make code changes to improve code - any change must address a specific user problem or request.
|
||||
|
||||
## Discuss the plans as early as possible
|
||||
|
||||
Please discuss the problem you want to solve and your detailed implementation plan with the project team prior to contributing, to avoid wasted time and additional changes. Acceptance of your contribution depends on your willingness and ability to iterate the proposed contribution to achieve the required quality level, coding style, test coverage, and alignment with user requirements as they are understood by the project team.
|
||||
|
||||
## Follow project structure, coding style and approaches
|
||||
|
||||
./PROJECT.md has information about the structure of this `simplexmq` repository.
|
||||
|
||||
./CODE.md has details about general requirements common for `simplexmq` and `simplex-chat` repositories.
|
||||
|
||||
This files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files:
|
||||
|
||||
```markdown
|
||||
@README.md
|
||||
@contributing/PROJECT.md
|
||||
@contributing/CODE.md
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
common:
|
||||
corrId - random BS, used as CbNonce
|
||||
entityId - p2r tlsUniq
|
||||
|
||||
# setup
|
||||
s->p: "proxy", uri, auth?
|
||||
# unless connected
|
||||
p->r: "p_handshake"
|
||||
p<-r: "r_key", tls-signed dh pub
|
||||
s<-r: "r_key", tls-signed dh pub # reply entityId contains tlsUniq
|
||||
|
||||
# working
|
||||
s ; generate random dh priv, make shared secret
|
||||
s->p: s2r("forward", random dh pub, SEND command blob)
|
||||
p->r: p2r("forward", random dh pub, s2r("forward", ...)))
|
||||
r->c@ "msg", ...
|
||||
p<-r: p2r("r_res", s2r("ok" / "error", error))
|
||||
s<-p@ s2r("ok" / "error", error)
|
||||
|
||||
# expired
|
||||
p<-r@ p2r("error", "key expired")
|
||||
s<-p@ "error", "key expired"
|
||||
s ; reconnect
|
||||
@@ -0,0 +1,472 @@
|
||||
# XFTP Server PostgreSQL Backend
|
||||
|
||||
## Overview
|
||||
|
||||
Add PostgreSQL backend support to xftp-server, following the SMP server pattern. Supports bidirectional migration between STM (in-memory with StoreLog) and PostgreSQL backends.
|
||||
|
||||
## Goals
|
||||
|
||||
- PostgreSQL-backed file metadata storage as an alternative to STM + StoreLog
|
||||
- Polymorphic server code via `FileStoreClass` typeclass with IO-based methods (following `QueueStoreClass` pattern)
|
||||
- Bidirectional migration: StoreLog <-> PostgreSQL via CLI commands
|
||||
- Shared `server_postgres` cabal flag (same flag enables both SMP and XFTP Postgres support)
|
||||
- INI-based backend selection at runtime
|
||||
|
||||
## Architecture
|
||||
|
||||
### FileStoreClass Typeclass
|
||||
|
||||
IO-based typeclass following the `QueueStoreClass` pattern — each method is a self-contained IO action, with the implementation responsible for its own atomicity (STM backend wraps in `atomically`, Postgres backend uses database transactions):
|
||||
|
||||
```haskell
|
||||
class FileStoreClass s where
|
||||
type FileStoreConfig s
|
||||
|
||||
-- Lifecycle
|
||||
newFileStore :: FileStoreConfig s -> IO s
|
||||
closeFileStore :: s -> IO ()
|
||||
|
||||
-- File operations
|
||||
addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ())
|
||||
setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ())
|
||||
addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ())
|
||||
getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey))
|
||||
deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ())
|
||||
blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ())
|
||||
deleteRecipient :: s -> RecipientId -> FileRec -> IO ()
|
||||
ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ())
|
||||
|
||||
-- Expiration (with LIMIT for Postgres; called in a loop until empty)
|
||||
expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
|
||||
|
||||
-- Storage and stats (for init-time computation)
|
||||
getUsedStorage :: s -> IO Int64
|
||||
getFileCount :: s -> IO Int
|
||||
```
|
||||
|
||||
- STM backend: each method wraps its STM transaction in `atomically` internally.
|
||||
- Postgres backend: each method runs its query via `withDB` / database connection internally.
|
||||
|
||||
No polymorphic monad or `runStore` dispatcher needed — unlike `MsgStoreClass`, XFTP file operations are individually atomic and don't require grouping multiple operations into backend-dependent transactions.
|
||||
|
||||
### PostgresFileStore Data Type
|
||||
|
||||
```haskell
|
||||
data PostgresFileStore = PostgresFileStore
|
||||
{ dbStore :: DBStore,
|
||||
dbStoreLog :: Maybe (StoreLog 'WriteMode)
|
||||
}
|
||||
```
|
||||
|
||||
- `dbStore` — connection pool created via `createDBStore`, runs schema migrations on init.
|
||||
- `dbStoreLog` — optional parallel log file (enabled by `db_store_log` INI setting). When present, every mutation (`addFile`, `setFilePath`, `deleteFile`, `blockFile`, `addRecipient`, `ackFile`) also writes to this log via a `withLog` wrapper. `withLog` is called AFTER the DB operation succeeds (so the log reflects committed state only). Log write failures are non-fatal (logged as warnings, do not fail the DB operation). This provides an audit trail and enables recovery via export.
|
||||
|
||||
`closeFileStore` for Postgres calls `closeDBStore` (closes connection pool) then `mapM_ closeStoreLog dbStoreLog` (flushes and closes the parallel log). For STM, it closes the storeLog. Called from a `finally` block during server shutdown, matching SMP's `stopServer` → `closeMsgStore` → `closeQueueStore` pattern.
|
||||
|
||||
### STMFileStore Type
|
||||
|
||||
After extracting from current `Store.hs`, `STMFileStore` retains the file and recipient maps but no longer owns `usedStorage` (moved to `XFTPEnv`):
|
||||
|
||||
```haskell
|
||||
data STMFileStore = STMFileStore
|
||||
{ files :: TMap SenderId FileRec,
|
||||
recipients :: TMap RecipientId (SenderId, RcvPublicAuthKey)
|
||||
}
|
||||
```
|
||||
|
||||
`closeFileStore` for STM is a no-op (TMaps are garbage-collected; the env-level `storeLog` is closed separately by the server).
|
||||
|
||||
### Error Handling
|
||||
|
||||
Postgres operations follow SMP's `withDB` / `handleDuplicate` pattern:
|
||||
|
||||
```haskell
|
||||
withDB :: Text -> PostgresFileStore -> (DB.Connection -> IO (Either XFTPErrorType a)) -> ExceptT XFTPErrorType IO a
|
||||
withDB op st action =
|
||||
ExceptT $ E.try (withTransaction (dbStore st) action) >>= either logErr pure
|
||||
where
|
||||
logErr :: E.SomeException -> IO (Either XFTPErrorType a)
|
||||
logErr e = logError ("STORE: " <> err) $> Left INTERNAL
|
||||
where
|
||||
err = op <> ", withDB, " <> tshow e
|
||||
|
||||
handleDuplicate :: SqlError -> IO (Either XFTPErrorType a)
|
||||
handleDuplicate e = case constraintViolation e of
|
||||
Just (UniqueViolation _) -> pure $ Left DUPLICATE_
|
||||
_ -> E.throwIO e
|
||||
```
|
||||
|
||||
- All DB operations wrapped in `withDB` — catches exceptions, logs, returns `INTERNAL`.
|
||||
- Unique constraint violations caught by `handleDuplicate` and mapped to `DUPLICATE_`.
|
||||
- UPDATE operations verified with `assertUpdated` — returns `AUTH` if 0 rows affected (matching SMP pattern, prevents silent failures when WHERE clause doesn't match).
|
||||
- Critical sections (DB write + TVar update) wrapped in `uninterruptibleMask_` to prevent async exceptions from leaving inconsistent state between DB and TVars.
|
||||
|
||||
### FileRec and TVar Fields
|
||||
|
||||
`FileRec` retains its `TVar` fields (matching SMP's `PostgresQueue` pattern):
|
||||
|
||||
```haskell
|
||||
data FileRec = FileRec
|
||||
{ senderId :: SenderId,
|
||||
fileInfo :: FileInfo,
|
||||
filePath :: TVar (Maybe FilePath),
|
||||
recipientIds :: TVar (Set RecipientId),
|
||||
createdAt :: RoundedFileTime,
|
||||
fileStatus :: TVar ServerEntityStatus
|
||||
}
|
||||
```
|
||||
|
||||
- **STM backend**: TVars are the source of truth, as currently.
|
||||
- **Postgres backend**: `getFile` reads from DB and creates a `FileRec` with fresh TVars populated from the DB row (matching SMP's `mkQ` pattern — `newTVarIO` per load). Mutation methods (`setFilePath`, `blockFile`, etc.) update both the DB (persistence) and the TVars (in-session consistency). The `recipientIds` TVar is initialized to `S.empty` — no subquery needed because no server code reads `recipientIds` directly; all recipient operations go through the typeclass methods (`addRecipient`, `deleteRecipient`, `ackFile`), which query the `recipients` table for Postgres.
|
||||
|
||||
### usedStorage Ownership
|
||||
|
||||
`usedStorage :: TVar Int64` moves from the store to `XFTPEnv`. The store typeclass does **not** manage `usedStorage` — it only provides `getUsedStorage` for init-time computation.
|
||||
|
||||
- **STM init**: StoreLog replay calls `setFilePath` (which only sets the filePath TVar — the STM `setFilePath` implementation is changed to **not** update `usedStorage`). Similarly, STM `deleteFile` (Store.hs line 117) and `blockFile` (line 125) are changed to **not** update `usedStorage` — the server handles all `usedStorage` adjustments externally. After replay, `getUsedStorage` computes the sum over all file sizes (matching current `countUsedStorage` behavior).
|
||||
- **Postgres init**: `getUsedStorage` executes `SELECT COALESCE(SUM(file_size), 0) FROM files`.
|
||||
- **Runtime**: Server manages `usedStorage` TVar directly for reserve/commit/rollback during uploads, and adjusts after `deleteFile`/`blockFile` calls.
|
||||
|
||||
**Note on `getUsedStorage` semantics**: The current STM `countUsedStorage` sums all file sizes unconditionally (including files without `filePath` set, i.e., created but not yet uploaded). The Postgres `getUsedStorage` matches this: `SELECT SUM(file_size) FROM files` (no `WHERE file_path IS NOT NULL`). In practice, orphaned files (created but never uploaded) are rare and short-lived (expired within 48h), so the difference is negligible. A future improvement could filter by `file_path IS NOT NULL` in both backends to reflect actual disk usage more accurately.
|
||||
|
||||
### Server.hs Refactoring
|
||||
|
||||
`Server.hs` becomes polymorphic over `FileStoreClass s`. Since all typeclass methods are IO, call sites replace `atomically` with direct IO calls to the store.
|
||||
|
||||
**Call sites requiring changes** (exhaustive list):
|
||||
|
||||
1. **`receiveServerFile`** (line 563): `atomically $ writeTVar filePath (Just fPath)` → `setFilePath store senderId fPath`. The `reserve` logic (line 551-555) stays as direct TVar manipulation on `usedStorage` from `XFTPEnv`.
|
||||
|
||||
2. **`verifyXFTPTransmission`** (line 453): `atomically $ verify =<< getFile st party fId` — the `getFile` call and subsequent `readTVar fileStatus` are in a single `atomically` block. Refactored to: `getFile st party fId` (IO), then `readTVarIO (fileStatus fr)` from the returned `FileRec` (safe for both backends — STM TVar is the source of truth, Postgres TVar is a fresh snapshot from DB).
|
||||
|
||||
3. **`retryAdd`** (line 516): Signature `XFTPFileId -> STM (Either XFTPErrorType a)` → `XFTPFileId -> IO (Either XFTPErrorType a)`. The `atomically` call (line 520) replaced with `liftIO`.
|
||||
|
||||
4. **`deleteOrBlockServerFile_`** (line 620): Parameter `FileStore -> STM (Either XFTPErrorType ())` → `FileStoreClass s => s -> IO (Either XFTPErrorType ())`. The `atomically` call (line 626) removed — the store method is already IO. After the store action, server adjusts `usedStorage` TVar in `XFTPEnv` based on `fileInfo.size`.
|
||||
|
||||
5. **`ackFileReception`** (line 605): `atomically $ deleteRecipient st rId fr` → `deleteRecipient st rId fr`.
|
||||
|
||||
6. **Control port `CPDelete`/`CPBlock`** (lines 371, 377): `atomically $ getFile fs SFRecipient fileId` → `getFile fs SFRecipient fileId`.
|
||||
|
||||
7. **`expireServerFiles`** (line 636): Replace per-file `expiredFilePath` iteration with batched `expiredFiles st old batchSize`, which returns `[(SenderId, Maybe FilePath, Word32)]` — the `Word32` file size is needed so the server can adjust the `usedStorage` TVar after each deletion. Called in a loop until the returned list is empty. The `itemDelay` between files applies to the deletion loop over each batch, not the query itself. STM backend ignores the batch size limit (returns all expired files from TMap scan); Postgres uses `LIMIT`.
|
||||
|
||||
8. **`restoreServerStats`** (line 694): `FileStore {files, usedStorage} <- asks store` accesses store fields directly. Refactored to: `usedStorage` from `XFTPEnv` via `asks usedStorage`, file count via `getFileCount store`. STM: `M.size <$> readTVarIO files`. Postgres: `SELECT COUNT(*) FROM files`.
|
||||
|
||||
### Store Config Selection
|
||||
|
||||
GADT in `Env.hs`:
|
||||
|
||||
```haskell
|
||||
data XFTPStoreConfig s where
|
||||
XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore
|
||||
#if defined(dbServerPostgres)
|
||||
XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore
|
||||
#endif
|
||||
```
|
||||
|
||||
`XFTPEnv` becomes polymorphic:
|
||||
|
||||
```haskell
|
||||
data XFTPEnv s = XFTPEnv
|
||||
{ config :: XFTPServerConfig,
|
||||
store :: s,
|
||||
usedStorage :: TVar Int64,
|
||||
storeLog :: Maybe (StoreLog 'WriteMode),
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The `M` monad (`ReaderT (XFTPEnv s) IO`) and all functions in `Server.hs` gain `FileStoreClass s =>` constraints.
|
||||
|
||||
**StoreLog lifecycle per backend:**
|
||||
|
||||
- **STM mode**: `storeLog = Just sl` (current behavior — append-only log for persistence and recovery).
|
||||
- **Postgres mode**: `storeLog = Nothing` (main storeLog disabled — Postgres is the source of truth). The optional parallel `dbStoreLog` inside `PostgresFileStore` provides audit/recovery if enabled via `db_store_log` INI setting.
|
||||
|
||||
The existing `withFileLog` pattern in Server.hs continues to work unchanged — it maps over `Maybe (StoreLog 'WriteMode)`, which is `Nothing` in Postgres mode so the calls become no-ops.
|
||||
|
||||
### Main.hs Store Type Dispatch
|
||||
|
||||
The `Start` CLI command gains a `--confirm-migrations` flag (default `MCConsole` — manual prompt, matching SMP's `StartOptions`). For automated deployments, `--confirm-migrations up` auto-applies forward migrations. The import command uses `MCYesUp` (always auto-apply).
|
||||
|
||||
Following SMP's existential dispatch pattern (`AStoreType` + `run`), `Main.hs` selects the store type from INI config and dispatches to the polymorphic server:
|
||||
|
||||
```haskell
|
||||
runServer ini = do
|
||||
let storeType = fromRight "memory" $ lookupValue "STORE_LOG" "store_files" ini
|
||||
case storeType of
|
||||
"memory" -> run $ XSCMemory (enableStoreLog $> storeLogFilePath)
|
||||
"database" ->
|
||||
#if defined(dbServerPostgres)
|
||||
run $ XSCDatabase PostgresFileStoreCfg {..}
|
||||
#else
|
||||
exitError "server not compiled with Postgres support"
|
||||
#endif
|
||||
_ -> exitError $ "Invalid store_files value: " <> storeType
|
||||
where
|
||||
run :: FileStoreClass s => XFTPStoreConfig s -> IO ()
|
||||
run storeCfg = do
|
||||
env <- newXFTPServerEnv storeCfg config
|
||||
runReaderT (xftpServer config) env
|
||||
```
|
||||
|
||||
**`newXFTPServerEnv` refactored signature:**
|
||||
|
||||
```haskell
|
||||
newXFTPServerEnv :: FileStoreClass s => XFTPStoreConfig s -> XFTPServerConfig -> IO (XFTPEnv s)
|
||||
newXFTPServerEnv storeCfg config = do
|
||||
(store, storeLog) <- case storeCfg of
|
||||
XSCMemory storeLogPath -> do
|
||||
st <- newFileStore ()
|
||||
sl <- mapM (`readWriteFileStore` st) storeLogPath
|
||||
pure (st, sl)
|
||||
XSCDatabase dbCfg -> do
|
||||
st <- newFileStore dbCfg
|
||||
pure (st, Nothing) -- main storeLog disabled for Postgres
|
||||
usedStorage <- newTVarIO =<< getUsedStorage store
|
||||
...
|
||||
pure XFTPEnv {config, store, usedStorage, storeLog, ...}
|
||||
```
|
||||
|
||||
### Startup Config Validation
|
||||
|
||||
Following SMP's `checkMsgStoreMode` pattern, `Main.hs` validates config before starting:
|
||||
|
||||
- **`store_files=database` + StoreLog file exists** (without `db_store_log=on`): Error — "StoreLog file present but store_files is `database`. Use `xftp-server database import` to migrate, or set `db_store_log: on`."
|
||||
- **`store_files=database` + schema doesn't exist**: Error — "Create schema in PostgreSQL or use `xftp-server database import`."
|
||||
- **`store_files=memory` + Postgres schema exists**: Warning — "Postgres schema exists but store_files is `memory`. Data in Postgres will not be used."
|
||||
- **Binary compiled without `server_postgres` + `store_files=database`**: Error — "Server not compiled with Postgres support."
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/Simplex/FileTransfer/Server/
|
||||
Store.hs -- FileStoreClass typeclass + shared types (FileRec, FileRecipient, etc.)
|
||||
Store/
|
||||
STM.hs -- STMFileStore (extracted from current Store.hs)
|
||||
Postgres.hs -- PostgresFileStore [CPP-guarded]
|
||||
Postgres/
|
||||
Migrations.hs -- Schema migrations [CPP-guarded]
|
||||
Config.hs -- PostgresFileStoreCfg [CPP-guarded]
|
||||
StoreLog.hs -- Unchanged (interchange format for both backends + migration)
|
||||
Env.hs -- XFTPStoreConfig GADT, polymorphic XFTPEnv
|
||||
Main.hs -- Store selection, migration CLI commands
|
||||
Server.hs -- Polymorphic over FileStoreClass
|
||||
```
|
||||
|
||||
## PostgreSQL Schema
|
||||
|
||||
Initial migration (`20260325_initial`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE files (
|
||||
sender_id BYTEA NOT NULL PRIMARY KEY,
|
||||
file_size INT4 NOT NULL,
|
||||
file_digest BYTEA NOT NULL,
|
||||
sender_key BYTEA NOT NULL,
|
||||
file_path TEXT,
|
||||
created_at INT8 NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE TABLE recipients (
|
||||
recipient_id BYTEA NOT NULL PRIMARY KEY,
|
||||
sender_id BYTEA NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
recipient_key BYTEA NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_recipients_sender_id ON recipients (sender_id);
|
||||
CREATE INDEX idx_files_created_at ON files (created_at);
|
||||
```
|
||||
|
||||
- `file_size` is `INT4` matching `Word32` in `FileInfo.size`
|
||||
- `sender_key` and `recipient_key` stored as `BYTEA` using binary encoding via `C.encodePubKey` / `C.decodePubKey` (matching SMP's `ToField`/`FromField` instances for `APublicAuthKey` — includes algorithm type tag in the binary format)
|
||||
- `file_path` nullable (set after upload completes via `setFilePath`)
|
||||
- `ON DELETE CASCADE` for recipients when file is hard-deleted
|
||||
- `created_at` stores rounded epoch seconds (1-hour precision, `RoundedFileTime`)
|
||||
- `status` as TEXT via `StrEncoding` (`ServerEntityStatus`: `EntityActive`, `EntityBlocked info`, `EntityOff`)
|
||||
- Hard deletes (no `deleted_at` column)
|
||||
- No PL/pgSQL functions needed; `setFilePath` uses `WHERE file_path IS NULL` to prevent duplicate uploads (the `UPDATE` itself acquires a row-level lock)
|
||||
- `used_storage` computed on startup: `SELECT COALESCE(SUM(file_size), 0) FROM files` (matches STM `countUsedStorage` — all files, see usedStorage Ownership section)
|
||||
|
||||
### Migrations Module
|
||||
|
||||
Following SMP's `QueueStore/Postgres/Migrations.hs` pattern:
|
||||
|
||||
```haskell
|
||||
module Simplex.FileTransfer.Server.Store.Postgres.Migrations
|
||||
( xftpServerMigrations,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.List (sortOn)
|
||||
import Data.Text (Text)
|
||||
import Simplex.Messaging.Agent.Store.Shared
|
||||
import Text.RawString.QQ (r)
|
||||
|
||||
xftpSchemaMigrations :: [(String, Text, Maybe Text)]
|
||||
xftpSchemaMigrations =
|
||||
[ ("20260325_initial", m20260325_initial, Nothing)
|
||||
]
|
||||
|
||||
xftpServerMigrations :: [Migration]
|
||||
xftpServerMigrations = sortOn name $ map migration xftpSchemaMigrations
|
||||
where
|
||||
migration (name, up, down) = Migration {name, up, down = down}
|
||||
|
||||
m20260325_initial :: Text
|
||||
m20260325_initial =
|
||||
[r|
|
||||
CREATE TABLE files (
|
||||
sender_id BYTEA NOT NULL PRIMARY KEY,
|
||||
...
|
||||
);
|
||||
|]
|
||||
```
|
||||
|
||||
The `Migration` type (from `Simplex.Messaging.Agent.Store.Shared`) has fields `{name :: String, up :: Text, down :: Maybe Text}`. Initial migration has `Nothing` for `down`. Future migrations should include `Just down_migration` for rollback support. Called via `createDBStore dbOpts xftpServerMigrations (MigrationConfig confirmMigrations Nothing)`.
|
||||
|
||||
### Postgres Operations
|
||||
|
||||
Key query patterns:
|
||||
|
||||
- **`addFile`**: `INSERT INTO files (...) VALUES (...)`, return `DUPLICATE_` on unique violation.
|
||||
- **`setFilePath`**: `UPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL`, verified with `assertUpdated` (returns `AUTH` if 0 rows affected — file not found or already uploaded). The `WHERE file_path IS NULL` prevents duplicate uploads; the `UPDATE` acquires a row lock implicitly. Only persists the path; `usedStorage` managed by server.
|
||||
- **`addRecipient`**: `INSERT INTO recipients (...)`, plus check for duplicates. No need for `recipientIds` TVar update — Postgres derives it from the table.
|
||||
- **`getFile`** (sender): `SELECT ... FROM files WHERE sender_id = ?`, returns auth key from `sender_key` column.
|
||||
- **`getFile`** (recipient): `SELECT f.*, r.recipient_key FROM recipients r JOIN files f ON ... WHERE r.recipient_id = ?`.
|
||||
- **`deleteFile`**: `DELETE FROM files WHERE sender_id = ?` (recipients cascade).
|
||||
- **`blockFile`**: `UPDATE files SET status = ? WHERE sender_id = ?`. When `deleted = True`, the server adjusts `usedStorage` externally (matching current STM behavior where `blockFile` only updates status and storage, not `filePath`).
|
||||
- **`expiredFiles`**: `SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? LIMIT ?` — batched query replaces per-file iteration, includes `file_size` for `usedStorage` adjustment. Called in a loop until no rows returned.
|
||||
|
||||
## INI Configuration
|
||||
|
||||
New keys in `[STORE_LOG]` section:
|
||||
|
||||
```ini
|
||||
[STORE_LOG]
|
||||
enable: on
|
||||
store_files: memory # memory | database
|
||||
db_connection: postgresql://xftp@/xftp_server_store
|
||||
db_schema: xftp_server
|
||||
db_pool_size: 10
|
||||
db_store_log: off
|
||||
expire_files_hours: 48
|
||||
```
|
||||
|
||||
`store_files` selects the backend (`store_files` rather than `store_queues` because XFTP stores files, not queues):
|
||||
- `memory` -> `XSCMemory` (current behavior)
|
||||
- `database` -> `XSCDatabase` (requires `server_postgres` build flag)
|
||||
|
||||
### INI Template Generation (`xftp-server init`)
|
||||
|
||||
The `iniFileContent` function in `Main.hs` must be updated to generate the new keys in the `[STORE_LOG]` section. Following SMP's `iniDbOpts` pattern with `optDisabled'` (prefixes `"# "` when value equals default), Postgres keys are generated commented out by default:
|
||||
|
||||
```ini
|
||||
[STORE_LOG]
|
||||
enable: on
|
||||
|
||||
# File storage mode: `memory` or `database` (PostgreSQL).
|
||||
store_files: memory
|
||||
|
||||
# Database connection settings for PostgreSQL database (`store_files: database`).
|
||||
# db_connection: postgresql://xftp@/xftp_server_store
|
||||
# db_schema: xftp_server
|
||||
# db_pool_size: 10
|
||||
|
||||
# Write database changes to store log file
|
||||
# db_store_log: off
|
||||
|
||||
expire_files_hours: 48
|
||||
```
|
||||
|
||||
Reuses `iniDBOptions` from `Simplex.Messaging.Server.CLI` for runtime parsing (falls back to defaults when keys are commented out or missing). `enableDbStoreLog'` pattern (`settingIsOn "STORE_LOG" "db_store_log"`) controls `dbStoreLogPath`.
|
||||
|
||||
### PostgresFileStoreCfg
|
||||
|
||||
```haskell
|
||||
data PostgresFileStoreCfg = PostgresFileStoreCfg
|
||||
{ dbOpts :: DBOpts,
|
||||
dbStoreLogPath :: Maybe FilePath,
|
||||
confirmMigrations :: MigrationConfirmation
|
||||
}
|
||||
```
|
||||
|
||||
No `deletedTTL` (hard deletes).
|
||||
|
||||
### Default DB Options
|
||||
|
||||
```haskell
|
||||
defaultXFTPDBOpts :: DBOpts
|
||||
defaultXFTPDBOpts =
|
||||
DBOpts
|
||||
{ connstr = "postgresql://xftp@/xftp_server_store",
|
||||
schema = "xftp_server",
|
||||
poolSize = 10,
|
||||
createSchema = False
|
||||
}
|
||||
```
|
||||
|
||||
## Migration CLI
|
||||
|
||||
Bidirectional migration via StoreLog as interchange format:
|
||||
|
||||
```
|
||||
xftp-server database import [--database DB_CONN] [--schema DB_SCHEMA] [--pool-size N]
|
||||
xftp-server database export [--database DB_CONN] [--schema DB_SCHEMA] [--pool-size N]
|
||||
```
|
||||
|
||||
No `--table` flag needed (unlike SMP which has queues/messages/all) — XFTP has a single entity type (files + recipients, always migrated together).
|
||||
|
||||
CLI options reuse `dbOptsP` parser from `Simplex.Messaging.Server.CLI`.
|
||||
|
||||
### Import (StoreLog -> PostgreSQL)
|
||||
|
||||
1. Confirm: prompt user with database connection details and StoreLog path
|
||||
2. Read and replay StoreLog into temporary `STMFileStore`
|
||||
3. Connect to PostgreSQL, run schema migrations (`createSchema = True`, `confirmMigrations = MCYesUp`)
|
||||
4. Batch-insert file records into `files` table using PostgreSQL COPY protocol (matching SMP's `batchInsertQueues` pattern for performance). Progress reported every 10k files.
|
||||
5. Batch-insert recipient records into `recipients` table using COPY protocol
|
||||
6. Verify counts: `SELECT COUNT(*) FROM files` / `recipients` — warn if mismatch
|
||||
7. Rename StoreLog to `.bak` (prevents accidental re-import, preserves original for rollback)
|
||||
8. Report counts
|
||||
|
||||
### Export (PostgreSQL -> StoreLog)
|
||||
|
||||
1. Confirm: prompt user with database connection details and output path. Fail if output file already exists.
|
||||
2. Connect to PostgreSQL
|
||||
3. Open new StoreLog file for writing
|
||||
4. Fold over all file records, writing per file (in this order, matching existing `writeFileStore`): `AddFile` (with `ServerEntityStatus` — this preserves `EntityBlocked` state), `AddRecipients`, then `PutFile` (if `file_path` is set)
|
||||
5. Report counts
|
||||
|
||||
Note: `AddFile` carries `ServerEntityStatus` which includes `EntityBlocked info`, so blocking state is preserved through export/import without needing separate `BlockFile` log entries.
|
||||
|
||||
File data on disk is untouched by migration — only metadata moves between backends.
|
||||
|
||||
## Cabal Integration
|
||||
|
||||
Shared `server_postgres` flag. New Postgres modules added to existing conditional block:
|
||||
|
||||
```cabal
|
||||
if flag(server_postgres)
|
||||
cpp-options: -DdbServerPostgres
|
||||
exposed-modules:
|
||||
...existing SMP modules...
|
||||
Simplex.FileTransfer.Server.Store.Postgres
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Migrations
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Config
|
||||
```
|
||||
|
||||
CPP guards (`#if defined(dbServerPostgres)`) in:
|
||||
- `Store.hs` — Postgres `FromField`/`ToField` instances for XFTP-specific types if needed
|
||||
- `Env.hs` — `XSCDatabase` constructor
|
||||
- `Main.hs` — database CLI commands, store selection for `database` mode, Postgres imports
|
||||
- `Server.hs` — Postgres-specific imports if needed
|
||||
|
||||
## Testing
|
||||
|
||||
- **Parameterized server tests**: Existing `xftpServerTests` refactored to accept a store type parameter (following SMP's `SpecWith (ASrvTransport, AStoreType)` pattern). The same server tests run against both STM and Postgres backends — STM tests run unconditionally, Postgres tests added under `#if defined(dbServerPostgres)` with `postgressBracket` for database lifecycle (drop → create → test → drop).
|
||||
- **Unit tests**: `PostgresFileStore` operations — add/get/delete/block/expire, duplicate detection, auth errors
|
||||
- **Migration round-trip**: STM store → export to StoreLog → import to Postgres → export back → verify StoreLog equality (including blocked file status)
|
||||
- **Tests location**: in `tests/` alongside existing XFTP tests, guarded by `server_postgres` CPP flag
|
||||
- **Test database**: PostgreSQL on `localhost:5432`, using a dedicated `xftp_server_test` schema (dropped and recreated per test run via `postgressBracket`, following SMP's test database lifecycle pattern)
|
||||
- **Test fixtures**: `testXFTPStoreDBOpts :: DBOpts` with `createSchema = True`, `confirmMigrations = MCYesUp`, in `tests/XFTPClient.hs`
|
||||
@@ -0,0 +1,648 @@
|
||||
# XFTP PostgreSQL Backend — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add PostgreSQL backend support to xftp-server as an alternative to STM + StoreLog, with bidirectional migration.
|
||||
|
||||
**Architecture:** Introduce `FileStoreClass` typeclass (IO-based, following `QueueStoreClass` pattern). Extract current STM store into `Store/STM.hs`, make `Server.hs` polymorphic, then add `Store/Postgres.hs` behind `server_postgres` CPP flag. `usedStorage` moves from store to `XFTPEnv` so the server manages quota tracking externally.
|
||||
|
||||
**Tech Stack:** Haskell, postgresql-simple, STM, fourmolu, cabal with CPP flags
|
||||
|
||||
**Design spec:** `plans/2026-03-25-xftp-postgres-backend-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Existing files modified:**
|
||||
- `src/Simplex/FileTransfer/Server/Store.hs` — rewritten: becomes typeclass + shared types
|
||||
- `src/Simplex/FileTransfer/Server/Env.hs` — polymorphic `XFTPEnv s`, `XFTPStoreConfig` GADT
|
||||
- `src/Simplex/FileTransfer/Server.hs` — polymorphic over `FileStoreClass s`
|
||||
- `src/Simplex/FileTransfer/Server/StoreLog.hs` — update for IO store functions
|
||||
- `src/Simplex/FileTransfer/Server/Main.hs` — INI config, dispatch, CLI commands
|
||||
- `simplexmq.cabal` — new modules
|
||||
- `tests/XFTPClient.hs` — Postgres test fixtures
|
||||
- `tests/Test.hs` — Postgres test group
|
||||
|
||||
**New files created:**
|
||||
- `src/Simplex/FileTransfer/Server/Store/STM.hs` — `STMFileStore` (extracted from current `Store.hs`)
|
||||
- `src/Simplex/FileTransfer/Server/Store/Postgres.hs` — `PostgresFileStore` [CPP-guarded]
|
||||
- `src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs` — `PostgresFileStoreCfg` [CPP-guarded]
|
||||
- `src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs` — schema SQL [CPP-guarded]
|
||||
- `tests/CoreTests/XFTPStoreTests.hs` — Postgres store unit tests [CPP-guarded]
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Move `usedStorage` from `FileStore` to `XFTPEnv`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
- [ ] **Step 1: Remove `usedStorage` from `FileStore` in `Store.hs`**
|
||||
|
||||
1. Remove `usedStorage :: TVar Int64` field from `FileStore` record (line 47).
|
||||
2. Remove `usedStorage <- newTVarIO 0` from `newFileStore` (line 75) and drop the field from the record construction (line 76).
|
||||
3. In `setFilePath` (line 92-97): remove `modifyTVar' (usedStorage st) (+ fromIntegral (size fileInfo))` — keep only `writeTVar filePath (Just fPath)`. Change pattern from `\FileRec {fileInfo, filePath}` to `\FileRec {filePath}` (fileInfo is now unused — `-Wunused-matches` error).
|
||||
4. In `deleteFile` (line 112-119): remove `modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)`. Change outer pattern match from `FileStore {files, recipients, usedStorage}` to `FileStore {files, recipients}`. Change inner pattern from `Just FileRec {fileInfo, recipientIds}` to `Just FileRec {recipientIds}` (`fileInfo` is now unused — `-Wunused-matches` error).
|
||||
5. In `blockFile` (line 122-127): remove `when deleted $ modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)`. Change pattern match from `st@FileStore {usedStorage}` to `st`. The `deleted` parameter and `fileInfo` in the inner pattern become unused — prefix with `_` or remove from pattern to avoid `-Wunused-matches`.
|
||||
|
||||
- [ ] **Step 2: Add `usedStorage` to `XFTPEnv` in `Env.hs`**
|
||||
|
||||
1. Add `usedStorage :: TVar Int64` field to `XFTPEnv` record (between `store` and `storeLog`, line 93).
|
||||
2. In `newXFTPServerEnv` (line 112-126): replace lines 117-118:
|
||||
```
|
||||
used <- countUsedStorage <$> readTVarIO (files store)
|
||||
atomically $ writeTVar (usedStorage store) used
|
||||
```
|
||||
with:
|
||||
```
|
||||
usedStorage <- newTVarIO =<< countUsedStorage <$> readTVarIO (files store)
|
||||
```
|
||||
3. Add `usedStorage` to the `pure XFTPEnv {..}` construction.
|
||||
|
||||
- [ ] **Step 3: Update all `usedStorage` access sites in `Server.hs`**
|
||||
|
||||
1. Line 552: `us <- asks $ usedStorage . store` → `us <- asks usedStorage`.
|
||||
2. Line 569: `us <- asks $ usedStorage . store` → `us <- asks usedStorage`.
|
||||
3. Line 639: `usedStart <- readTVarIO $ usedStorage st` → `usedStart <- readTVarIO =<< asks usedStorage`.
|
||||
4. Line 647: `usedEnd <- readTVarIO $ usedStorage st` → `usedEnd <- readTVarIO =<< asks usedStorage`.
|
||||
5. Line 694: `FileStore {files, usedStorage} <- asks store` → split into `FileStore {files} <- asks store` and `usedStorage <- asks usedStorage`.
|
||||
6. In `deleteOrBlockServerFile_` (line 620): after `void $ atomically $ storeAction st`, add usedStorage adjustment — `us <- asks usedStorage` then `atomically $ modifyTVar' us $ subtract (fromIntegral $ size fileInfo)` when file had a path (check `path` from `readTVarIO filePath` earlier in the function).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git commit -m "refactor(xftp): move usedStorage from FileStore to XFTPEnv"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `getUsedStorage`, `getFileCount`, `expiredFiles` functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
- [ ] **Step 1: Add three new functions to `Store.hs`**
|
||||
|
||||
1. Add to exports: `getUsedStorage`, `getFileCount`, `expiredFiles`.
|
||||
2. Remove `expiredFilePath` from exports AND delete the function definition (dead code → `-Wunused-binds` error). Also remove `($>>=)` from import `Simplex.Messaging.Util (ifM, ($>>=))` → `Simplex.Messaging.Util (ifM)` — `$>>=` was only used by `expiredFilePath`.
|
||||
3. Add import: `qualified Data.Map.Strict as M` (needed for `M.foldl'` in `getUsedStorage` and `M.toList` in `expiredFiles`).
|
||||
4. Implement:
|
||||
```haskell
|
||||
getUsedStorage :: FileStore -> IO Int64
|
||||
getUsedStorage FileStore {files} =
|
||||
M.foldl' (\acc FileRec {fileInfo = FileInfo {size}} -> acc + fromIntegral size) 0 <$> readTVarIO files
|
||||
|
||||
getFileCount :: FileStore -> IO Int
|
||||
getFileCount FileStore {files} = M.size <$> readTVarIO files
|
||||
|
||||
expiredFiles :: FileStore -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
|
||||
expiredFiles FileStore {files} old _limit = do
|
||||
fs <- readTVarIO files
|
||||
fmap catMaybes . forM (M.toList fs) $ \(sId, FileRec {fileInfo = FileInfo {size}, filePath, createdAt = RoundedSystemTime createdAt}) ->
|
||||
if createdAt + fileTimePrecision < old
|
||||
then do
|
||||
path <- readTVarIO filePath
|
||||
pure $ Just (sId, path, size)
|
||||
else pure Nothing
|
||||
```
|
||||
5. Add imports: `Data.Maybe (catMaybes)`, `Data.Word (Word32)` (note: `qualified Data.Map.Strict as M` already added in item 3).
|
||||
|
||||
- [ ] **Step 2: Replace `countUsedStorage` in `Env.hs`**
|
||||
|
||||
1. Replace `countUsedStorage <$> readTVarIO (files store)` with `getUsedStorage store` in `newXFTPServerEnv`.
|
||||
2. Remove `countUsedStorage` function definition and its export.
|
||||
3. Remove `qualified Data.Map.Strict as M` import if no longer used.
|
||||
|
||||
- [ ] **Step 3: Update `restoreServerStats` in `Server.hs` to use `getFileCount`**
|
||||
|
||||
In `restoreServerStats` (line 694-696): replace `FileStore {files} <- asks store` and `_filesCount <- M.size <$> readTVarIO files` with `st <- asks store` and `_filesCount <- liftIO $ getFileCount st` (eliminates the `FileStore` pattern match — `files` binding no longer needed).
|
||||
|
||||
- [ ] **Step 4: Replace `expireServerFiles` iteration in `Server.hs`**
|
||||
|
||||
1. Replace the body of `expireServerFiles` (lines 636-660). Remove `files' <- readTVarIO (files st)` and the `forM_ (M.keys files')` loop.
|
||||
2. New body: call `expiredFiles st old 10000` in a loop. For each `(sId, filePath_, fileSize)` in returned list: apply `itemDelay`, remove disk file if present, call `atomically $ deleteFile st sId`, adjust `usedStorage` TVar by `fileSize`, increment `filesExpired` stat. Loop until `expiredFiles` returns `[]`.
|
||||
3. Remove `Data.Map.Strict` import from Server.hs if no longer needed (was used for `M.size` and `M.keys` — now replaced by `getFileCount` and `expiredFiles`).
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 6: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git commit -m "refactor(xftp): add getUsedStorage, getFileCount, expiredFiles store functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Change `Store.hs` functions from STM to IO
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/StoreLog.hs`
|
||||
|
||||
- [ ] **Step 1: Change all Store.hs function signatures from STM to IO**
|
||||
|
||||
For each of: `addFile`, `setFilePath`, `addRecipient`, `getFile`, `deleteFile`, `blockFile`, `deleteRecipient`, `ackFile`:
|
||||
1. Change return type from `STM (Either XFTPErrorType ...)` to `IO (Either XFTPErrorType ...)` (or `STM ()` to `IO ()` for `deleteRecipient`).
|
||||
2. Wrap the function body in `atomically $ do ...`.
|
||||
3. Keep `withFile` and `newFileRec` as internal STM helpers (called inside the `atomically` blocks).
|
||||
|
||||
- [ ] **Step 2: Update Server.hs call sites — remove `atomically` wrappers**
|
||||
|
||||
1. Line 563 (`receiveServerFile`): change `atomically $ writeTVar filePath (Just fPath)` → add `st <- asks store` then `void $ liftIO $ setFilePath st senderId fPath` (design call site #1 — `store` is not in scope in `receiveServerFile`'s `receive` helper, so bind via `asks`; `void` avoids `-Wunused-do-bind` warning on the `Either` result).
|
||||
2. Line 453 (`verifyXFTPTransmission`): split `atomically $ verify =<< getFile st party fId` into: `liftIO (getFile st party fId)` (IO→M lift), then pattern match on result, use `readTVarIO (fileStatus fr)` instead of `readTVar`.
|
||||
3. Lines 371, 377 (control port `CPDelete`/`CPBlock`): change `ExceptT $ atomically $ getFile fs SFRecipient fileId` → `ExceptT $ liftIO $ getFile fs SFRecipient fileId` (inside `unliftIO u $ do` block which runs in M monad — `liftIO` required to lift IO into M).
|
||||
4. Line 508 (`addFile` in `createFile`): the `ExceptT $ addFile st sId file ts EntityActive` — `addFile` is now IO, `ExceptT` wraps IO directly. Remove any `atomically`.
|
||||
5. Line 514 (`addRecipient`): same — `ExceptT . addRecipient st sId` works directly in IO.
|
||||
6. Line 516 (`retryAdd`): change parameter type from `(XFTPFileId -> STM (Either XFTPErrorType a))` to `(XFTPFileId -> IO (Either XFTPErrorType a))`. Line 520: change `atomically (add fId)` to `liftIO (add fId)`.
|
||||
7. Line 605 (`ackFileReception`): change `atomically $ deleteRecipient st rId fr` to `liftIO $ deleteRecipient st rId fr`.
|
||||
8. Line 620 (`deleteOrBlockServerFile_`): change third parameter type from `(FileStore -> STM (Either XFTPErrorType ()))` to `(FileStore -> IO (Either XFTPErrorType ()))`. Line 626: change `void $ atomically $ storeAction st` to `void $ liftIO $ storeAction st`.
|
||||
9. `expireServerFiles` `delete` helper: change `atomically $ deleteFile st sId` to `liftIO $ deleteFile st sId` (deleteFile is now IO; `liftIO` required because the helper runs in M monad, not IO).
|
||||
|
||||
- [ ] **Step 3: Update `StoreLog.hs` — remove `atomically` from replay**
|
||||
|
||||
In `readFileStore` (line 93), function `addToStore`:
|
||||
1. Change `atomically (addToStore lr)` to `addToStore lr` — store functions are now IO.
|
||||
2. The `addToStore` body calls `addFile`, `setFilePath`, `deleteFile`, `blockFile`, `ackFile` — all IO now, no `atomically` needed.
|
||||
3. For `AddRecipients`: `runExceptT $ mapM_ (ExceptT . addRecipient st sId) rcps` — `addRecipient` returns `IO (Either ...)`, so `ExceptT . addRecipient st sId` works directly.
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
|
||||
git commit -m "refactor(xftp): change file store operations from STM to IO"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extract `FileStoreClass` typeclass, move STM impl to `Store/STM.hs`
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/STM.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/StoreLog.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
- Modify: `simplexmq.cabal`
|
||||
|
||||
- [ ] **Step 1: Create `Store/STM.hs` — move all implementation code**
|
||||
|
||||
1. Create directory `src/Simplex/FileTransfer/Server/Store/`.
|
||||
2. Create `src/Simplex/FileTransfer/Server/Store/STM.hs`.
|
||||
3. Move from `Store.hs`: `FileStore` data type (rename to `STMFileStore`), all function implementations, internal helpers (`withFile`, `newFileRec`), all STM-specific imports.
|
||||
4. Rename all `FileStore` references to `STMFileStore` in the new file.
|
||||
5. Module declaration: `module Simplex.FileTransfer.Server.Store.STM` exporting only `STMFileStore (..)` — do NOT export standalone functions (`addFile`, `setFilePath`, etc.) to avoid name collisions with the typeclass methods from `Store.hs`.
|
||||
|
||||
- [ ] **Step 2: Rewrite `Store.hs` as the typeclass module**
|
||||
|
||||
1. Add `{-# LANGUAGE TypeFamilies #-}` pragma to `Store.hs` (required for `type FileStoreConfig s` associated type).
|
||||
2. Keep in `Store.hs`: `FileRec (..)`, `FileRecipient (..)`, `RoundedFileTime`, `fileTimePrecision` definitions and their `StrEncoding` instance.
|
||||
3. Add `FileStoreClass` typeclass:
|
||||
```haskell
|
||||
class FileStoreClass s where
|
||||
type FileStoreConfig s
|
||||
|
||||
-- Lifecycle
|
||||
newFileStore :: FileStoreConfig s -> IO s
|
||||
closeFileStore :: s -> IO ()
|
||||
|
||||
-- File operations
|
||||
addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ())
|
||||
setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ())
|
||||
addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ())
|
||||
getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey))
|
||||
deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ())
|
||||
blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ())
|
||||
deleteRecipient :: s -> RecipientId -> FileRec -> IO ()
|
||||
ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ())
|
||||
|
||||
-- Expiration
|
||||
expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
|
||||
|
||||
-- Stats
|
||||
getUsedStorage :: s -> IO Int64
|
||||
getFileCount :: s -> IO Int
|
||||
```
|
||||
4. Do NOT re-export from `Store/STM.hs` — this would create a circular module dependency (Store.hs imports Store/STM.hs, Store/STM.hs imports Store.hs). Consumers must import `Store.STM` directly where they need `STMFileStore`.
|
||||
5. Remove all STM-specific imports that are no longer needed.
|
||||
|
||||
- [ ] **Step 3: Add `FileStoreClass` instance in `Store/STM.hs`**
|
||||
|
||||
1. Import `FileStoreClass` from `Simplex.FileTransfer.Server.Store`.
|
||||
2. Inline all implementations directly in the instance body (do NOT delegate to standalone functions — the standalone names collide with typeclass method names, causing ambiguous occurrences for importers):
|
||||
```haskell
|
||||
instance FileStoreClass STMFileStore where
|
||||
type FileStoreConfig STMFileStore = ()
|
||||
newFileStore () = do
|
||||
files <- TM.emptyIO
|
||||
recipients <- TM.emptyIO
|
||||
pure STMFileStore {files, recipients}
|
||||
closeFileStore _ = pure ()
|
||||
addFile st sId fileInfo createdAt status = atomically $ ...
|
||||
setFilePath st sId fPath = atomically $ ...
|
||||
-- ... (each method's body is the existing function body, inlined)
|
||||
```
|
||||
3. Remove the standalone top-level function definitions — they are now instance methods. Keep only `withFile` and `newFileRec` as internal helpers used by the instance methods.
|
||||
|
||||
- [ ] **Step 4: Update importers**
|
||||
|
||||
1. `Env.hs`: add `import Simplex.FileTransfer.Server.Store.STM (STMFileStore (..))`. Change `FileStore` → `STMFileStore` in `XFTPEnv` type and `newXFTPServerEnv`. Change `store <- newFileStore` to `store <- newFileStore ()` (typeclass method now takes `FileStoreConfig STMFileStore` which is `()`). Keep `import Simplex.FileTransfer.Server.Store` for `FileRec`, `FileRecipient`, `FileStoreClass`, etc.
|
||||
2. `Server.hs`: add `import Simplex.FileTransfer.Server.Store.STM`. Change `FileStore` → `STMFileStore` in any explicit type annotations. Import `FileStoreClass` from `Simplex.FileTransfer.Server.Store`.
|
||||
3. `StoreLog.hs`: add `import Simplex.FileTransfer.Server.Store.STM` to access concrete `STMFileStore` type and store functions used during log replay. Change `FileStore` → `STMFileStore` in `readWriteFileStore` and `writeFileStore` parameter types.
|
||||
|
||||
- [ ] **Step 5: Update cabal file**
|
||||
|
||||
Add `Simplex.FileTransfer.Server.Store.STM` to `exposed-modules` in the `!flag(client_library)` section, alongside existing XFTP server modules.
|
||||
|
||||
- [ ] **Step 6: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 7: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 8: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs simplexmq.cabal
|
||||
git commit -m "refactor(xftp): extract FileStoreClass typeclass, move STM impl to Store.STM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Make `XFTPEnv` and `Server.hs` polymorphic over `FileStoreClass`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
- Modify: `tests/XFTPClient.hs` (if it calls `runXFTPServerBlocking` directly)
|
||||
|
||||
- [ ] **Step 1: Make `XFTPEnv` polymorphic in `Env.hs`**
|
||||
|
||||
1. Add `XFTPStoreConfig` GADT: `data XFTPStoreConfig s where XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore`.
|
||||
2. Change `data XFTPEnv` to `data XFTPEnv s` — field `store :: FileStore` becomes `store :: s`.
|
||||
3. Change `newXFTPServerEnv :: XFTPServerConfig -> IO XFTPEnv` to `newXFTPServerEnv :: FileStoreClass s => XFTPStoreConfig s -> XFTPServerConfig -> IO (XFTPEnv s)`.
|
||||
4. Pattern match on `XSCMemory storeLogPath` in `newXFTPServerEnv` body. Create store via `newFileStore ()`, storeLog via `mapM (`readWriteFileStore` st) storeLogPath`.
|
||||
|
||||
- [ ] **Step 2: Make `Server.hs` polymorphic**
|
||||
|
||||
1. Change `type M a = ReaderT XFTPEnv IO a` to `type M s a = ReaderT (XFTPEnv s) IO a`.
|
||||
2. Add `FileStoreClass s =>` constraint to all functions using `M s a`. Use `forall s.` in signatures of functions that have `where`-block bindings with `M s` type annotations — `ScopedTypeVariables` requires explicit `forall` to bring `s` into scope for inner type signatures (matching SMP's `smpServer :: forall s. MsgStoreClass s => ...` pattern). Full list: `xftpServer`, `processRequest`, `verifyXFTPTransmission`, `processXFTPRequest` and all its `where`-bound functions (`createFile`, `addRecipients`, `receiveServerFile`, `sendServerFile`, `deleteServerFile`, `ackFileReception`, `retryAdd`, `addFileRetry`, `addRecipientRetry`), `deleteServerFile_`, `blockServerFile`, `deleteOrBlockServerFile_`, `expireServerFiles`, `randomId`, `getFileId`, `withFileLog`, `incFileStat`, `saveServerStats`, `restoreServerStats`, `randomDelay` (inside `#ifdef slow_servers` CPP block). Also update `encodeXftp` (line 236) and `runCPClient` (line 339) which use explicit `ReaderT XFTPEnv IO` instead of the `M` alias — change to `ReaderT (XFTPEnv s) IO`.
|
||||
3. Change `runXFTPServerBlocking` and `runXFTPServer` to take `XFTPStoreConfig s` parameter.
|
||||
4. Add `closeFileStore store` call to the server shutdown path (in the `finally` block or `stopServer` equivalent — after saving stats, before logging "Server stopped"). This ensures Postgres connection pool and `dbStoreLog` are properly closed. For STM this is a no-op.
|
||||
|
||||
- [ ] **Step 3: Update `Main.hs` dispatch**
|
||||
|
||||
1. In `runServer`: construct `XSCMemory (enableStoreLog $> storeLogFilePath)`.
|
||||
2. Add dispatch function that calls the updated `runXFTPServer` (which creates `started` internally):
|
||||
```haskell
|
||||
run :: FileStoreClass s => XFTPStoreConfig s -> IO ()
|
||||
run storeCfg = runXFTPServer storeCfg serverConfig
|
||||
```
|
||||
3. Call `run` with the `XSCMemory` config.
|
||||
|
||||
- [ ] **Step 4: Update test helper if needed**
|
||||
|
||||
If `tests/XFTPClient.hs` calls `runXFTPServerBlocking` directly, update the call to pass an `XSCMemory` config. Check the `withXFTPServer` / `serverBracket` helper.
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `cabal build && cabal build test:simplexmq-test`
|
||||
|
||||
- [ ] **Step 6: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs
|
||||
git add src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs tests/XFTPClient.hs simplexmq.cabal
|
||||
git commit -m "refactor(xftp): make XFTPEnv and server polymorphic over FileStoreClass"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Postgres config, migrations, and store skeleton
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs`
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs`
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `simplexmq.cabal`
|
||||
|
||||
- [ ] **Step 1: Create `Store/Postgres/Config.hs`**
|
||||
|
||||
```haskell
|
||||
module Simplex.FileTransfer.Server.Store.Postgres.Config
|
||||
( PostgresFileStoreCfg (..),
|
||||
defaultXFTPDBOpts,
|
||||
)
|
||||
where
|
||||
|
||||
import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..))
|
||||
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation)
|
||||
|
||||
data PostgresFileStoreCfg = PostgresFileStoreCfg
|
||||
{ dbOpts :: DBOpts,
|
||||
dbStoreLogPath :: Maybe FilePath,
|
||||
confirmMigrations :: MigrationConfirmation
|
||||
}
|
||||
|
||||
defaultXFTPDBOpts :: DBOpts
|
||||
defaultXFTPDBOpts =
|
||||
DBOpts
|
||||
{ connstr = "postgresql://xftp@/xftp_server_store",
|
||||
schema = "xftp_server",
|
||||
poolSize = 10,
|
||||
createSchema = False
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `Store/Postgres/Migrations.hs`**
|
||||
|
||||
Full migration module with `xftpServerMigrations :: [Migration]` and `m20260325_initial` containing CREATE TABLE SQL for `files` and `recipients` tables plus indexes. Follow SMP's `QueueStore/Postgres/Migrations.hs` pattern exactly: tuple list → `sortOn name . map migration`.
|
||||
|
||||
- [ ] **Step 3: Create `Store/Postgres.hs` with stub instance**
|
||||
|
||||
1. Define `PostgresFileStore` with `dbStore :: DBStore` and `dbStoreLog :: Maybe (StoreLog 'WriteMode)`.
|
||||
2. `instance FileStoreClass PostgresFileStore` with `error "not implemented"` for all methods except `newFileStore` (calls `createDBStore` + opens `dbStoreLog`) and `closeFileStore` (closes both). `type FileStoreConfig PostgresFileStore = PostgresFileStoreCfg`.
|
||||
3. Add `withDB`, `handleDuplicate`, `assertUpdated`, `withLog` helpers.
|
||||
|
||||
- [ ] **Step 4: Add `XSCDatabase` GADT constructor in `Env.hs` (CPP-guarded)**
|
||||
|
||||
```haskell
|
||||
#if defined(dbServerPostgres)
|
||||
import Simplex.FileTransfer.Server.Store.Postgres (PostgresFileStore)
|
||||
import Simplex.FileTransfer.Server.Store.Postgres.Config (PostgresFileStoreCfg)
|
||||
#endif
|
||||
|
||||
data XFTPStoreConfig s where
|
||||
XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore
|
||||
#if defined(dbServerPostgres)
|
||||
XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update cabal**
|
||||
|
||||
Add to existing `if flag(server_postgres)` block:
|
||||
```
|
||||
Simplex.FileTransfer.Server.Store.Postgres
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Config
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Migrations
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build both ways**
|
||||
|
||||
Run: `cabal build && cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Env.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs src/Simplex/FileTransfer/Server/Env.hs simplexmq.cabal
|
||||
git commit -m "feat(xftp): add PostgreSQL store skeleton with schema migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Implement `PostgresFileStore` operations
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store/Postgres.hs`
|
||||
|
||||
- [ ] **Step 1: Implement `addFile`**
|
||||
|
||||
`INSERT INTO files (sender_id, file_size, file_digest, sender_key, file_path, created_at, status) VALUES (?,?,?,?,NULL,?,?)`. Catch unique violation with `handleDuplicate` → `DUPLICATE_`. Call `withLog "addFile"` after.
|
||||
|
||||
- [ ] **Step 2: Implement `getFile`**
|
||||
|
||||
For `SFSender`: `SELECT ... FROM files WHERE sender_id = ?`. Construct `FileRec` with `newTVarIO` per TVar field. `recipientIds = S.empty`.
|
||||
For `SFRecipient`: `SELECT f.*, r.recipient_key FROM recipients r JOIN files f ON r.sender_id = f.sender_id WHERE r.recipient_id = ?`.
|
||||
|
||||
- [ ] **Step 3: Implement `setFilePath`**
|
||||
|
||||
`UPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL`. Use `assertUpdated`. Call `withLog "setFilePath"`.
|
||||
|
||||
- [ ] **Step 4: Implement `addRecipient`**
|
||||
|
||||
`INSERT INTO recipients (recipient_id, sender_id, recipient_key) VALUES (?,?,?)`. `handleDuplicate` → `DUPLICATE_`. Call `withLog "addRecipient"`.
|
||||
|
||||
- [ ] **Step 5: Implement `deleteFile`, `blockFile`**
|
||||
|
||||
`deleteFile`: `DELETE FROM files WHERE sender_id = ?` (CASCADE). `withLog "deleteFile"`.
|
||||
`blockFile`: `UPDATE files SET status = ? WHERE sender_id = ?`. `assertUpdated`. `withLog "blockFile"`.
|
||||
|
||||
- [ ] **Step 6: Implement `deleteRecipient`, `ackFile`**
|
||||
|
||||
`deleteRecipient`: `DELETE FROM recipients WHERE recipient_id = ?`. `withLog "deleteRecipient"`.
|
||||
`ackFile`: same + return `Left AUTH` if 0 rows.
|
||||
|
||||
- [ ] **Step 7: Implement `expiredFiles`, `getUsedStorage`, `getFileCount`**
|
||||
|
||||
`expiredFiles`: `SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? LIMIT ?`.
|
||||
`getUsedStorage`: `SELECT COALESCE(SUM(file_size), 0) FROM files`.
|
||||
`getFileCount`: `SELECT COUNT(*) FROM files`.
|
||||
|
||||
- [ ] **Step 8: Add `ToField`/`FromField` instances**
|
||||
|
||||
For `RoundedFileTime` (Int64 wrapper), `ServerEntityStatus` (Text via StrEncoding), `C.APublicAuthKey` (Binary via `encodePubKey`/`decodePubKey`). Check SMP's `QueueStore/Postgres.hs` for existing instances to import.
|
||||
|
||||
- [ ] **Step 9: Wrap mutation operations in `uninterruptibleMask_`**
|
||||
|
||||
Operations that combine a DB write with a TVar update (e.g., `getFile` constructs `FileRec` with `newTVarIO`) must be wrapped in `E.uninterruptibleMask_` to prevent async exceptions from leaving inconsistent state. Follow SMP's `addQueue_`, `deleteStoreQueue` pattern.
|
||||
|
||||
- [ ] **Step 10: Build**
|
||||
|
||||
Run: `cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 11: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store/Postgres.hs
|
||||
git commit -m "feat(xftp): implement PostgresFileStore operations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Add INI config, Main.hs dispatch, startup validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
|
||||
- [ ] **Step 1: Update `iniFileContent` in `Main.hs`**
|
||||
|
||||
Add to `[STORE_LOG]` section: `store_files: memory`, commented-out `db_connection`, `db_schema`, `db_pool_size`, `db_store_log` keys. Follow SMP's `optDisabled'` pattern for commented defaults.
|
||||
|
||||
- [ ] **Step 2: Add `StartOptions` and `--confirm-migrations` flag**
|
||||
|
||||
```haskell
|
||||
data StartOptions = StartOptions
|
||||
{ confirmMigrations :: MigrationConfirmation
|
||||
}
|
||||
```
|
||||
Add to `Start` command parser with default `MCConsole`. Thread through to `runServer`.
|
||||
|
||||
- [ ] **Step 3: Add store_files INI parsing and CPP-guarded Postgres dispatch**
|
||||
|
||||
In `runServer`: read `store_files` from INI (`fromRight "memory" $ lookupValue "STORE_LOG" "store_files" ini`). Add `"database"` branch (CPP-guarded) that constructs `PostgresFileStoreCfg` using `iniDBOptions ini defaultXFTPDBOpts` and `enableDbStoreLog'` pattern. Non-postgres build: `exitError`.
|
||||
|
||||
- [ ] **Step 4: Add `XSCDatabase` branch in `newXFTPServerEnv` (`Env.hs`)**
|
||||
|
||||
CPP-guarded pattern match on `XSCDatabase dbCfg`: `newFileStore dbCfg`, `storeLog = Nothing`.
|
||||
|
||||
- [ ] **Step 5: Add startup config validation**
|
||||
|
||||
Add `checkFileStoreMode` (CPP-guarded) before `run`: validate conflicting storeLog file + database mode, missing schema, etc. per design doc.
|
||||
|
||||
- [ ] **Step 6: Build both ways**
|
||||
|
||||
Run: `cabal build && cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs
|
||||
git add src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs
|
||||
git commit -m "feat(xftp): add PostgreSQL INI config, store dispatch, startup validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add database import/export CLI commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
|
||||
- [ ] **Step 1: Add `Database` CLI command (CPP-guarded)**
|
||||
|
||||
Add `Database StoreCmd DBOpts` constructor to `CliCommand`. Add `database` subcommand parser with `import`/`export` subcommands + `dbOptsP defaultXFTPDBOpts`.
|
||||
|
||||
- [ ] **Step 2: Implement `importFileStoreToDatabase`**
|
||||
|
||||
1. `confirmOrExit` with database details.
|
||||
2. Create temporary `STMFileStore`, replay StoreLog via `readWriteFileStore`.
|
||||
3. Create `PostgresFileStore` with `createSchema = True`, `confirmMigrations = MCYesUp`.
|
||||
4. Batch-insert files using PostgreSQL COPY protocol. Progress every 10k.
|
||||
5. Batch-insert recipients using COPY protocol.
|
||||
6. Verify counts: `SELECT COUNT(*)` — warn on mismatch.
|
||||
7. Rename StoreLog to `.bak`.
|
||||
8. Report counts.
|
||||
|
||||
- [ ] **Step 3: Implement `exportDatabaseToStoreLog`**
|
||||
|
||||
1. `confirmOrExit`. Fail if output file exists.
|
||||
2. Create `PostgresFileStore` from config.
|
||||
3. Open StoreLog for writing.
|
||||
4. Fold over file records: write `AddFile` (with status), `AddRecipients`, `PutFile` per file.
|
||||
5. Close StoreLog, report counts.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 5: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs
|
||||
git add src/Simplex/FileTransfer/Server/Main.hs
|
||||
git commit -m "feat(xftp): add database import/export CLI commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Add Postgres tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/XFTPClient.hs`
|
||||
- Modify: `tests/Test.hs`
|
||||
- Create: `tests/CoreTests/XFTPStoreTests.hs`
|
||||
|
||||
- [ ] **Step 1: Add test fixtures in `tests/XFTPClient.hs`**
|
||||
|
||||
```haskell
|
||||
testXFTPStoreDBOpts :: DBOpts
|
||||
testXFTPStoreDBOpts =
|
||||
DBOpts
|
||||
{ connstr = "postgresql://test_xftp_server_user@/test_xftp_server_db",
|
||||
schema = "xftp_server_test",
|
||||
poolSize = 10,
|
||||
createSchema = True
|
||||
}
|
||||
```
|
||||
Add `testXFTPDBConnectInfo :: ConnectInfo` matching the connection string.
|
||||
|
||||
- [ ] **Step 2: Add Postgres server test group in `tests/Test.hs`**
|
||||
|
||||
CPP-guarded block that runs existing `xftpServerTests` with Postgres store config, wrapped in `postgressBracket testXFTPDBConnectInfo`. Parameterize `withXFTPServer` to accept store config if needed.
|
||||
|
||||
- [ ] **Step 3: Create `tests/CoreTests/XFTPStoreTests.hs` — unit tests**
|
||||
|
||||
Test `PostgresFileStore` operations directly:
|
||||
- `addFile` + `getFile SFSender` round-trip.
|
||||
- `addFile` duplicate → `DUPLICATE_`.
|
||||
- `getFile` nonexistent → `AUTH`.
|
||||
- `setFilePath` + verify `WHERE file_path IS NULL` guard.
|
||||
- `addRecipient` + `getFile SFRecipient` round-trip.
|
||||
- `deleteFile` cascades recipients.
|
||||
- `blockFile` + verify status.
|
||||
- `expiredFiles` batch semantics.
|
||||
- `getUsedStorage`, `getFileCount` correctness.
|
||||
|
||||
- [ ] **Step 4: Add migration round-trip test**
|
||||
|
||||
Create `STMFileStore` with test data (files + recipients + blocked status) → export to StoreLog → import to Postgres → export back → compare StoreLog files byte-for-byte.
|
||||
|
||||
- [ ] **Step 5: Build and run tests**
|
||||
|
||||
```bash
|
||||
cabal build -fserver_postgres test:simplexmq-test
|
||||
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -fserver_postgres
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs
|
||||
git add tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs tests/Test.hs
|
||||
git commit -m "test(xftp): add PostgreSQL backend tests"
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
Revision 2, 2024-06-22
|
||||
Revision 4, 2026-03-09
|
||||
|
||||
Evgeny Poberezkin
|
||||
|
||||
@@ -8,16 +8,17 @@ Evgeny Poberezkin
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [What is SimpleX](#what-is-simplex)
|
||||
- [Network model](#network-model)
|
||||
- [Applications](#applications)
|
||||
- [SimpleX objectives](#simplex-objectives)
|
||||
- [In Comparison](#in-comparison)
|
||||
- [Technical Details](#technical-details)
|
||||
- [Trust in Servers](#trust-in-servers)
|
||||
- [Client -> Server Communication](#client---server-communication)
|
||||
- [Trust in Routers](#trust-in-routers)
|
||||
- [Client -> Router Communication](#client---router-communication)
|
||||
- [2-hop Onion Message Routing](#2-hop-onion-message-routing)
|
||||
- [SimpleX Messaging Protocol](#simplex-messaging-protocol)
|
||||
- [SimpleX Agents](#simplex-agents)
|
||||
- [Encryption Primitives Used](#encryption-primitives-used)
|
||||
- [Threat model](#threat-model)
|
||||
- [Security](#security)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
|
||||
|
||||
@@ -27,27 +28,27 @@ Evgeny Poberezkin
|
||||
|
||||
SimpleX as a whole is a platform upon which applications can be built. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is one such application that also serves as an example and reference application.
|
||||
|
||||
- [SimpleX Messaging Protocol](./simplex-messaging.md) (SMP) is a protocol to send messages in one direction to a recipient, relying on a server in-between. The messages are delivered via uni-directional queues created by recipients.
|
||||
|
||||
- SMP protocol allows to send message via a SMP server playing proxy role using 2-hop onion routing (referred to as "private routing" in messaging clients) to protect transport information of the sender (IP address and session) from the server chosen (and possibly controlled) by the recipient.
|
||||
- [SimpleX Messaging Protocol](./simplex-messaging.md) (SMP) is a protocol to send messages in one direction to a recipient, relying on a router in-between. The messages are delivered via uni-directional queues created by recipients.
|
||||
|
||||
- SMP protocol allows to send message via a SMP router playing proxy role using 2-hop onion routing (referred to as "private routing" in messaging clients) to protect transport information of the sender (IP address and session) from the router chosen (and possibly controlled) by the recipient.
|
||||
|
||||
- SMP runs over a transport protocol (shown below as TLS) that provides integrity, server authentication, confidentiality, and transport channel binding.
|
||||
|
||||
- A SimpleX Server is one of those servers.
|
||||
- A SimpleX router is one of those routers.
|
||||
|
||||
- The SimpleX Network is the term used for the collective of SimpleX Servers that facilitate SMP.
|
||||
- The SimpleX Network is the term used for the collective of SimpleX routers that facilitate SMP.
|
||||
|
||||
- SimpleX Client libraries speak SMP to SimpleX Servers and provide a low-level API not generally intended to be used by applications.
|
||||
- SimpleX Client libraries speak SMP to SimpleX routers and provide a low-level API not generally intended to be used by applications.
|
||||
|
||||
- SimpleX Agents interface with SimpleX Clients to provide a more high-level API intended to be used by applications. Typically they are embedded as libraries, but can also be abstracted into local services.
|
||||
|
||||
- SimpleX Agents communicate with other agents inside e2e encrypted envelopes provided by SMP protocol - the syntax and semantics of the messages exchanged by the agent are defined by [SMP agent protocol](./agent-protocol.md)
|
||||
|
||||
|
||||
*Diagram showing the SimpleX Chat app, with logical layers of the chat application interfacing with a SimpleX Agent library, which in turn interfaces with a SimpleX Client library. The Client library in turn speaks the Messaging Protocol to a SimpleX Server.*
|
||||
*Diagram showing the SimpleX Chat app, with logical layers of the chat application interfacing with a SimpleX Agent library, which in turn interfaces with a SimpleX Client library. The Client library in turn speaks the Messaging Protocol to a SimpleX router.*
|
||||
|
||||
```
|
||||
User's Computer Internet Third-Party Server
|
||||
User's Computer Internet Third-Party Router
|
||||
------------------ | ---------------------- | -------------------------
|
||||
| |
|
||||
SimpleX Chat | |
|
||||
@@ -57,11 +58,43 @@ SimpleX as a whole is a platform upon which applications can be built. [SimpleX
|
||||
+----------------+ | |
|
||||
| SimpleX Agent | | |
|
||||
+----------------+ -------------- TLS ---------------- +----------------+
|
||||
| SimpleX Client | ------ SimpleX Messaging Protocol ------> | SimpleX Server |
|
||||
| SimpleX Client | ------ SimpleX Messaging Protocol ------> | SimpleX router |
|
||||
+----------------+ ----------------------------------- +----------------+
|
||||
| |
|
||||
```
|
||||
|
||||
#### Network model
|
||||
|
||||
SimpleX is a general-purpose packet routing network built on top of the Internet. Network endpoints — end-user devices, automated services, AI-enabled applications, IoT devices — exchange data packets through SimpleX network nodes (SMP routers), which accept, buffer, and deliver packets. Each router operates independently and can be operated by any party on standard computing hardware.
|
||||
|
||||
SimpleX routers use resource-based addressing: each address identifies a resource on a router, similar to how the World Wide Web addresses resources via URLs. Internet routers, by comparison, use endpoint-based addressing, where IP addresses identify destination devices. Because of this design, SimpleX network participants do not need globally unique addresses to communicate.
|
||||
|
||||
SimpleX network has two resource-based addressing schemes:
|
||||
|
||||
- *Messaging queues* ([SMP](./simplex-messaging.md)). A queue is a unidirectional, ordered sequence of fixed-size data packets (16,384 bytes each). Each queue has a resource address on a specific router, gated by cryptographic credentials that separately authorize sending and receiving.
|
||||
|
||||
- *Data packets* ([XFTP](./xftp.md)). A data packet is an individually addressed block in one of the standard sizes. Each packet has a unique resource address on a specific router, gated by cryptographic credentials. Data packet addressing is more efficient for delivery of larger payloads than queues.
|
||||
|
||||
Packet delivery follows a two-router path. The sending endpoint submits a packet to a first router, which forwards it to a second router, where the receiving endpoint retrieves it. The sending endpoint's IP address is known only to the first router; the receiving endpoint's IP address is known only to the second router. See [2-hop Onion Message Routing](#2-hop-onion-message-routing) for details.
|
||||
|
||||
Routers buffer packets between submission and retrieval — from seconds to days, enabling asynchronous delivery when endpoints are online at different times. Packets are removed after delivery or after a configured expiration period.
|
||||
|
||||
|
||||
#### Applications
|
||||
|
||||
Applications currently using SimpleX network:
|
||||
|
||||
- **SimpleX Chat** — a peer-to-peer messenger using SimpleX network as a transport layer, in the same way that communication applications use WebRTC, Tor, i2p, or Nym. All communication logic — contacts, conversations, groups, message formats, end-to-end encryption — runs on endpoint devices.
|
||||
|
||||
- **IoT devices** — using the SimpleX queue protocol directly for sensor data collection and device control.
|
||||
|
||||
- **AI-based services** — automated services built on the SimpleX Chat application core.
|
||||
|
||||
- **Secure monitoring and control systems** — applications for equipment monitoring and control, including robotics, using the network for command delivery and telemetry collection.
|
||||
|
||||
[SimpleGo](https://simplego.dev), developed by an independent organization, is a microcontroller-based device running a SimpleX Chat-compatible messenger directly on a microcontroller without a general-purpose operating system. Running over 20 days on a single battery charge, it demonstrates the energy efficiency of resource-based addressing: the device receives packets without continuous polling. A microcontroller-based router implementation that functions simultaneously as a WiFi router is also in development.
|
||||
|
||||
|
||||
#### SimpleX objectives
|
||||
|
||||
1. Provide messaging infrastructure for distributed applications. This infrastructure needs to have the following qualities:
|
||||
@@ -70,7 +103,7 @@ SimpleX as a whole is a platform upon which applications can be built. [SimpleX
|
||||
|
||||
- Privacy: protect against traffic correlation attacks to determine the contacts that the users communicate with.
|
||||
|
||||
- Reliability: the messages should be delivered even if some participating network servers or receiving clients fail, with “at least once” delivery guarantee.
|
||||
- Reliability: the messages should be delivered even if some participating network routers or receiving clients fail, with "at least once" delivery guarantee.
|
||||
|
||||
- Integrity: the messages sent in one direction are ordered in a way that sender and recipient agree on; the recipient can detect when a message was removed or changed.
|
||||
|
||||
@@ -78,63 +111,63 @@ SimpleX as a whole is a platform upon which applications can be built. [SimpleX
|
||||
|
||||
- Low latency: the delay introduced by the network should not be higher than 100ms-1s in addition to the underlying TCP network latency.
|
||||
|
||||
2. Provide better communication security and privacy than the alternative instant messaging solutions. In particular SimpleX provides better privacy of metadata (who talks to whom and when) and better security against active network attackers and malicious servers.
|
||||
2. Provide better communication security and privacy than the alternative instant messaging solutions. In particular SimpleX provides better privacy of metadata (who talks to whom and when) and better security against active network attackers and malicious routers.
|
||||
|
||||
3. Balance user experience with privacy requirements, prioritizing experience of mobile device users.
|
||||
|
||||
|
||||
#### In Comparison
|
||||
|
||||
SimpleX network has a design similar to P2P networks, but unlike most P2P networks it consists of clients and servers without depending on any centralized component.
|
||||
SimpleX network has a design similar to P2P networks, but unlike most P2P networks it consists of clients and routers without depending on any centralized component.
|
||||
In comparison to more traditional messaging applications (e.g. WhatsApp, Signal, Telegram) the key differences of SimpleX network are:
|
||||
|
||||
- participants do not need to have globally unique addresses to communicate, instead they use redundant unidirectional (simplex) messaging queues, with a separate set of queues for each contact.
|
||||
|
||||
- connection requests are passed out-of-band, non-optionally protecting key exchange against man-in-the-middle attack.
|
||||
|
||||
- simple message queues provided by network servers are used by the clients to create more complex communication scenarios, such as duplex one-to-one communication, transmitting files, group communication without central servers, and content/communication channels.
|
||||
- simple message queues provided by network routers are used by the clients to create more complex communication scenarios, such as duplex one-to-one communication, transmitting files, group communication without central routers, and content/communication channels.
|
||||
|
||||
- servers do not store any user information (no user profiles or contacts, or messages once they are delivered), and primarily use in-memory persistence.
|
||||
- routers do not store any user information (no user profiles or contacts, or messages once they are delivered), and primarily use in-memory persistence.
|
||||
|
||||
- users can change servers with minimal disruption - even after an in-use server disappears, simply by changing the configuration on which servers the new queues are created.
|
||||
- users can change routers with minimal disruption - even after an in-use router disappears, simply by changing the configuration on which routers the new queues are created.
|
||||
|
||||
|
||||
## Technical Details
|
||||
|
||||
#### Trust in Servers
|
||||
#### Trust in Routers
|
||||
|
||||
Clients communicate directly with servers (but not with other clients) using SimpleX Messaging Protocol (SMP) running over some transport protocol that provides integrity, server authentication, confidentiality, and transport channel binding. By default, we assume this transport protocol is TLS.
|
||||
Clients communicate directly with routers (but not with other clients) using SimpleX Messaging Protocol (SMP) running over some transport protocol that provides integrity, server authentication, confidentiality, and transport channel binding. By default, we assume this transport protocol is TLS.
|
||||
|
||||
Users use multiple servers, and choose where to receive their messages. Accordingly, they send messages to their communication partners' chosen servers either directly, if this is a known/trusted server, or via another SMP server providing proxy functionality to protect IP address and session of the sender.
|
||||
Users use multiple routers, and choose where to receive their messages. Accordingly, they send messages to their communication partners' chosen routers either directly, if this is a known/trusted router, or via another SMP router providing proxy functionality to protect IP address and session of the sender.
|
||||
|
||||
Although end-to-end encryption is always present, users place a degree of trust in servers they connect to. This trust decision is very similar to a user's choice of email provider; however the trust placed in a SimpleX server is significantly less. Notably, there is no re-used identifier or credential between queues on the same (or different) servers. While a user *may* re-use a transport connection to fetch messages from multiple queues, or connect to a server from the same IP address, both are choices a user may opt into to break the promise of un-correlatable queues.
|
||||
Although end-to-end encryption is always present, users place a degree of trust in routers they connect to. This trust decision is very similar to a user's choice of email provider; however the trust placed in a SimpleX router is significantly less. Notably, there is no re-used identifier or credential between queues on the same (or different) routers. While a user *may* re-use a transport connection to fetch messages from multiple queues, or connect to a router from the same IP address, both are choices a user may opt into to break the promise of un-correlatable queues.
|
||||
|
||||
Users may trust a server because:
|
||||
Users may trust a router because:
|
||||
|
||||
- They deploy and control the servers themselves from the available open-source code. This has the trade-offs of strong trust in the server but limited metadata obfuscation to a passive network observer. Techniques such as noise traffic, traffic mixing (incurring latency), and using an onion routing transport protocol can mitigate that.
|
||||
- They deploy and control the routers themselves from the available open-source code. This has the trade-offs of strong trust in the router but limited metadata obfuscation to a passive network observer. Techniques such as noise traffic, traffic mixing (incurring latency), and using an onion routing transport protocol can mitigate that.
|
||||
|
||||
- They use servers from a trusted commercial provider. The more clients the provider has, the less metadata about the communication times is leaked to the network observers.
|
||||
- They use routers from a trusted commercial provider. The more clients the provider has, the less metadata about the communication times is leaked to the network observers.
|
||||
|
||||
By default, servers do not retain access logs, and permanently delete messages and queues when requested. Messages persist only in memory until they cross a threshold of time, typically on the order of days.[0] There is still a risk that a server maliciously records all queues and messages (even though encrypted) sent via the same transport connection to gain a partial knowledge of the user’s communications graph and other meta-data.
|
||||
By default, routers do not retain access logs, and permanently delete messages and queues when requested. Messages persist in memory or in a database until they cross a threshold of time, typically on the order of days.[0] There is still a risk that a router maliciously records all queues and messages (even though encrypted) sent via the same transport connection to gain a partial knowledge of the user's communications graph and other meta-data.
|
||||
|
||||
SimpleX supports measures (managed transparently to the user at the agent level) to mitigate the trust placed in servers. These include rotating the queues in use between users, noise traffic, supporting overlay networks such as Tor, and isolating traffic to different queues to different transport connections (and Tor circuits, if Tor is used).
|
||||
SimpleX supports measures (managed transparently to the user at the agent level) to mitigate the trust placed in routers. These include rotating the queues in use between users, noise traffic, supporting overlay networks such as Tor, and isolating traffic to different queues to different transport connections (and Tor circuits, if Tor is used).
|
||||
|
||||
[0] While configurable by servers, a minimum value is enforced by the default software. SimpleX Agents can provide redundant routing over queues to mitigate against message loss.
|
||||
[0] While configurable by routers, a minimum value is enforced by the default software. SimpleX Agents can provide redundant routing over queues to mitigate against message loss.
|
||||
|
||||
|
||||
#### Client -> Server Communication
|
||||
#### Client -> Router Communication
|
||||
|
||||
Utilizing TLS grants the SimpleX Messaging Protocol (SMP) server authentication and metadata protection to a passive network observer. But SMP does not rely on the transport protocol for message confidentiality or client authentication. The SMP protocol itself provides end-to-end confidentiality, authentication, and integrity of messages between communicating parties.
|
||||
|
||||
Servers have long-lived, self-signed, offline certificates whose hash is pre-shared with clients over secure channels - either provided with the client library or provided in the secure introduction between clients, as part of the server address. The offline certificate signs an online certificate used in the transport protocol handshake. [0]
|
||||
Routers have long-lived, self-signed, offline certificates whose hash is pre-shared with clients over secure channels - either provided with the client library or provided in the secure introduction between clients, as part of the router address. The offline certificate signs an online certificate used in the transport protocol handshake. [0]
|
||||
|
||||
If the transport protocol's confidentiality is broken, incoming and outgoing messages to the server cannot be correlated by message contents. Additionally, because of encryption at the SMP layer, impersonating the server is not sufficient to pass (and therefore correlate) a message from a sender to recipient - the only attack possible is to drop the messages. Only by additionally *compromising* the server can one pass and correlate messages.
|
||||
If the transport protocol's confidentiality is broken, incoming and outgoing messages to the router cannot be correlated by message contents. Additionally, because of encryption at the SMP layer, impersonating the router is not sufficient to pass (and therefore correlate) a message from a sender to recipient - the only attack possible is to drop the messages. Only by additionally *compromising* the router can one pass and correlate messages.
|
||||
|
||||
It's important to note that the SMP protocol does not do server authentication. Instead we rely upon the fact that an attacker who tricks the transport protocol into authenticating the server incorrectly cannot do anything with the SMP messages except drop them.
|
||||
It's important to note that the SMP protocol does not do server authentication. Instead we rely upon the fact that an attacker who tricks the transport protocol into authenticating the router incorrectly cannot do anything with the SMP messages except drop them.
|
||||
|
||||
After the connection is established, the client sends blocks of a fixed size 16KB, and the server replies with the blocks of the same size to reduce metadata observable to a network adversary. The protocol has been designed to make traffic correlation attacks difficult, adapting ideas from Tor, remailers, and more general onion and mix networks. It does not try to replace Tor though - SimpleX servers can be deployed as onion services and SimpleX clients can communicate with servers over Tor to further improve participants privacy.
|
||||
After the connection is established, the client sends blocks of a fixed size 16KB, and the router replies with the blocks of the same size to reduce metadata observable to a network adversary. The protocol has been designed to make traffic correlation attacks difficult, adapting ideas from Tor, remailers, and more general onion and mix networks. It does not try to replace Tor though - SimpleX routers can be deployed as onion services and SimpleX clients can communicate with routers over Tor to further improve participants privacy.
|
||||
|
||||
By using fixed-size blocks, oversized for the expected content, the vast majority of traffic is uniform in nature. When enough traffic is transiting a server simultaneously, the server acts as a low-latency mix node. We can't rely on this behavior to make a security claim, but we have engineered to take advantage of it when we can. As mentioned, this holds true even if the transport connection is compromised.
|
||||
By using fixed-size blocks, oversized for the expected content, the vast majority of traffic is uniform in nature. When enough traffic is transiting a router simultaneously, the router acts as a low-latency mix node. We can't rely on this behavior to make a security claim, but we have engineered to take advantage of it when we can. As mentioned, this holds true even if the transport connection is compromised.
|
||||
|
||||
The protocol does not protect against attacks targeted at particular users with known identities - e.g., if the attacker wants to prove that two known users are communicating, they can achieve it by observing their local traffic. At the same time, it substantially complicates large-scale traffic correlation, making determining the real user identities much less effective.
|
||||
|
||||
@@ -143,39 +176,39 @@ The protocol does not protect against attacks targeted at particular users with
|
||||
|
||||
#### 2-hop Onion Message Routing
|
||||
|
||||
As SimpleX Messaging Protocol servers providing messaging queues are chosen by the recipients, in case senders connect to these servers directly the server owners (who potentially can be the recipients themselves) can learn senders' IP addresses (if Tor is not used) and which other queues on the same server are accessed by the user in the same transport connection (even if Tor is used).
|
||||
As SimpleX Messaging Protocol routers providing messaging queues are chosen by the recipients, in case senders connect to these routers directly the router owners (who potentially can be the recipients themselves) can learn senders' IP addresses (if Tor is not used) and which other queues on the same router are accessed by the user in the same transport connection (even if Tor is used).
|
||||
|
||||
While the clients support isolating the messages sent to different queues into different transport connections (and Tor circuits), this is not practical, as it consumes additional traffic and system resources.
|
||||
|
||||
To mitigate this problem SimpleX Messaging Protocol servers support 2-hop onion message routing when the SMP server chosen by the sender forwards the messages to the servers chosen by the recipients, thus protecting both the senders IP addresses and sessions, even if connection isolation and Tor are not used.
|
||||
To mitigate this problem SimpleX Messaging Protocol routers support 2-hop onion message routing when the SMP router chosen by the sender forwards the messages to the routers chosen by the recipients, thus protecting both the senders IP addresses and sessions, even if connection isolation and Tor are not used.
|
||||
|
||||
The design of 2-hop onion message routing prevents these potential attacks:
|
||||
|
||||
- MITM by proxy (SMP server that forwards the messages).
|
||||
- MITM by proxy (SMP router that forwards the messages).
|
||||
|
||||
- Identification by the proxy which and how many queues the sender sends messages to (as messages are additionally e2e encrypted between the sender and the destination SMP server).
|
||||
- Identification by the proxy which and how many queues the sender sends messages to (as messages are additionally e2e encrypted between the sender and the destination SMP router).
|
||||
|
||||
- Correlation of messages sent to different queues via the same user session (as random correlation IDs and keys are used for each message).
|
||||
|
||||
See more details about 2-hop onion message routing design in [SimpleX Messaging Protocol](./simplex-messaging.md#proxying-sender-commands)
|
||||
|
||||
Also see [Threat model](#threat-model)
|
||||
Also see [Security](./security.md)
|
||||
|
||||
|
||||
#### SimpleX Messaging Protocol
|
||||
|
||||
SMP is initialized with an in-person or out-of-band introduction message, where Alice provides Bob with details of a server (including IP address or host name, port, and hash of the long-lived offline certificate), a queue ID, and Alice's public keys to agree e2e encryption. These introductions are similar to the PANDA key-exchange, in that if observed, the adversary can race to establish the communication channel instead of the intended participant. [0]
|
||||
SMP is initialized with an in-person or out-of-band introduction message, where Alice provides Bob with details of a router (including IP address or host name, port, and hash of the long-lived offline certificate), a queue ID, and Alice's public keys to agree e2e encryption. These introductions are similar to the PANDA key-exchange, in that if observed, the adversary can race to establish the communication channel instead of the intended participant. [0]
|
||||
|
||||
Because queues are uni-directional, Bob provides an identically-formatted introduction message to Alice over Alice's now-established receiving queue.
|
||||
|
||||
When setting up a queue, the server will create separate sender and recipient queue IDs (provided to Alice during set-up and Bob during initial connection). Additionally, during set-up Alice will perform a DH exchange with the server to agree upon a shared secret. This secret will be used to re-encrypt Bob's incoming message before Alice receives it, creating the anti-correlation property earlier-described should the transport encryption be compromised.
|
||||
When setting up a queue, the router will create separate sender and recipient queue IDs (provided to Alice during set-up and Bob during initial connection). Additionally, during set-up Alice will perform a DH exchange with the router to agree upon a shared secret. This secret will be used to re-encrypt Bob's incoming message before Alice receives it, creating the anti-correlation property earlier-described should the transport encryption be compromised.
|
||||
|
||||
[0] Users can additionally create public 'contact queues' that are only used to receive connection requests.
|
||||
[0] Users can additionally create public 'contact queues' that are only used to receive connection requests.
|
||||
|
||||
|
||||
#### SimpleX Agents
|
||||
|
||||
SimpleX agents provide higher-level operations compared to SimpleX Clients, who are primarily concerned with creating queues and communicating with servers using SMP. Agent operations include:
|
||||
SimpleX agents provide higher-level operations compared to SimpleX Clients, who are primarily concerned with creating queues and communicating with routers using SMP. Agent operations include:
|
||||
|
||||
- Managing sets of bi-directional, redundant queues for communication partners
|
||||
|
||||
@@ -186,195 +219,21 @@ SimpleX agents provide higher-level operations compared to SimpleX Clients, who
|
||||
- Noise traffic
|
||||
|
||||
|
||||
#### Encryption Primitives Used
|
||||
## Security
|
||||
|
||||
- Ed25519 or Curve25519 to authorize/verify commands to SMP servers (authorization algorithm is set via client/server configuration).
|
||||
- Curve25519 for DH exchange to agree:
|
||||
- the shared secret between server and recipient (to encrypt message bodies - it avoids shared cipher-text in sender and recipient traffic)
|
||||
- the shared secret between sender and recipient (to encrypt messages end-to-end in each queue - it avoids shared cipher-text in redundant queues).
|
||||
- [NaCl crypto_box](https://nacl.cr.yp.to/box.html) encryption scheme (curve25519xsalsa20poly1305) for message body encryption between server and recipient and for E2E per-queue encryption.
|
||||
- SHA256 to validate server offline certificates.
|
||||
- [double ratchet](https://signal.org/docs/specifications/doubleratchet/) protocol for end-to-end message encryption between the agents:
|
||||
- Curve448 keys to agree shared secrets required for double ratchet initialization (using [X3DH](https://signal.org/docs/specifications/x3dh/) key agreement with 2 ephemeral keys for each side),
|
||||
- AES-GCM AEAD cipher,
|
||||
- SHA512-based HKDF for key derivation.
|
||||
For encryption primitives, threat model, and detailed security analysis, see [Security](./security.md).
|
||||
|
||||
SimpleX provides these security properties:
|
||||
|
||||
## Threat Model
|
||||
- **End-to-end encryption** using Double Ratchet algorithm with forward secrecy and post-quantum cryptography.
|
||||
|
||||
#### Global Assumptions
|
||||
- **No shared identifiers** across connections — contacts cannot prove they communicate with the same user.
|
||||
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of servers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
- The user's client uses 2-hop onion message routing.
|
||||
- **Sender deniability** — neither routers nor recipients can cryptographically prove message origin.
|
||||
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
- **Transport metadata protection** — fixed-size blocks, 2-hop onion routing, and optional connection isolation frustrate traffic correlation.
|
||||
|
||||
*can:*
|
||||
|
||||
- identify that and when a user is using SimpleX.
|
||||
|
||||
- determine which servers the user receives the messages from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- see who sends messages to the user and who the user sends the messages to.
|
||||
|
||||
- determine the servers used by users' contacts.
|
||||
|
||||
#### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- identify who and when is using SimpleX.
|
||||
|
||||
- learn which SimpleX Messaging Protocol servers are used as receive queues for which users.
|
||||
|
||||
- learn when messages are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose
|
||||
|
||||
*cannot, even in case of a compromised transport protocol:*
|
||||
|
||||
- perform traffic correlation attacks with any increase in efficiency over a non-compromised transport protocol
|
||||
|
||||
#### SimpleX Messaging Protocol server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when a queue recipient is online
|
||||
|
||||
- know how many messages are sent via the queue (although some may be noise or not content messages).
|
||||
|
||||
- learn which messages would trigger notifications even if a user does not use [push notifications](./push-notifications.md).
|
||||
|
||||
- perform the correlation of the queue used to receive messages (matching multiple queues to a single user) via either a re-used transport connection, user's IP Address, or connection timing regularities.
|
||||
|
||||
- learn a recipient's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- drop all future messages inserted into a queue, detectable only over other, redundant queues.
|
||||
|
||||
- lie about the state of a queue to the recipient and/or to the sender (e.g. suspended or deleted when it is not).
|
||||
|
||||
- spam a user with invalid messages.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the users' end-to-end encryption with an active attack.
|
||||
|
||||
- learn a sender's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, even if Tor is not used (provided messages are sent via proxy SMP server).
|
||||
|
||||
- perform senders' queue correlation (matching multiple queues to a single sender) via either a re-used transport connection, user's IP Address, or connection timing regularities, unless it has additional information from the proxy SMP server (provided messages are sent via proxy SMP server).
|
||||
|
||||
#### SimpleX Messaging Protocol server that proxies the messages to another SMP server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a sender's IP address, as long as Tor is not used.
|
||||
|
||||
- learn when a sender with a given IP address is online.
|
||||
|
||||
- know how many messages are sent from a given IP address and to a given destination SMP server.
|
||||
|
||||
- drop all messages from a given IP address or to a given destination server.
|
||||
|
||||
- unless destination SMP server detects repeated public DH keys of senders, replay messages to a destination server within a single session, causing either duplicate message delivery (which will be detected and ignored by the receiving clients), or, when receiving client is not connected to SMP server, exhausting capacity of destination queues used within the session.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- perform queue correlation (matching multiple queues to a single user), unless it has additional information from the destination SMP server.
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- learn which messages would trigger notifications.
|
||||
|
||||
- learn the destination queues of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the user's end-to-end encryption with another user via an active attack.
|
||||
|
||||
- compromise the user's end-to-end encryption with the destination SMP servers via an active attack.
|
||||
|
||||
#### An attacker who obtained Alice's (decrypted) chat database
|
||||
|
||||
*can:*
|
||||
|
||||
- see the history of all messages exchanged by Alice with her communication partners.
|
||||
|
||||
- see shared profiles of contacts and groups.
|
||||
|
||||
- surreptitiously receive new messages sent to Alice via existing queues; until communication queues are rotated or the Double-Ratchet advances forward.
|
||||
|
||||
- prevent Alice from receiving all new messages sent to her - either surreptitiously by emptying the queues regularly or overtly by deleting them.
|
||||
|
||||
- send messages from the user to their contacts; recipients will detect it as soon as the user sends the next message, because the previous message hash won’t match (and potentially won’t be able to decrypt them in case they don’t keep the previous ratchet keys).
|
||||
|
||||
*cannot:*
|
||||
|
||||
- impersonate a sender and send messages to the user whose database was stolen. Doing so requires also compromising the server (to place the message in the queue, that is possible until the Double-Ratchet advances forward) or the user's device at a subsequent time (to place the message in the database).
|
||||
|
||||
- undetectably communicate at the same time as Alice with her contacts. Doing so would result in the contact getting different messages with repeated IDs.
|
||||
|
||||
- undetectably monitor message queues in realtime without alerting the user they are doing so, as a second subscription request unsubscribes the first and notifies the second.
|
||||
|
||||
#### A user’s contact
|
||||
|
||||
*can:*
|
||||
|
||||
- spam the user with messages.
|
||||
|
||||
- forever retain messages from the user.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- cryptographically prove to a third-party that a message came from a user (assuming the user’s device is not seized).
|
||||
|
||||
- prove that two contacts they have is the same user.
|
||||
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user.
|
||||
|
||||
#### An attacker who observes Alice showing an introduction message to Bob
|
||||
|
||||
*can:*
|
||||
|
||||
- Impersonate Bob to Alice.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- Impersonate Alice to Bob.
|
||||
|
||||
#### An attacker with Internet access
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service SimpleX messaging servers.
|
||||
|
||||
- spam a user's public “contact queue” with connection requests.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send messages to a user who they are not connected with.
|
||||
|
||||
- enumerate queues on a SimpleX server.
|
||||
- **Out-of-band key exchange** — connection requests passed outside the network protect against MITM attacks.
|
||||
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
Revision 1, 2026-03-09
|
||||
|
||||
# SimpleX Network: Security
|
||||
|
||||
This document describes the cryptographic primitives and threat model for the SimpleX network. For a general introduction, see [SimpleX: messaging and application platform](./overview-tjr.md).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Encryption primitives](#encryption-primitives)
|
||||
- [Threat model](#threat-model)
|
||||
- [Global Assumptions](#global-assumptions)
|
||||
- [A passive adversary able to monitor the traffic of one user](#a-passive-adversary-able-to-monitor-the-traffic-of-one-user)
|
||||
- [A passive adversary able to monitor a set of senders and recipients](#a-passive-adversary-able-to-monitor-a-set-of-senders-and-recipients)
|
||||
- [SimpleX Messaging Protocol router](#simplex-messaging-protocol-router)
|
||||
- [SimpleX Messaging Protocol router that proxies the messages to another SMP router](#simplex-messaging-protocol-router-that-proxies-the-messages-to-another-smp-router)
|
||||
- [An attacker who obtained Alice's (decrypted) chat database](#an-attacker-who-obtained-alices-decrypted-chat-database)
|
||||
- [A user's contact](#a-users-contact)
|
||||
- [An attacker who observes Alice showing an introduction message to Bob](#an-attacker-who-observes-alice-showing-an-introduction-message-to-bob)
|
||||
- [An attacker with Internet access](#an-attacker-with-internet-access)
|
||||
|
||||
|
||||
## Encryption primitives
|
||||
|
||||
- **Router command authorization**: X25519 DH-based authenticated encryption (SMP v7+), providing sender deniability. Ed25519 signatures used for recipient commands and notifier commands.
|
||||
|
||||
- **Per-queue key agreement**: Curve25519 DH exchange to agree:
|
||||
- the shared secret between router and recipient (to encrypt message bodies — avoids shared ciphertext in sender and recipient traffic),
|
||||
- the shared secret between sender and recipient (to encrypt messages end-to-end in each queue — avoids shared ciphertext in redundant queues).
|
||||
|
||||
- **SMP-layer encryption**: [NaCl crypto_box](https://nacl.cr.yp.to/box.html) (curve25519xsalsa20poly1305) for message body encryption between router and recipient, and for e2e per-queue encryption.
|
||||
|
||||
- **Certificate validation**: SHA256 to validate router offline certificates.
|
||||
|
||||
- **End-to-end encryption**: [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) protocol:
|
||||
- Curve448 keys for shared secret agreement via [X3DH](https://signal.org/docs/specifications/x3dh/) with 2 ephemeral keys per side,
|
||||
- optional [SNTRUP761](https://ntruprime.cr.yp.to/) post-quantum KEM running in parallel with the DH ratchet (see [PQDR](./pqdr.md)), providing post-quantum forward secrecy,
|
||||
- AES-GCM AEAD cipher,
|
||||
- SHA512-based HKDF for key derivation.
|
||||
|
||||
|
||||
## Threat Model
|
||||
|
||||
### Global Assumptions
|
||||
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of routers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
- The user's client uses 2-hop onion message routing.
|
||||
|
||||
### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
*can:*
|
||||
|
||||
- identify that and when a user is using SimpleX.
|
||||
|
||||
- determine which routers the user receives messages from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- see who sends messages to the user and who the user sends messages to.
|
||||
|
||||
- determine the routers used by users' contacts.
|
||||
|
||||
### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- identify who and when is using SimpleX.
|
||||
|
||||
- learn which SimpleX Messaging Protocol routers are used as receive queues for which users.
|
||||
|
||||
- learn when messages are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the routers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot, even in case of a compromised transport protocol:*
|
||||
|
||||
- perform traffic correlation attacks with any increase in efficiency over a non-compromised transport protocol.
|
||||
|
||||
### SimpleX Messaging Protocol router
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when a queue recipient is online.
|
||||
|
||||
- know how many messages are sent via the queue (although some may be noise or not content messages).
|
||||
|
||||
- learn which messages would trigger notifications even if a user does not use [push notifications](./push-notifications.md).
|
||||
|
||||
- perform the correlation of the queue used to receive messages (matching multiple queues to a single user) via either a re-used transport connection, user's IP Address, or connection timing regularities.
|
||||
|
||||
- learn a recipient's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- drop all future messages inserted into a queue, detectable only over other, redundant queues.
|
||||
|
||||
- lie about the state of a queue to the recipient and/or to the sender (e.g. suspended or deleted when it is not).
|
||||
|
||||
- spam a user with invalid messages.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the users' end-to-end encryption with an active attack.
|
||||
|
||||
- learn a sender's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, even if Tor is not used (provided messages are sent via proxy SMP router).
|
||||
|
||||
- perform senders' queue correlation (matching multiple queues to a single sender) via either a re-used transport connection, user's IP Address, or connection timing regularities, unless it has additional information from the proxy SMP router (provided messages are sent via proxy SMP router).
|
||||
|
||||
### SimpleX Messaging Protocol router that proxies the messages to another SMP router
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a sender's IP address, as long as Tor is not used.
|
||||
|
||||
- learn when a sender with a given IP address is online.
|
||||
|
||||
- know how many messages are sent from a given IP address and to a given destination SMP router.
|
||||
|
||||
- drop all messages from a given IP address or to a given destination router.
|
||||
|
||||
- unless destination SMP router detects repeated public DH keys of senders, replay messages to a destination router within a single session, causing either duplicate message delivery (which will be detected and ignored by the receiving clients), or, when receiving client is not connected to SMP router, exhausting capacity of destination queues used within the session.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- perform queue correlation (matching multiple queues to a single user), unless it has additional information from the destination SMP router.
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- learn which messages would trigger notifications.
|
||||
|
||||
- learn the destination queues of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the user's end-to-end encryption with another user via an active attack.
|
||||
|
||||
- compromise the user's end-to-end encryption with the destination SMP routers via an active attack.
|
||||
|
||||
### An attacker who obtained Alice's (decrypted) chat database
|
||||
|
||||
*can:*
|
||||
|
||||
- see the history of all messages exchanged by Alice with her communication partners.
|
||||
|
||||
- see shared profiles of contacts and groups.
|
||||
|
||||
- surreptitiously receive new messages sent to Alice via existing queues; until communication queues are rotated or the Double-Ratchet advances forward.
|
||||
|
||||
- prevent Alice from receiving all new messages sent to her - either surreptitiously by emptying the queues regularly or overtly by deleting them.
|
||||
|
||||
- send messages from the user to their contacts; recipients will detect it as soon as the user sends the next message, because the previous message hash won't match (and potentially won't be able to decrypt them in case they don't keep the previous ratchet keys).
|
||||
|
||||
*cannot:*
|
||||
|
||||
- impersonate a sender and send messages to the user whose database was stolen. Doing so requires also compromising the router (to place the message in the queue, that is possible until the Double-Ratchet advances forward) or the user's device at a subsequent time (to place the message in the database).
|
||||
|
||||
- undetectably communicate at the same time as Alice with her contacts. Doing so would result in the contact getting different messages with repeated IDs.
|
||||
|
||||
- undetectably monitor message queues in realtime without alerting the user they are doing so, as a second subscription request unsubscribes the first and notifies the first.
|
||||
|
||||
### A user's contact
|
||||
|
||||
*can:*
|
||||
|
||||
- spam the user with messages.
|
||||
|
||||
- forever retain messages from the user.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- cryptographically prove to a third-party that a message came from a user (assuming the user's device is not seized).
|
||||
|
||||
- prove that two contacts they have is the same user.
|
||||
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user.
|
||||
|
||||
### An attacker who observes Alice showing an introduction message to Bob
|
||||
|
||||
*can:*
|
||||
|
||||
- Impersonate Bob to Alice.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- Impersonate Alice to Bob.
|
||||
|
||||
### An attacker with Internet access
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service SimpleX messaging routers.
|
||||
|
||||
- spam a user's public "contact queue" with connection requests.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send messages to a user who they are not connected with.
|
||||
|
||||
- enumerate queues on a SimpleX router.
|
||||
@@ -0,0 +1,154 @@
|
||||
# XFTP Server: SNI, CORS, and Web Support
|
||||
|
||||
Implementation details for Phase 3 of `rfcs/2026-01-30-send-file-page.md` (sections 6.1-6.4).
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The XFTP server is extended to support web browser clients by:
|
||||
|
||||
1. **SNI-based TLS certificate switching** — Present a CA-issued web certificate (e.g., Let's Encrypt) to browsers, while continuing to present the self-signed XFTP identity certificate to native clients.
|
||||
2. **CORS headers** — Add CORS response headers on SNI connections so browsers allow cross-origin XFTP requests.
|
||||
3. **Configuration** — `[WEB]` INI section for HTTPS cert/key paths; opt-in (commented out by default).
|
||||
|
||||
Web handshake (challenge-response identity proof, §6.3 of parent RFC) is not yet implemented and will be added separately.
|
||||
|
||||
## 2. SNI Certificate Switching
|
||||
|
||||
### 2.1 Reusing the SMP Pattern
|
||||
|
||||
The SMP server already implements SNI-based certificate switching via `TLSServerCredential` and `runTransportServerState_` (see `rfcs/2024-09-15-shared-port.md`). The XFTP server applies the same pattern with one key difference: both native and web XFTP clients use HTTP/2 transport, whereas SMP switches between raw SMP protocol and HTTP entirely.
|
||||
|
||||
### 2.2 Approach
|
||||
|
||||
When `httpServerCreds` is configured, the XFTP server bypasses `runHTTP2Server` and uses `runTransportServerState_` directly to obtain the per-connection `sniUsed` flag. It then sets up HTTP/2 manually on each TLS connection using `withHTTP2` (same internals as `runHTTP2ServerWith_`). The `sniUsed` flag is captured in the closure and shared by all HTTP/2 requests on that connection.
|
||||
|
||||
When `httpServerCreds` is absent, the existing `runHTTP2Server` path is unchanged.
|
||||
|
||||
```
|
||||
Native client (no SNI) ──TLS──> XFTP identity cert ──HTTP/2──> processRequest (no CORS)
|
||||
Browser client (SNI) ──TLS──> Web CA cert ──HTTP/2──> processRequest (+ CORS)
|
||||
```
|
||||
|
||||
### 2.3 Certificate Chain
|
||||
|
||||
The web certificate file (e.g., `web.crt`) must contain the full chain: leaf certificate followed by the signing CA certificate. `loadServerCredential` uses `T.credentialLoadX509Chain` which reads all PEM blocks from the file.
|
||||
|
||||
The client validates the chain by comparing `idCert` fingerprint (the CA cert, second in the 2-cert chain) against the known `keyHash`. This is the same validation as for XFTP identity certificates — the CA that signed the web cert must match the XFTP server's identity.
|
||||
|
||||
## 3. CORS Support
|
||||
|
||||
### 3.1 Design
|
||||
|
||||
CORS headers are only added when both conditions are true:
|
||||
- `addCORSHeaders` is `True` in `TransportServerConfig` (set in XFTP `Main.hs`)
|
||||
- `sniUsed` is `True` for the current TLS connection
|
||||
|
||||
This ensures native clients never see CORS headers.
|
||||
|
||||
### 3.2 Response Headers
|
||||
|
||||
All POST responses on SNI connections include:
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Expose-Headers: *
|
||||
```
|
||||
|
||||
### 3.3 OPTIONS Preflight
|
||||
|
||||
OPTIONS requests are intercepted at the HTTP/2 dispatch level, before `processRequest`. This is necessary because `processRequest` rejects bodies that don't match `xftpBlockSize`.
|
||||
|
||||
Preflight response:
|
||||
```
|
||||
HTTP/2 200
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: POST, OPTIONS
|
||||
Access-Control-Allow-Headers: *
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
### 3.4 Security
|
||||
|
||||
`Access-Control-Allow-Origin: *` is safe because:
|
||||
- All XFTP commands require Ed25519 authentication (per-chunk keys from file description).
|
||||
- No cookies or browser credentials are involved.
|
||||
- File content is end-to-end encrypted.
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
### 4.1 INI Template
|
||||
|
||||
```ini
|
||||
[WEB]
|
||||
# cert: /etc/opt/simplex-xftp/web.crt
|
||||
# key: /etc/opt/simplex-xftp/web.key
|
||||
```
|
||||
|
||||
Commented out by default — web support is opt-in.
|
||||
|
||||
### 4.2 Behavior
|
||||
|
||||
- `[WEB]` section not configured: silently ignored, server operates normally for native clients only.
|
||||
- `[WEB]` section configured with valid cert/key paths: SNI + CORS enabled.
|
||||
- `[WEB]` section configured with missing cert files: warning + continue (non-fatal, unlike SMP where it is fatal).
|
||||
|
||||
## 5. Files Modified
|
||||
|
||||
### 5.1 `src/Simplex/Messaging/Transport/Server.hs`
|
||||
|
||||
Added `addCORSHeaders :: Bool` field to `TransportServerConfig`. Updated `mkTransportServerConfig` to accept the new parameter. All existing SMP call sites pass `False`.
|
||||
|
||||
### 5.2 `src/Simplex/Messaging/Transport/HTTP2/Server.hs`
|
||||
|
||||
- Extracted `expireInactiveClient` from `runHTTP2ServerWith_`'s `where` clause to a module-level function.
|
||||
- Parameterized `runHTTP2ServerWith_`: setup type changed from `((TLS p -> IO ()) -> a)` to `(((Bool, TLS p) -> IO ()) -> a)`, callback from `HTTP2ServerFunc` to `Bool -> HTTP2ServerFunc`. The `Bool` is the per-connection `sniUsed` flag, threaded through `H.run` to the callback.
|
||||
- Extended `runHTTP2Server` with `Maybe T.Credential` parameter for SNI web certificate. Its setup uses `runTransportServerState_` with `TLSServerCredential`, which naturally provides `(sniUsed, tls)` pairs matching the new `runHTTP2ServerWith_` setup type.
|
||||
- Adapted `runHTTP2ServerWith` (client-side HTTP/2, no SNI): wraps its setup to inject `(False, tls)` and its callback with `const`.
|
||||
- Updated `getHTTP2Server` (test helper) to pass `Nothing` for httpCreds.
|
||||
|
||||
### 5.3 `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
|
||||
- Added `httpCredentials :: Maybe ServerCredentials` to `XFTPServerConfig`.
|
||||
- Added `httpServerCreds :: Maybe T.Credential` to `XFTPEnv`.
|
||||
- `newXFTPServerEnv` loads HTTP credentials when configured.
|
||||
|
||||
### 5.4 `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
|
||||
- Added `[WEB]` section to INI template.
|
||||
- Added `httpCredentials` parsing from INI `[WEB]` section (`cert` and `key` fields).
|
||||
- Set `addCORSHeaders = isJust httpCredentials_` in transport config (conditional on web cert presence).
|
||||
|
||||
### 5.5 `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
Core server changes:
|
||||
|
||||
- `runServer` calls `runHTTP2Server` with `httpCreds_` and a `\sniUsed -> handleRequest (sniUsed && addCORSHeaders transportConfig)` callback. TLS params are `defaultSupportedParamsHTTPS` when web creds present, `defaultSupportedParams` otherwise. SNI routing, HTTP/2 setup, and client expiration are handled inside `runHTTP2Server`.
|
||||
|
||||
- `XFTPTransportRequest` carries `addCORS :: Bool` field, threaded through to `sendXFTPResponse`.
|
||||
|
||||
- `sendXFTPResponse` conditionally includes CORS headers based on `addCORS`.
|
||||
|
||||
- OPTIONS requests on SNI connections return CORS preflight headers before reaching `processRequest`.
|
||||
|
||||
- Helper functions: `corsHeaders` (response headers), `corsPreflightHeaders` (preflight headers).
|
||||
|
||||
### 5.6 `tests/XFTPClient.hs`
|
||||
|
||||
- Added `httpCredentials = Nothing` to `testXFTPServerConfig`.
|
||||
- Added `testXFTPServerConfigSNI` with web cert config and `addCORSHeaders = True`.
|
||||
- Added `withXFTPServerSNI` helper.
|
||||
|
||||
### 5.7 `tests/XFTPServerTests.hs`
|
||||
|
||||
Added SNI and CORS tests as a subsection within `xftpServerTests` (6 tests):
|
||||
|
||||
1. **SNI cert selection** — Connect with SNI + `h2` ALPN, verify RSA web certificate is presented.
|
||||
2. **Non-SNI cert selection** — Connect without SNI + `xftp/1` ALPN, verify Ed448 XFTP certificate is presented.
|
||||
3. **CORS headers** — SNI POST request includes `Access-Control-Allow-Origin: *` and `Access-Control-Expose-Headers: *`.
|
||||
4. **OPTIONS preflight** — SNI OPTIONS request returns all CORS preflight headers.
|
||||
5. **No CORS without SNI** — Non-SNI POST request has no CORS headers.
|
||||
6. **File chunk delivery** — Full XFTP file chunk upload/download through SNI-enabled server verifying no regression.
|
||||
|
||||
## 6. Remaining Work
|
||||
|
||||
- **Web handshake** (§6.3 of parent RFC): Challenge-response identity proof for SNI connections. The server detects web clients via the `sniUsed` flag and expects a 32-byte challenge in the first POST body (non-empty, unlike standard handshake). Response includes full cert chain + signature over `(challenge ++ sessionId)`.
|
||||
- **Static page serving** (§6.5 of parent RFC): Optional serving of the web page HTML/JS bundle on GET requests.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Web Handshake — Challenge-Response Identity Proof
|
||||
|
||||
RFC §6.3: Server proves XFTP identity to web clients independently of TLS CA infrastructure.
|
||||
|
||||
## 1. Protocol
|
||||
|
||||
**Standard handshake** (unchanged):
|
||||
```
|
||||
Client → empty POST → Server
|
||||
Server → padded {vRange, sessionId, authPubKey, Nothing} → Client
|
||||
Client → padded {version, keyHash, Nothing} → Server
|
||||
Server → empty → Client
|
||||
```
|
||||
|
||||
**Web handshake** (SNI connection, non-empty hello):
|
||||
```
|
||||
Client → padded {32 random bytes} → Server
|
||||
Server → padded {vRange, sessionId, authPubKey, Just sigBytes} → Client
|
||||
sigBytes = signatureBytes(sign(identityLeafKey, challenge <> sessionId))
|
||||
Client validates:
|
||||
1. chainIdCaCerts(authPubKey.certChain) → CCValid {leafCert, idCert}
|
||||
2. SHA-256(idCert) == keyHash (server identity)
|
||||
3. verify(leafCert.pubKey, sigBytes, challenge <> sessionId) (challenge-response)
|
||||
4. verify(leafCert.pubKey, signedPubKey.signature, signedPubKey.objectDer) (DH key auth)
|
||||
Client → padded {version, keyHash, Just challenge} → Server
|
||||
Server verifies: echoed challenge == stored challenge from step 1
|
||||
Server → empty → Client
|
||||
```
|
||||
|
||||
**Detection**: `sniUsed` per-connection flag. Non-empty hello allowed only when `sniUsed`. Empty hello with SNI → standard handshake.
|
||||
|
||||
**Why both steps 3 and 4**: Native clients verify `signedPubKey` using the TLS peer certificate (`serverKey` from `getServerVerifyKey`), which is the XFTP identity cert in non-SNI connections — TLS provides this binding. Web clients cannot access TLS peer certificate data (browser API limitation; TLS presents the web CA cert but provides no API to extract it). So web clients must verify at the application layer using `authPubKey.certChain`, which always contains the XFTP identity chain regardless of which cert TLS used. Step 3 proves the server holds its identity key *right now* (freshness via random challenge). Step 4 proves the DH session key was signed by the identity key holder (prevents MITM key substitution). Together they give web clients some assurance native clients get from TLS, except channel binding for commands.
|
||||
|
||||
## 2. Type Changes — `src/Simplex/FileTransfer/Transport.hs`
|
||||
|
||||
### `XFTPServerHandshake` (line 114)
|
||||
|
||||
Add field: `webIdentityProof :: Maybe ByteString` — raw Ed448 signature bytes (114 bytes), or `Nothing` for standard handshake. No record needed — the cert chain is already in `authPubKey.certChain`.
|
||||
|
||||
### `Encoding XFTPServerHandshake` (line 136)
|
||||
|
||||
- `smpEncode`: append `smpEncode webIdentityProof`
|
||||
- `smpP`: `Tail compat`, if non-empty `eitherToMaybe $ smpDecode compat`
|
||||
|
||||
Backward compat: old clients ignore via `Tail _compat`; new client + old server → empty compat → `Nothing`.
|
||||
|
||||
### `XFTPClientHandshake` (line 121)
|
||||
|
||||
Add field: `webChallenge :: Maybe ByteString`
|
||||
|
||||
### `Encoding XFTPClientHandshake` (line 128)
|
||||
|
||||
Same `Tail compat` pattern as server handshake.
|
||||
|
||||
### Export list
|
||||
|
||||
Both types use `(..)` export — new fields auto-exported.
|
||||
|
||||
## 3. Server Changes — `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
### `XFTPTransportRequest` (line 88)
|
||||
|
||||
Add field: `sniUsed :: SNICredentialUsed` (`Bool` from `Transport.Server`). Add import.
|
||||
|
||||
### `Handshake` (line 117)
|
||||
|
||||
`HandshakeSent C.PrivateKeyX25519` → `HandshakeSent C.PrivateKeyX25519 (Maybe ByteString)` — stores 32-byte web challenge or `Nothing`.
|
||||
|
||||
### `runServer` handler (line 145–161)
|
||||
|
||||
- Pass `sniUsed` into request construction (line 154)
|
||||
- SNI-first routing: when `sniUsed`, always route to `xftpServerHandshakeV1` (web ALPN `h2` would otherwise fall to `_` catch-all)
|
||||
|
||||
### `xftpServerHandshakeV1` (line 162)
|
||||
|
||||
- Destructure `sniUsed` from request
|
||||
- Match `HandshakeSent pk challenge_` → `processClientHandshake pk challenge_`
|
||||
|
||||
### `processHello` (line 171)
|
||||
|
||||
- Branch `(sniUsed, B.null bodyHead)`:
|
||||
- `(_, True)` → standard: `challenge_ = Nothing`
|
||||
- `(True, False)` → web: unpad, verify 32 bytes, `challenge_ = Just`
|
||||
- `(False, False)` → `throwE HANDSHAKE`
|
||||
- Store: `HandshakeSent pk challenge_`
|
||||
- Compute: `webIdentityProof = C.signatureBytes . C.sign serverSignKey . (<> sessionId) <$> challenge_`
|
||||
- Construct `XFTPServerHandshake` with `webIdentityProof`
|
||||
|
||||
### `processClientHandshake` (line 183)
|
||||
|
||||
- Accept `challenge_` parameter
|
||||
- Decode `webChallenge` from `XFTPClientHandshake`
|
||||
- Add: `unless (challenge_ == webChallenge) $ throwE HANDSHAKE`
|
||||
(standard: both `Nothing` → passes)
|
||||
|
||||
## 4. Native Client — `src/Simplex/FileTransfer/Client.hs`
|
||||
|
||||
### `xftpClientHandshakeV1` (line 142)
|
||||
|
||||
Add `webChallenge = Nothing` in `sendClientHandshake` call.
|
||||
|
||||
No other changes — parser handles new fields via `Tail`, native client ignores `webIdentityProof`.
|
||||
|
||||
## 5. TypeScript Changes (DONE except Ed448)
|
||||
|
||||
Sections 5.1 and 5.2 are implemented. Section 5.3 needs Ed448 support.
|
||||
|
||||
## 10. Ed448 Support via `@noble/curves`
|
||||
|
||||
**Problem**: Production servers use Ed448 certificates (default). `identity.ts` only supports Ed25519 via libsodium. libsodium has no Ed448 support and never will.
|
||||
|
||||
**Solution**: Add `@noble/curves` dependency for Ed448 verification only. All other crypto stays with libsodium.
|
||||
|
||||
### 10.1 `xftp-web/package.json` — Add dependency
|
||||
|
||||
```json
|
||||
"dependencies": {
|
||||
"libsodium-wrappers-sumo": "^0.7.13",
|
||||
"@noble/curves": "^1.9.7"
|
||||
}
|
||||
```
|
||||
|
||||
Use v1.x (supports both CJS and ESM). v2.x is ESM-only with `.js` extension requirement.
|
||||
|
||||
### 10.2 `xftp-web/src/crypto/keys.ts` — Ed448 DER constants and decode
|
||||
|
||||
Add Ed448 SPKI DER prefix (12 bytes, same prefix length as Ed25519):
|
||||
```
|
||||
30 43 30 05 06 03 2b 65 71 03 3a 00
|
||||
```
|
||||
|
||||
| Property | Ed25519 | Ed448 |
|
||||
|----------|---------|-------|
|
||||
| OID | `2b 65 70` | `2b 65 71` |
|
||||
| SPKI prefix | `30 2a ...` | `30 43 ...` |
|
||||
| Raw key size | 32 bytes | 57 bytes |
|
||||
| SPKI total | 44 bytes | 69 bytes |
|
||||
| Signature size | 64 bytes | 114 bytes |
|
||||
|
||||
New functions:
|
||||
- `decodePubKeyEd448(der: Uint8Array): Uint8Array` — 69 bytes → 57 bytes raw
|
||||
- `encodePubKeyEd448(raw: Uint8Array): Uint8Array` — 57 bytes → 69 bytes DER
|
||||
- `verifyEd448(publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean` — uses `ed448.verify(sig, msg, publicKey)` from `@noble/curves/ed448`
|
||||
|
||||
Note: `@noble/curves` parameter order is `(signature, message, publicKey)`, not `(publicKey, signature, message)`.
|
||||
|
||||
### 10.3 `xftp-web/src/crypto/identity.ts` — Algorithm-agnostic verification
|
||||
|
||||
Replace `extractCertEd25519Key` + hardcoded Ed25519 `verify` with algorithm detection:
|
||||
|
||||
1. `extractCertPublicKeyInfo(certDer)` → SPKI DER (already exists, works for any algorithm)
|
||||
2. Detect algorithm from SPKI: byte at offset 8 is `0x70` (Ed25519) or `0x71` (Ed448)
|
||||
3. Extract raw key with appropriate decoder
|
||||
4. Verify signatures with appropriate function
|
||||
|
||||
```typescript
|
||||
type CertKeyAlgorithm = 'ed25519' | 'ed448'
|
||||
|
||||
function detectKeyAlgorithm(spki: Uint8Array): CertKeyAlgorithm {
|
||||
if (spki.length === 44 && spki[8] === 0x70) return 'ed25519'
|
||||
if (spki.length === 69 && spki[8] === 0x71) return 'ed448'
|
||||
throw new Error("unsupported certificate key algorithm")
|
||||
}
|
||||
```
|
||||
|
||||
`verifyIdentityProof` changes:
|
||||
- Extract SPKI from leaf cert
|
||||
- Detect algorithm → choose `decodePubKeyEd25519`/`decodePubKeyEd448` and `verify`/`verifyEd448`
|
||||
- Both challenge signature and DH key signature use the same leaf key + algorithm
|
||||
|
||||
Remove `extractCertEd25519Key` (replaced by generic path). Keep `extractCertPublicKeyInfo` (already generic).
|
||||
|
||||
### 10.4 `xftp-web/src/protocol/handshake.ts` — Comment update
|
||||
|
||||
`SignedKey.signature` comment: "raw Ed25519 signature bytes (64 bytes)" → "raw signature bytes (Ed25519: 64, Ed448: 114)"
|
||||
|
||||
### 10.5 Tests — `tests/XFTPWebTests.hs`
|
||||
|
||||
**Integration test**: Switch from `withXFTPServerEd25519SNI` (Ed25519 fixtures) to `withXFTPServerSNI` (default Ed448 fixtures). Update fingerprint source from `tests/fixtures/ed25519/ca.crt` to `tests/fixtures/ca.crt`.
|
||||
|
||||
Optionally add a second integration test with Ed25519 to cover both paths, or rely on existing unit tests for Ed25519 coverage.
|
||||
|
||||
### 10.6 Implementation order
|
||||
|
||||
1. `npm install @noble/curves` in `xftp-web/`
|
||||
2. `keys.ts` — Ed448 constants, decode, encode, verifyEd448
|
||||
3. `identity.ts` — algorithm detection, generic verification
|
||||
4. `handshake.ts` — comment fix
|
||||
5. `XFTPWebTests.hs` — switch integration test to Ed448
|
||||
6. Build TS + run all tests
|
||||
|
||||
## 6. Haskell Integration Test — `tests/XFTPServerTests.hs`
|
||||
|
||||
Add `testWebHandshake` to "XFTP SNI and CORS" describe block.
|
||||
|
||||
1. `withXFTPServerSNI` — server with web credentials
|
||||
2. Connect with SNI + `h2` ALPN
|
||||
3. Send padded 32-byte challenge
|
||||
4. Decode `XFTPServerHandshake`, assert `webIdentityProof` is `Just`
|
||||
5. `chainIdCaCerts` on `authPubKey.certChain` → `CCValid {leafCert, idCert}`
|
||||
6. Verify `SHA-256(idCert) == keyHash`
|
||||
7. Extract `leafCert` public key, verify challenge signature
|
||||
8. Verify `signedPubKey` signature using `leafCert` key (DH key auth)
|
||||
9. Send `XFTPClientHandshake` with `webChallenge = Just challenge`
|
||||
10. Assert empty response
|
||||
|
||||
Imports: `XFTPServerHandshake (..)`, `XFTPClientHandshake (..)`, `ChainCertificates (..)`, `chainIdCaCerts`.
|
||||
|
||||
## 7. TS Tests — `tests/XFTPWebTests.hs`
|
||||
|
||||
### Unit tests
|
||||
|
||||
- **`decodeServerHandshake` with proof**: Haskell-encode with `Just sigBytes`, TS-decode, verify bytes match.
|
||||
- **`encodeClientHandshake` with challenge**: TS-encode, compare with Haskell-encoded.
|
||||
- **`chainIdCaCerts`**: 2/3/4-cert chains return correct positions.
|
||||
- **`caFingerprint` (fixed)**: matches `sha256(idCert)` for 2 and 3-cert chains.
|
||||
|
||||
### Integration test
|
||||
|
||||
Node.js inline script against `withXFTPServerSNI`:
|
||||
1. Connect with SNI via `http2.connect`
|
||||
2. Send padded challenge, decode `XFTPServerHandshake` with TS
|
||||
3. `verifyIdentityProof` — full chain validation + challenge sig + DH key sig
|
||||
4. Send client handshake with echoed challenge
|
||||
5. Assert empty response
|
||||
|
||||
## 8. Implementation Order
|
||||
|
||||
1. `Transport.hs` — `Maybe` fields + encoding instances
|
||||
2. `Server.hs` — `sniUsed`, challenge in `Handshake`, `processHello`, `processClientHandshake`, SNI routing
|
||||
3. `Client.hs` — `webChallenge = Nothing`
|
||||
4. Build: `cabal build --ghc-options -O0`
|
||||
5. Run existing SNI/CORS tests
|
||||
6. `XFTPServerTests.hs` — `testWebHandshake`
|
||||
7. `handshake.ts` — types, decoding, `chainIdCaCerts`, fix `caFingerprint`
|
||||
8. `crypto/identity.ts` — Node.js verification functions
|
||||
9. `XFTPWebTests.hs` — unit + integration tests
|
||||
10. Build TS + run all tests
|
||||
|
||||
## 9. Verification
|
||||
|
||||
```bash
|
||||
cd xftp-web && npm install && npm run build && cd ..
|
||||
cabal test --ghc-options=-O0 --test-option='--match=/XFTP/XFTP server/XFTP SNI and CORS/' --test-show-details=streaming
|
||||
cabal test --ghc-options=-O0 --test-option='--match=/XFTP Web Client/' --test-show-details=streaming
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
# Plan: Browser ↔ Haskell File Transfer Tests
|
||||
|
||||
## Table of Contents
|
||||
1. Goal
|
||||
2. Current State
|
||||
3. Implementation
|
||||
4. Success Criteria
|
||||
5. Files
|
||||
6. Order
|
||||
|
||||
## 1. Goal
|
||||
Run browser upload/download tests in headless Chromium via Vitest, proving fetch-based transport works in real browser environment.
|
||||
|
||||
## 2. Current State
|
||||
- `client.ts`: Transport abstraction done — http2 for Node, fetch for browser ✓
|
||||
- `agent.ts`: Uses `node:crypto` (randomBytes) and `node:zlib` (deflateRawSync/inflateRawSync) — **won't run in browser**
|
||||
- `XFTPWebTests.hs`: Cross-language tests exist (Haskell calls TS via Node.js) ✓
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
### 3.1 Make agent.ts isomorphic
|
||||
|
||||
| Current (Node.js only) | Isomorphic replacement |
|
||||
|------------------------|------------------------|
|
||||
| `import crypto from "node:crypto"` | Remove import |
|
||||
| `import zlib from "node:zlib"` | `import pako from "pako"` |
|
||||
| `crypto.randomBytes(32)` | `crypto.getRandomValues(new Uint8Array(32))` |
|
||||
| `zlib.deflateRawSync(buf)` | `pako.deflateRaw(buf)` |
|
||||
| `zlib.inflateRawSync(buf)` | `pako.inflateRaw(buf)` |
|
||||
|
||||
Note: `crypto.getRandomValues` available in both browser and Node.js (globalThis.crypto).
|
||||
|
||||
### 3.2 Vitest browser mode setup
|
||||
|
||||
`package.json` additions:
|
||||
```json
|
||||
"devDependencies": {
|
||||
"vitest": "^3.0.0",
|
||||
"@vitest/browser": "^3.0.0",
|
||||
"playwright": "^1.50.0",
|
||||
"@types/pako": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
`vitest.config.ts`:
|
||||
```typescript
|
||||
import {defineConfig} from 'vitest/config'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
|
||||
// Compute fingerprint from ca.crt (same as Haskell's loadFileFingerprint)
|
||||
const caCert = readFileSync('../tests/fixtures/ca.crt')
|
||||
const fingerprint = createHash('sha256').update(caCert).digest('base64url')
|
||||
const serverAddr = `xftp://${fingerprint}@localhost:7000`
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr)
|
||||
},
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{browser: 'chromium'}],
|
||||
headless: true,
|
||||
providerOptions: {
|
||||
launch: {ignoreHTTPSErrors: true}
|
||||
}
|
||||
},
|
||||
globalSetup: './test/globalSetup.ts'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 Server startup
|
||||
|
||||
`test/globalSetup.ts`:
|
||||
```typescript
|
||||
import {spawn, ChildProcess} from 'child_process'
|
||||
import {resolve, join} from 'path'
|
||||
import {mkdtempSync, writeFileSync, copyFileSync} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
|
||||
let server: ChildProcess | null = null
|
||||
|
||||
export async function setup() {
|
||||
const fixtures = resolve(__dirname, '../../tests/fixtures')
|
||||
|
||||
// Create temp directories
|
||||
const cfgDir = mkdtempSync(join(tmpdir(), 'xftp-cfg-'))
|
||||
const logDir = mkdtempSync(join(tmpdir(), 'xftp-log-'))
|
||||
const filesDir = mkdtempSync(join(tmpdir(), 'xftp-files-'))
|
||||
|
||||
// Copy certificates to cfgDir (xftp-server expects ca.crt, server.key, server.crt there)
|
||||
copyFileSync(join(fixtures, 'ca.crt'), join(cfgDir, 'ca.crt'))
|
||||
copyFileSync(join(fixtures, 'server.key'), join(cfgDir, 'server.key'))
|
||||
copyFileSync(join(fixtures, 'server.crt'), join(cfgDir, 'server.crt'))
|
||||
|
||||
// Write INI config file
|
||||
const iniContent = `[STORE_LOG]
|
||||
enable: off
|
||||
|
||||
[TRANSPORT]
|
||||
host: localhost
|
||||
port: 7000
|
||||
|
||||
[FILES]
|
||||
path: ${filesDir}
|
||||
|
||||
[WEB]
|
||||
cert: ${join(fixtures, 'web.crt')}
|
||||
key: ${join(fixtures, 'web.key')}
|
||||
`
|
||||
writeFileSync(join(cfgDir, 'file-server.ini'), iniContent)
|
||||
|
||||
// Spawn xftp-server with env vars
|
||||
server = spawn('cabal', ['exec', 'xftp-server', '--', 'start'], {
|
||||
env: {
|
||||
...process.env,
|
||||
XFTP_SERVER_CFG_PATH: cfgDir,
|
||||
XFTP_SERVER_LOG_PATH: logDir
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
// Wait for "Listening on port 7000..."
|
||||
await waitForServerReady(server)
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
server?.kill('SIGTERM')
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
function waitForServerReady(proc: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Server start timeout')), 15000)
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
if (data.toString().includes('Listening on port')) {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[xftp-server]', data.toString())
|
||||
})
|
||||
proc.on('error', reject)
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code !== 0) reject(new Error(`Server exited with code ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Server env vars (from `apps/xftp-server/Main.hs` + `getEnvPath`):
|
||||
- `XFTP_SERVER_CFG_PATH` — directory containing `file-server.ini` and certs (`ca.crt`, `server.key`, `server.crt`)
|
||||
- `XFTP_SERVER_LOG_PATH` — directory for logs
|
||||
|
||||
### 3.4 Browser test
|
||||
|
||||
`test/browser.test.ts`:
|
||||
```typescript
|
||||
import {test, expect} from 'vitest'
|
||||
import {encryptFileForUpload, uploadFile, downloadFile} from '../src/agent.js'
|
||||
import {parseXFTPServer} from '../src/protocol/address.js'
|
||||
|
||||
const server = parseXFTPServer(import.meta.env.XFTP_SERVER)
|
||||
|
||||
test('browser upload + download round-trip', async () => {
|
||||
const data = new Uint8Array(50000)
|
||||
crypto.getRandomValues(data)
|
||||
const encrypted = encryptFileForUpload(data, 'test.bin')
|
||||
const {rcvDescription} = await uploadFile(server, encrypted)
|
||||
const {content} = await downloadFile(rcvDescription)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
```
|
||||
|
||||
## 4. Success Criteria
|
||||
|
||||
1. `npm run build` — agent.ts compiles without node: imports
|
||||
2. `cabal test --test-option='--match=/XFTP Web Client/'` — existing Node.js tests still pass
|
||||
3. `npm run test:browser` — browser round-trip test passes in headless Chromium
|
||||
|
||||
## 5. Files to Create/Modify
|
||||
|
||||
**Modify:**
|
||||
- `xftp-web/package.json` — add vitest, @vitest/browser, playwright, pako, @types/pako
|
||||
- `xftp-web/src/agent.ts` — replace node:crypto, node:zlib with isomorphic alternatives
|
||||
|
||||
**Create:**
|
||||
- `xftp-web/vitest.config.ts` — browser mode config
|
||||
- `xftp-web/test/globalSetup.ts` — xftp-server lifecycle
|
||||
- `xftp-web/test/browser.test.ts` — browser round-trip test
|
||||
|
||||
## 6. Order of Implementation
|
||||
|
||||
1. **Add pako dependency** — `npm install pako @types/pako`
|
||||
2. **Make agent.ts isomorphic** — replace node:crypto, node:zlib
|
||||
3. **Verify Node.js tests pass** — `cabal test --test-option='--match=/XFTP Web Client/'`
|
||||
4. **Set up Vitest** — add devDeps, create vitest.config.ts
|
||||
5. **Create globalSetup.ts** — write INI config, spawn xftp-server
|
||||
6. **Write browser test** — upload + download round-trip
|
||||
7. **Verify browser test passes** — `npm run test:browser`
|
||||
@@ -0,0 +1,920 @@
|
||||
# Browser Transport & Web Worker Architecture
|
||||
|
||||
## TOC
|
||||
|
||||
1. Executive Summary
|
||||
2. Transport: fetch() API
|
||||
3. Architecture: Environment Abstraction
|
||||
4. Web Worker Implementation
|
||||
5. OPFS Implementation
|
||||
6. Implementation Plan
|
||||
7. Testing Strategy
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Adapt `client.ts` from `node:http2` to `fetch()` API for isomorphic Node.js/browser support. Add environment abstraction layer so the same upload/download pipeline works with or without Web Workers and with or without OPFS. In browsers, crypto runs in a Web Worker to keep UI responsive; in Node.js tests, crypto runs directly.
|
||||
|
||||
**Key architectural constraint:** Existing crypto functions (`encryptFile`, `decryptChunks`, etc.) remain unchanged. The abstraction layer wraps them, choosing execution context (direct vs Worker) and storage (memory vs OPFS) based on environment.
|
||||
|
||||
**Scope:**
|
||||
- Replace `node:http2` with `fetch()` in `client.ts`
|
||||
- Add `CryptoBackend` abstraction with three implementations
|
||||
- Create Web Worker that calls existing crypto functions
|
||||
- Add OPFS storage for large files in browser
|
||||
|
||||
**Out of scope:** Web page UI (Phase 5 in main RFC).
|
||||
|
||||
## 2. Transport: fetch() API
|
||||
|
||||
### 2.1 Current State
|
||||
|
||||
`client.ts` uses `node:http2`:
|
||||
```typescript
|
||||
import http2 from "node:http2"
|
||||
const session = http2.connect(url)
|
||||
const stream = session.request({':method': 'POST', ':path': '/'})
|
||||
stream.write(commandBlock)
|
||||
stream.end(chunkData)
|
||||
```
|
||||
|
||||
### 2.2 Target State
|
||||
|
||||
Isomorphic `fetch()` (Node.js 18+ and browsers):
|
||||
```typescript
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: concatStreams(commandBlock, chunkData),
|
||||
duplex: 'half', // Required for streaming request body
|
||||
})
|
||||
const reader = response.body!.getReader()
|
||||
```
|
||||
|
||||
### 2.3 Key Differences
|
||||
|
||||
| Aspect | node:http2 | fetch() |
|
||||
|--------|-----------|---------|
|
||||
| Session management | Explicit `session.connect()` / `session.close()` | Per-request (HTTP/2 connection reuse is automatic) |
|
||||
| Streaming upload | `stream.write()` chunks | `ReadableStream` body + `duplex: 'half'` |
|
||||
| Streaming download | `stream.on('data')` | `response.body.getReader()` |
|
||||
| Connection pooling | Manual | Automatic per origin |
|
||||
|
||||
### 2.4 API Changes
|
||||
|
||||
```typescript
|
||||
// Before (node:http2)
|
||||
export interface XFTPClient {
|
||||
session: http2.ClientHttp2Session
|
||||
thParams: THParams
|
||||
server: XFTPServer
|
||||
}
|
||||
|
||||
// After (fetch)
|
||||
export interface XFTPClient {
|
||||
baseUrl: string // "https://host:port"
|
||||
thParams: THParams
|
||||
server: XFTPServer
|
||||
}
|
||||
```
|
||||
|
||||
`connectXFTP()` performs handshake via fetch, returns `XFTPClient` with `baseUrl`.
|
||||
Subsequent commands use `fetch(client.baseUrl, ...)`.
|
||||
|
||||
### 2.5 Handshake via fetch()
|
||||
|
||||
**TLS session binding:** Multiple fetch() requests to the same origin reuse the HTTP/2 connection, which means they share the same TLS session. The server's `sessionId` (derived from TLS channel binding) remains consistent across the handshake round-trips and subsequent commands.
|
||||
|
||||
```typescript
|
||||
async function connectXFTP(server: XFTPServer): Promise<XFTPClient> {
|
||||
const baseUrl = `https://${server.host}:${server.port}`
|
||||
|
||||
// Round-trip 1: challenge → server handshake + identity proof
|
||||
const challenge = crypto.getRandomValues(new Uint8Array(32))
|
||||
const req1 = pad(encodeWebClientHello(challenge), xftpBlockSize)
|
||||
const resp1 = await fetch(baseUrl, {method: 'POST', body: req1})
|
||||
|
||||
const reader = resp1.body!.getReader()
|
||||
const serverBlock = await readExactly(reader, xftpBlockSize)
|
||||
const serverHs = decodeServerHandshake(unPad(serverBlock))
|
||||
const proofBody = await readRemaining(reader)
|
||||
verifyIdentityProof(server.keyHash, challenge, serverHs.sessionId, proofBody)
|
||||
|
||||
// Round-trip 2: client handshake → server ack
|
||||
const clientHs = encodeClientHandshake({xftpVersion: 3, keyHash: server.keyHash})
|
||||
const req2 = pad(clientHs, xftpBlockSize)
|
||||
await fetch(baseUrl, {method: 'POST', body: req2})
|
||||
|
||||
return {baseUrl, thParams: {sessionId: serverHs.sessionId, ...}, server}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 Command Execution
|
||||
|
||||
```typescript
|
||||
async function sendXFTPCommand(
|
||||
client: XFTPClient,
|
||||
key: Uint8Array,
|
||||
entityId: Uint8Array,
|
||||
cmd: Uint8Array,
|
||||
chunkData?: Uint8Array
|
||||
): Promise<{response: Uint8Array, body?: ReadableStream}> {
|
||||
const block = xftpEncodeAuthTransmission(client.thParams, key, entityId, cmd)
|
||||
|
||||
const reqBody = chunkData
|
||||
? concatBytes(block, chunkData)
|
||||
: block
|
||||
|
||||
const resp = await fetch(client.baseUrl, {
|
||||
method: 'POST',
|
||||
body: reqBody,
|
||||
duplex: 'half',
|
||||
})
|
||||
|
||||
const reader = resp.body!.getReader()
|
||||
const responseBlock = await readExactly(reader, xftpBlockSize)
|
||||
const parsed = xftpDecodeTransmission(responseBlock)
|
||||
|
||||
// For FGET: remaining body is encrypted chunk
|
||||
const hasMore = await peekReader(reader)
|
||||
return {
|
||||
response: parsed,
|
||||
body: hasMore ? wrapAsStream(reader) : undefined
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Architecture: Environment Abstraction
|
||||
|
||||
### 3.1 Core Principle
|
||||
|
||||
**Existing crypto functions remain unchanged.** The functions `encryptFile()`, `decryptChunks()`, `sha512()`, etc. in `crypto/file.ts` and `crypto/digest.ts` are pure computation — they take input bytes and produce output bytes. They have no knowledge of Workers, OPFS, or execution context.
|
||||
|
||||
The abstraction layer sits between `agent.ts` (upload/download orchestration) and these crypto functions:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ agent.ts (upload/download orchestration) │
|
||||
│ - Unchanged logic: encrypt → chunk → upload → build description │
|
||||
│ - Calls CryptoBackend interface, not crypto functions directly │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ CryptoBackend interface (env.ts) │
|
||||
│ - Abstract interface for encrypt/decrypt/readChunk/writeChunk │
|
||||
│ - Factory function selects implementation based on environment │
|
||||
├──────────────┬──────────────────────┬───────────────────────────────┤
|
||||
│ DirectMemory │ WorkerMemory │ WorkerOPFS │
|
||||
│ Backend │ Backend │ Backend │
|
||||
│ (Node.js) │ (Browser, ≤50MB) │ (Browser, >50MB) │
|
||||
├──────────────┼──────────────────────┼───────────────────────────────┤
|
||||
│ Calls crypto │ Posts to Worker, │ Posts to Worker, │
|
||||
│ functions │ Worker calls crypto │ Worker calls crypto, │
|
||||
│ directly │ functions, returns │ streams through OPFS │
|
||||
│ │ via postMessage │ │
|
||||
├──────────────┴──────────────────────┴───────────────────────────────┤
|
||||
│ crypto/file.ts, crypto/digest.ts (unchanged) │
|
||||
│ - encryptFile(), decryptChunks(), sha512(), etc. │
|
||||
│ - Pure functions, no environment dependencies │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 CryptoBackend Interface
|
||||
|
||||
```typescript
|
||||
// env.ts
|
||||
export interface CryptoBackend {
|
||||
// Encrypt file, store result (in memory or OPFS depending on backend)
|
||||
encrypt(
|
||||
data: Uint8Array,
|
||||
fileName: string,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<EncryptResult>
|
||||
|
||||
// Decrypt from stored encrypted data
|
||||
decrypt(
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
size: number,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<DecryptResult>
|
||||
|
||||
// Read chunk from stored encrypted data (for upload)
|
||||
readChunk(offset: number, size: number): Promise<Uint8Array>
|
||||
|
||||
// Write chunk to storage (for download, before decrypt)
|
||||
writeChunk(data: Uint8Array, offset: number): Promise<void>
|
||||
|
||||
// Clean up temporary storage
|
||||
cleanup(): Promise<void>
|
||||
}
|
||||
|
||||
export interface EncryptResult {
|
||||
digest: Uint8Array // SHA-512 of encrypted data
|
||||
key: Uint8Array // Generated encryption key
|
||||
nonce: Uint8Array // Generated nonce
|
||||
chunkSizes: number[] // Chunk sizes for upload
|
||||
totalSize: number // Total encrypted size
|
||||
}
|
||||
|
||||
export interface DecryptResult {
|
||||
header: FileHeader // Extracted file header (fileName, etc.)
|
||||
content: Uint8Array // Decrypted file content
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Backend Implementations
|
||||
|
||||
**DirectMemoryBackend** (Node.js):
|
||||
```typescript
|
||||
class DirectMemoryBackend implements CryptoBackend {
|
||||
private encryptedData: Uint8Array | null = null
|
||||
|
||||
async encrypt(data: Uint8Array, fileName: string, onProgress?): Promise<EncryptResult> {
|
||||
const key = randomBytes(32)
|
||||
const nonce = randomBytes(24)
|
||||
// Call existing crypto function directly
|
||||
this.encryptedData = encryptFile(data, fileName, key, nonce, onProgress)
|
||||
const digest = sha512(this.encryptedData)
|
||||
const chunkSizes = prepareChunkSizes(this.encryptedData.length)
|
||||
return { digest, key, nonce, chunkSizes, totalSize: this.encryptedData.length }
|
||||
}
|
||||
|
||||
async decrypt(key, nonce, size, onProgress): Promise<DecryptResult> {
|
||||
// Call existing crypto function directly
|
||||
return decryptChunks([this.encryptedData!], key, nonce, size, onProgress)
|
||||
}
|
||||
|
||||
async readChunk(offset: number, size: number): Promise<Uint8Array> {
|
||||
return this.encryptedData!.slice(offset, offset + size)
|
||||
}
|
||||
|
||||
async writeChunk(data: Uint8Array, offset: number): Promise<void> {
|
||||
if (!this.encryptedData) this.encryptedData = new Uint8Array(offset + data.length)
|
||||
this.encryptedData.set(data, offset)
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.encryptedData = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**WorkerMemoryBackend** and **WorkerOPFSBackend** are similar but post messages to a Web Worker instead of calling crypto directly. The Worker then calls the same `encryptFile()`, `decryptChunks()` functions. See §4 for Worker implementation details.
|
||||
|
||||
### 3.4 Factory Function
|
||||
|
||||
```typescript
|
||||
// env.ts
|
||||
export function createCryptoBackend(fileSize: number): CryptoBackend {
|
||||
const hasWorker = typeof Worker !== 'undefined'
|
||||
const hasOPFS = typeof navigator?.storage?.getDirectory !== 'undefined'
|
||||
const isLargeFile = fileSize > 50 * 1024 * 1024
|
||||
|
||||
if (hasWorker && hasOPFS && isLargeFile) {
|
||||
return new WorkerOPFSBackend() // Browser + large file
|
||||
} else if (hasWorker) {
|
||||
return new WorkerMemoryBackend() // Browser + small file
|
||||
} else {
|
||||
return new DirectMemoryBackend() // Node.js
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Usage in agent.ts
|
||||
|
||||
```typescript
|
||||
// agent.ts - upload orchestration (simplified)
|
||||
export async function uploadFile(
|
||||
server: XFTPServer,
|
||||
fileData: Uint8Array,
|
||||
fileName: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<string> {
|
||||
// Create backend based on environment
|
||||
const backend = createCryptoBackend(fileData.length)
|
||||
|
||||
try {
|
||||
// Encrypt (runs in Worker in browser, directly in Node)
|
||||
const enc = await backend.encrypt(fileData, fileName, onProgress)
|
||||
|
||||
// Upload chunks (same code regardless of backend)
|
||||
const client = await connectXFTP(server)
|
||||
const sentChunks = []
|
||||
let offset = 0
|
||||
for (const size of enc.chunkSizes) {
|
||||
const chunk = await backend.readChunk(offset, size)
|
||||
const sent = await uploadChunk(client, chunk, enc.digest)
|
||||
sentChunks.push(sent)
|
||||
offset += size
|
||||
}
|
||||
|
||||
// Build description and URI
|
||||
const fd = buildFileDescription(enc, sentChunks)
|
||||
return encodeFileDescriptionURI(fd)
|
||||
} finally {
|
||||
await backend.cleanup()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key point: `uploadFile()` logic is identical regardless of whether crypto runs in a Worker or directly. The `CryptoBackend` abstraction hides that detail.
|
||||
|
||||
### 3.6 Why This Matters for Testing
|
||||
|
||||
- **Layer 1 tests** (per-function): Call `encryptFile()`, `decryptChunks()` directly via Node — unchanged
|
||||
- **Layer 2 tests** (full flow): Call `uploadFile()`, `downloadFile()` in Node — uses `DirectMemoryBackend`, same code path as browser except for Worker
|
||||
- **Layer 3 tests** (browser): Call `uploadFile()`, `downloadFile()` in Playwright — uses `WorkerMemoryBackend` or `WorkerOPFSBackend`
|
||||
|
||||
All three layers exercise the same crypto functions. The only difference is execution context.
|
||||
|
||||
## 4. Web Worker Implementation
|
||||
|
||||
### 4.1 Why Web Worker
|
||||
|
||||
File encryption (XSalsa20-Poly1305) is sequential and CPU-bound:
|
||||
- 100 MB file ≈ 1-2 seconds of continuous computation
|
||||
- Running on main thread blocks UI (no progress updates, frozen page)
|
||||
- Chunking into async microtasks adds complexity and still causes jank
|
||||
|
||||
Web Worker runs crypto in parallel thread. Main thread stays responsive.
|
||||
|
||||
### 4.2 Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Main Thread │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ UI (upload/ │ │ Progress │ │ Network (fetch) │ │
|
||||
│ │ download) │ │ display │ │ │ │
|
||||
│ └──────┬──────┘ └──────▲──────┘ └──────────▲──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ postMessage │ progress │ encrypted │
|
||||
│ ▼ │ events │ chunks │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Web Worker │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ Crypto Pipeline ││
|
||||
│ │ - encryptFile() with progress callbacks ││
|
||||
│ │ - decryptChunks() with progress callbacks ││
|
||||
│ │ - OPFS read/write for temp storage ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 Message Protocol
|
||||
|
||||
**Main → Worker:**
|
||||
|
||||
```typescript
|
||||
type WorkerRequest =
|
||||
// Encrypt file, store result in OPFS (large) or memory (small)
|
||||
| {type: 'encrypt', file: File, fileName: string, useOPFS: boolean}
|
||||
// Read encrypted chunk from OPFS for upload
|
||||
| {type: 'readChunk', offset: number, size: number}
|
||||
// Write downloaded chunk to OPFS for later decryption
|
||||
| {type: 'writeChunk', data: ArrayBuffer, offset: number}
|
||||
// Decrypt from OPFS or provided chunks
|
||||
| {type: 'decrypt', key: Uint8Array, nonce: Uint8Array, size: number, chunks?: ArrayBuffer[]}
|
||||
// Delete OPFS temp files
|
||||
| {type: 'cleanup'}
|
||||
| {type: 'cancel'}
|
||||
```
|
||||
|
||||
**Worker → Main:**
|
||||
|
||||
```typescript
|
||||
type WorkerResponse =
|
||||
| {type: 'progress', phase: 'encrypt' | 'decrypt', done: number, total: number}
|
||||
// For OPFS: encData is empty, data lives in OPFS temp file
|
||||
| {type: 'encrypted', encData: ArrayBuffer | null, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array, chunkSizes: number[]}
|
||||
| {type: 'chunk', data: ArrayBuffer} // Response to readChunk
|
||||
| {type: 'chunkWritten'} // Response to writeChunk
|
||||
| {type: 'decrypted', header: FileHeader, content: ArrayBuffer}
|
||||
| {type: 'cleaned'} // Response to cleanup
|
||||
| {type: 'error', message: string}
|
||||
```
|
||||
|
||||
### 4.4 Worker Implementation
|
||||
|
||||
```typescript
|
||||
// crypto.worker.ts
|
||||
import {encryptFile, encryptFileStreaming, decryptChunks, decryptFromOPFS} from './crypto/file.js'
|
||||
import {sha512} from './crypto/digest.js'
|
||||
import {prepareChunkSizes} from './protocol/chunks.js'
|
||||
|
||||
let opfsHandle: FileSystemSyncAccessHandle | null = null
|
||||
|
||||
self.onmessage = async (e: MessageEvent<WorkerRequest>) => {
|
||||
const req = e.data
|
||||
|
||||
if (req.type === 'encrypt') {
|
||||
const key = crypto.getRandomValues(new Uint8Array(32))
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(24))
|
||||
|
||||
if (req.useOPFS) {
|
||||
// Large file: stream through OPFS to avoid memory pressure
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const fileHandle = await root.getFileHandle('encrypted-temp', {create: true})
|
||||
opfsHandle = await fileHandle.createSyncAccessHandle()
|
||||
|
||||
// Stream encrypt: read 64KB from File, encrypt, write to OPFS
|
||||
const digest = await encryptFileStreaming(
|
||||
req.file,
|
||||
req.fileName,
|
||||
key,
|
||||
nonce,
|
||||
opfsHandle,
|
||||
(done, total) => self.postMessage({type: 'progress', phase: 'encrypt', done, total})
|
||||
)
|
||||
|
||||
const encSize = opfsHandle.getSize()
|
||||
const chunkSizes = prepareChunkSizes(encSize)
|
||||
|
||||
self.postMessage({
|
||||
type: 'encrypted',
|
||||
encData: null, // Data in OPFS, not memory
|
||||
digest, key, nonce, chunkSizes
|
||||
})
|
||||
} else {
|
||||
// Small file: in-memory is fine
|
||||
const source = new Uint8Array(await req.file.arrayBuffer())
|
||||
const encData = encryptFile(source, req.fileName, key, nonce, (done, total) => {
|
||||
self.postMessage({type: 'progress', phase: 'encrypt', done, total})
|
||||
})
|
||||
|
||||
const digest = sha512(encData)
|
||||
const chunkSizes = prepareChunkSizes(encData.length)
|
||||
|
||||
self.postMessage({
|
||||
type: 'encrypted',
|
||||
encData: encData.buffer,
|
||||
digest, key, nonce, chunkSizes
|
||||
}, [encData.buffer])
|
||||
}
|
||||
}
|
||||
|
||||
if (req.type === 'readChunk') {
|
||||
// Read chunk from OPFS for upload
|
||||
const chunk = new Uint8Array(req.size)
|
||||
opfsHandle!.read(chunk, {at: req.offset})
|
||||
self.postMessage({type: 'chunk', data: chunk.buffer}, [chunk.buffer])
|
||||
}
|
||||
|
||||
if (req.type === 'writeChunk') {
|
||||
// Write downloaded chunk to OPFS
|
||||
if (!opfsHandle) {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const fileHandle = await root.getFileHandle('download-temp', {create: true})
|
||||
opfsHandle = await fileHandle.createSyncAccessHandle()
|
||||
}
|
||||
opfsHandle.write(new Uint8Array(req.data), {at: req.offset})
|
||||
self.postMessage({type: 'chunkWritten'})
|
||||
}
|
||||
|
||||
if (req.type === 'decrypt') {
|
||||
let result
|
||||
if (req.chunks) {
|
||||
// Small file: chunks provided in memory
|
||||
const chunks = req.chunks.map(b => new Uint8Array(b))
|
||||
result = decryptChunks(chunks, req.key, req.nonce, req.size, (done, total) => {
|
||||
self.postMessage({type: 'progress', phase: 'decrypt', done, total})
|
||||
})
|
||||
} else {
|
||||
// Large file: read from OPFS
|
||||
result = decryptFromOPFS(opfsHandle!, req.key, req.nonce, req.size, (done, total) => {
|
||||
self.postMessage({type: 'progress', phase: 'decrypt', done, total})
|
||||
})
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: 'decrypted',
|
||||
header: result.header,
|
||||
content: result.content.buffer
|
||||
}, [result.content.buffer])
|
||||
}
|
||||
|
||||
if (req.type === 'cleanup') {
|
||||
if (opfsHandle) {
|
||||
opfsHandle.close()
|
||||
opfsHandle = null
|
||||
}
|
||||
const root = await navigator.storage.getDirectory()
|
||||
try { await root.removeEntry('encrypted-temp') } catch {}
|
||||
try { await root.removeEntry('download-temp') } catch {}
|
||||
self.postMessage({type: 'cleaned'})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Main Thread Wrapper
|
||||
|
||||
```typescript
|
||||
// crypto-worker.ts (main thread)
|
||||
export class CryptoWorker {
|
||||
private worker: Worker
|
||||
private pending: Map<string, {resolve: Function, reject: Function}> = new Map()
|
||||
private onProgress?: (done: number, total: number) => void
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL('./crypto.worker.js', import.meta.url), {type: 'module'})
|
||||
this.worker.onmessage = (e) => this.handleMessage(e.data)
|
||||
}
|
||||
|
||||
async encrypt(file: File, onProgress?: (done: number, total: number) => void): Promise<EncryptedFileInfo> {
|
||||
const useOPFS = file.size > 50 * 1024 * 1024 // 50 MB threshold
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set('encrypt', {resolve, reject})
|
||||
this.onProgress = onProgress
|
||||
this.worker.postMessage({type: 'encrypt', file, fileName: file.name, useOPFS})
|
||||
})
|
||||
}
|
||||
|
||||
async decrypt(
|
||||
chunks: Uint8Array[],
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
size: number,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<DownloadResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set('decrypt', {resolve, reject})
|
||||
this.onProgress = onProgress
|
||||
this.worker.postMessage({
|
||||
type: 'decrypt',
|
||||
chunks: chunks.map(c => c.buffer),
|
||||
key, nonce, size
|
||||
}, chunks.map(c => c.buffer))
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: WorkerResponse) {
|
||||
if (msg.type === 'progress') {
|
||||
this.onProgress?.(msg.done, msg.total)
|
||||
} else if (msg.type === 'encrypted') {
|
||||
this.pending.get('encrypt')?.resolve({
|
||||
encData: msg.encData ? new Uint8Array(msg.encData) : null, // null when using OPFS
|
||||
digest: msg.digest,
|
||||
key: msg.key,
|
||||
nonce: msg.nonce,
|
||||
chunkSizes: msg.chunkSizes
|
||||
})
|
||||
} else if (msg.type === 'decrypted') {
|
||||
this.pending.get('decrypt')?.resolve({
|
||||
header: msg.header,
|
||||
content: new Uint8Array(msg.content)
|
||||
})
|
||||
} else if (msg.type === 'error') {
|
||||
// Reject all pending
|
||||
for (const p of this.pending.values()) p.reject(new Error(msg.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. OPFS Implementation
|
||||
|
||||
### 5.1 Purpose
|
||||
|
||||
For files approaching 100 MB, holding encrypted data in memory while uploading creates memory pressure. OPFS provides temporary file storage:
|
||||
- Write encrypted data to OPFS as it's generated
|
||||
- Read chunks from OPFS for upload
|
||||
- Delete after upload completes
|
||||
|
||||
### 5.2 When to Use
|
||||
|
||||
- Files > 50 MB: Use OPFS
|
||||
- Files ≤ 50 MB: In-memory (simpler, no OPFS overhead)
|
||||
|
||||
Threshold is configurable.
|
||||
|
||||
### 5.3 OPFS API
|
||||
|
||||
```typescript
|
||||
// In Web Worker (synchronous API for performance)
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const fileHandle = await root.getFileHandle('encrypted-temp', {create: true})
|
||||
const accessHandle = await fileHandle.createSyncAccessHandle()
|
||||
|
||||
// Write encrypted chunks as they're generated
|
||||
accessHandle.write(encryptedChunk, {at: offset})
|
||||
|
||||
// Read chunk for upload
|
||||
const chunk = new Uint8Array(chunkSize)
|
||||
accessHandle.read(chunk, {at: chunkOffset})
|
||||
|
||||
// Cleanup
|
||||
accessHandle.close()
|
||||
await root.removeEntry('encrypted-temp')
|
||||
```
|
||||
|
||||
### 5.4 Upload Flow with OPFS
|
||||
|
||||
```
|
||||
1. Main: user drops file
|
||||
2. Main → Worker: {type: 'encrypt', file}
|
||||
3. Worker:
|
||||
- Create OPFS temp file
|
||||
- Encrypt 64KB at a time, write to OPFS
|
||||
- Post progress every 64KB
|
||||
- Compute digest
|
||||
- Return {digest, key, nonce, chunkSizes} (data stays in OPFS)
|
||||
4. Main: for each chunk:
|
||||
- Main → Worker: {type: 'readChunk', offset, size}
|
||||
- Worker: read from OPFS, return chunk
|
||||
- Main: upload chunk via fetch()
|
||||
5. Main → Worker: {type: 'cleanup'}
|
||||
6. Worker: delete OPFS temp file
|
||||
```
|
||||
|
||||
### 5.5 Download Flow with OPFS
|
||||
|
||||
```
|
||||
1. Main: parse URL, get FileDescription
|
||||
2. Main: for each chunk:
|
||||
- Download via fetch()
|
||||
- Main → Worker: {type: 'writeChunk', data, offset}
|
||||
- Worker: write to OPFS temp file
|
||||
3. Main → Worker: {type: 'decrypt', key, nonce, size}
|
||||
4. Worker:
|
||||
- Read from OPFS
|
||||
- Decrypt, verify auth tag
|
||||
- Return {header, content}
|
||||
5. Main: trigger browser download
|
||||
6. Main → Worker: {type: 'cleanup'}
|
||||
```
|
||||
|
||||
## 6. Implementation Plan
|
||||
|
||||
### 6.1 Phase A: fetch() Transport
|
||||
|
||||
**Goal:** Replace `node:http2` with `fetch()` in `client.ts`. All existing Node.js tests pass.
|
||||
|
||||
1. Rewrite `connectXFTP()` to use fetch() for handshake
|
||||
2. Rewrite `sendXFTPCommand()` to use fetch()
|
||||
3. Update `createXFTPChunk`, `uploadXFTPChunk`, `downloadXFTPChunk`, etc.
|
||||
4. Remove `node:http2` import
|
||||
5. Run existing Haskell integration tests — must pass
|
||||
|
||||
**Files:** `client.ts`
|
||||
|
||||
### 6.2 Phase B: Environment Abstraction + Web Worker
|
||||
|
||||
**Goal:** Add `CryptoBackend` abstraction (§3) so the same code works in Node (direct) and browser (Worker).
|
||||
|
||||
1. Create `env.ts` with `CryptoBackend` interface and `createCryptoBackend()` factory (as specified in §3)
|
||||
2. Implement `DirectMemoryBackend` for Node.js
|
||||
3. Create `crypto.worker.ts` that imports and calls existing crypto functions
|
||||
4. Implement `WorkerMemoryBackend` for browser
|
||||
5. Update `agent.ts` to use `createCryptoBackend()` instead of direct crypto calls
|
||||
6. Existing tests pass (now using `DirectMemoryBackend`)
|
||||
|
||||
**Files:** `env.ts`, `crypto.worker.ts`, `agent.ts`
|
||||
|
||||
### 6.3 Phase C: OPFS Backend
|
||||
|
||||
**Goal:** Large files (>50 MB) use OPFS for temp storage in browser.
|
||||
|
||||
1. Implement `WorkerOPFSBackend` — uses OPFS sync API in worker
|
||||
2. Add OPFS helpers in worker: read/write to temp file
|
||||
3. Factory function now returns `WorkerOPFSBackend` for large files
|
||||
4. Same `agent.ts` code works — only backend implementation differs
|
||||
|
||||
**Files:** `env.ts`, `crypto.worker.ts`
|
||||
|
||||
### 6.4 Phase D: Browser Testing
|
||||
|
||||
**Goal:** Verify everything works in real browsers.
|
||||
|
||||
1. Create minimal test HTML page
|
||||
2. Test upload flow in Chrome, Firefox, Safari
|
||||
3. Test download flow
|
||||
4. Test progress reporting
|
||||
5. Test cancellation
|
||||
6. Test error handling (network failure, invalid file)
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Test Layers
|
||||
|
||||
The `CryptoBackend` abstraction (§3) enables testing at multiple levels without code duplication:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 3: Browser Integration (Playwright) │
|
||||
│ - Web Worker message passing │
|
||||
│ - OPFS read/write │
|
||||
│ - Progress UI updates │
|
||||
│ - Real browser fetch() with CORS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Layer 2: Full Flow (Haskell-driven, Node.js) │
|
||||
│ - fetch() transport against real xftp-server │
|
||||
│ - Upload: encrypt → chunk → upload → build description │
|
||||
│ - Download: parse → download → verify → decrypt │
|
||||
│ - Cross-language: TS upload ↔ Haskell download (and vice versa) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Layer 1: Per-Function (Haskell-driven, Node.js) │
|
||||
│ - 172 existing tests │
|
||||
│ - Byte-identical output vs Haskell functions │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 Layer 1: Per-Function Tests (Existing)
|
||||
|
||||
Existing Haskell-driven tests in `XFTPWebTests.hs`. Each test calls a TypeScript function via Node and compares output with Haskell.
|
||||
|
||||
```bash
|
||||
cabal test --ghc-options -O0 --test-option='--match=/XFTP Web Client/'
|
||||
```
|
||||
|
||||
All 172 tests must pass. No changes needed for browser transport work.
|
||||
|
||||
### 7.3 Layer 2: Full Flow Tests (Node.js + fetch)
|
||||
|
||||
Haskell-driven integration tests using Node.js native fetch(). These test the complete upload/download flow without Worker/OPFS.
|
||||
|
||||
```haskell
|
||||
-- XFTPWebTests.hs (extends existing test file)
|
||||
it "fetch transport: upload and download round-trip" $ do
|
||||
withXFTPServer testXFTPServerConfigSNI $ \server -> do
|
||||
-- TypeScript uploads via fetch(), returns URI
|
||||
uri <- jsOut $ callTS "src/agent" "uploadFileTest" serverAddrHex <> testFileHex
|
||||
-- TypeScript downloads via fetch()
|
||||
content <- jsOut $ callTS "src/agent" "downloadFileTest" uriHex
|
||||
content `shouldBe` testFileContent
|
||||
|
||||
it "fetch transport: TS upload, Haskell download" $ do
|
||||
withXFTPServer testXFTPServerConfigSNI $ \server -> do
|
||||
uri <- jsOut $ callTS "src/agent" "uploadFileTest" serverAddrHex <> testFileHex
|
||||
-- Haskell agent downloads using existing xftp CLI pattern
|
||||
outPath <- withAgent 1 agentCfg initAgentServers testDB $ \a -> do
|
||||
rfId <- xftpReceiveFile' a 1 uri Nothing
|
||||
waitRfDone a
|
||||
content <- B.readFile outPath
|
||||
content `shouldBe` testFileContent
|
||||
```
|
||||
|
||||
**What this tests:**
|
||||
- fetch() handshake (challenge-response, TLS session binding)
|
||||
- fetch() command execution (FNEW, FPUT, FGET, FACK)
|
||||
- Streaming request/response bodies
|
||||
- Full encrypt → upload → download → decrypt flow
|
||||
|
||||
**What this doesn't test:**
|
||||
- Web Worker message passing
|
||||
- OPFS storage
|
||||
- Browser-specific fetch() behavior (CORS preflight, etc.)
|
||||
|
||||
### 7.4 Layer 3: Browser Integration Tests (Playwright)
|
||||
|
||||
Playwright tests run in real browsers, testing browser-specific functionality.
|
||||
|
||||
**Test infrastructure:**
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── test/
|
||||
│ ├── browser.test.ts # Playwright test file
|
||||
│ └── test-server.ts # Spawns xftp-server for tests
|
||||
└── test-page/
|
||||
├── index.html # Minimal test UI
|
||||
└── test-harness.ts # Exposes test functions to window
|
||||
```
|
||||
|
||||
**Running browser tests:**
|
||||
|
||||
```bash
|
||||
cd xftp-web
|
||||
npm run test:browser # Spawns xftp-server, runs Playwright
|
||||
```
|
||||
|
||||
**Test cases:**
|
||||
|
||||
```typescript
|
||||
// test/browser.test.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
let serverProcess: ChildProcess
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Spawn xftp-server with SNI cert for browser TLS
|
||||
serverProcess = spawn('xftp-server', ['start', '-c', 'test-config.ini'])
|
||||
await waitForServer()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
serverProcess.kill()
|
||||
})
|
||||
|
||||
test('small file upload/download (in-memory)', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const data = new Uint8Array(1024 * 1024) // 1 MB
|
||||
crypto.getRandomValues(data)
|
||||
const file = new File([data], 'small.bin')
|
||||
|
||||
const uri = await window.xftp.uploadFile(file)
|
||||
const downloaded = await window.xftp.downloadFile(uri)
|
||||
|
||||
return {
|
||||
uploadedSize: data.length,
|
||||
downloadedSize: downloaded.length,
|
||||
match: arraysEqual(data, downloaded),
|
||||
usedOPFS: window.xftp.lastUploadUsedOPFS
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.match).toBe(true)
|
||||
expect(result.usedOPFS).toBe(false) // Small file, no OPFS
|
||||
})
|
||||
|
||||
test('large file upload/download (OPFS)', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const data = new Uint8Array(60 * 1024 * 1024) // 60 MB
|
||||
crypto.getRandomValues(data)
|
||||
const file = new File([data], 'large.bin')
|
||||
|
||||
const uri = await window.xftp.uploadFile(file)
|
||||
const downloaded = await window.xftp.downloadFile(uri)
|
||||
|
||||
return {
|
||||
match: arraysEqual(data, downloaded),
|
||||
usedOPFS: window.xftp.lastUploadUsedOPFS
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.match).toBe(true)
|
||||
expect(result.usedOPFS).toBe(true) // Large file, used OPFS
|
||||
})
|
||||
|
||||
test('progress events fire during upload', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
const progressEvents = await page.evaluate(async () => {
|
||||
const events: number[] = []
|
||||
const data = new Uint8Array(10 * 1024 * 1024) // 10 MB
|
||||
const file = new File([data], 'progress.bin')
|
||||
|
||||
await window.xftp.uploadFile(file, (done, total) => {
|
||||
events.push(done / total)
|
||||
})
|
||||
|
||||
return events
|
||||
})
|
||||
|
||||
expect(progressEvents.length).toBeGreaterThan(1)
|
||||
expect(progressEvents[progressEvents.length - 1]).toBe(1) // 100% at end
|
||||
})
|
||||
|
||||
test('Web Worker keeps UI responsive', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
// Start upload and measure main thread responsiveness
|
||||
const result = await page.evaluate(async () => {
|
||||
const data = new Uint8Array(50 * 1024 * 1024) // 50 MB
|
||||
const file = new File([data], 'responsive.bin')
|
||||
|
||||
let frameCount = 0
|
||||
let uploadDone = false
|
||||
|
||||
// Count animation frames during upload
|
||||
function countFrames() {
|
||||
frameCount++
|
||||
if (!uploadDone) requestAnimationFrame(countFrames)
|
||||
}
|
||||
requestAnimationFrame(countFrames)
|
||||
|
||||
const start = performance.now()
|
||||
await window.xftp.uploadFile(file)
|
||||
uploadDone = true
|
||||
const elapsed = performance.now() - start
|
||||
|
||||
// If main thread was blocked, frameCount would be very low
|
||||
const expectedFrames = (elapsed / 1000) * 30 // ~30 fps minimum
|
||||
return { frameCount, expectedFrames, elapsed }
|
||||
})
|
||||
|
||||
// Should maintain reasonable frame rate (Worker offloaded crypto)
|
||||
expect(result.frameCount).toBeGreaterThan(result.expectedFrames * 0.5)
|
||||
})
|
||||
```
|
||||
|
||||
### 7.5 Cross-Browser Matrix
|
||||
|
||||
| Browser | fetch streaming | Web Worker | OPFS sync | Status |
|
||||
|---------|----------------|------------|-----------|--------|
|
||||
| Chrome 105+ | ✓ | ✓ | ✓ | Primary target |
|
||||
| Firefox 111+ | ✓ | ✓ | ✓ | Supported |
|
||||
| Safari 16.4+ | ✓ | ✓ | ✓ | Supported |
|
||||
| Edge 105+ | ✓ | ✓ | ✓ | Supported (Chromium) |
|
||||
|
||||
Playwright tests run against Chrome by default. CI can run against all browsers.
|
||||
|
||||
### 7.6 Test Execution Summary
|
||||
|
||||
| Phase | Test Layer | Command | What's Verified |
|
||||
|-------|-----------|---------|-----------------|
|
||||
| A | Layer 1 + 2 | `cabal test --test-option='--match=/XFTP Web Client/'` | fetch() transport, full flow |
|
||||
| B | Layer 3 | `npm run test:browser` | Worker message passing, progress |
|
||||
| C | Layer 3 | `npm run test:browser` | OPFS storage for large files |
|
||||
| D | Layer 3 | `npm run test:browser -- --project=firefox,webkit` | Cross-browser |
|
||||
@@ -0,0 +1,772 @@
|
||||
# Send File Web Page — Implementation Plan
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Architecture
|
||||
3. CryptoBackend & Web Worker
|
||||
4. Server Configuration
|
||||
5. Page Structure & UI
|
||||
6. Upload Flow
|
||||
7. Download Flow
|
||||
8. Build & Dev Setup
|
||||
9. agent.ts Changes
|
||||
10. Testing
|
||||
11. Files
|
||||
12. Implementation Order
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Build a static web page for browser-based XFTP file transfer (Phase 5 of master RFC). The page supports upload (drag-drop → encrypt → upload → shareable link) and download (open link → download → decrypt → save). Crypto runs in a Web Worker; large files use OPFS temp storage.
|
||||
|
||||
Two build variants:
|
||||
- **Local**: single test server at `localhost:7000` (development/testing)
|
||||
- **Production**: 12 preset XFTP servers (6 SimpleX + 6 Flux)
|
||||
|
||||
Uses Vite for bundling (already a dependency via vitest). No CSS framework — plain CSS per RFC spec.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── src/ # Library (existing, targeted changes)
|
||||
│ ├── agent.ts # Modified: uploadFile readChunk, downloadFileRaw
|
||||
│ ├── client.ts # Modified: downloadXFTPChunkRaw
|
||||
│ ├── crypto/ # Unchanged
|
||||
│ ├── download.ts # Unchanged
|
||||
│ └── protocol/
|
||||
│ └── description.ts # Fix: SHA-256 → SHA-512 comment on digest field
|
||||
├── web/ # Web page (new)
|
||||
│ ├── index.html # Entry point (CSP meta tag)
|
||||
│ ├── main.ts # Router + sodium.ready init
|
||||
│ ├── upload.ts # Upload UI + orchestration
|
||||
│ ├── download.ts # Download UI + orchestration
|
||||
│ ├── progress.ts # Circular progress canvas component
|
||||
│ ├── servers.ts # Server list (build-time configured, imports servers.json)
|
||||
│ ├── servers.json # Preset server addresses (shared with vite.config.ts)
|
||||
│ ├── crypto-backend.ts # CryptoBackend interface + WorkerBackend
|
||||
│ ├── crypto.worker.ts # Web Worker: encrypt/decrypt/OPFS
|
||||
│ └── style.css # Minimal styling
|
||||
├── vite.config.ts # Page build config (new)
|
||||
├── tsconfig.web.json # IDE/CI type-check for web/ (new)
|
||||
├── tsconfig.worker.json # IDE/CI type-check for worker (new)
|
||||
├── playwright.config.ts # Page E2E test config (new)
|
||||
├── vitest.config.ts # Test config (existing)
|
||||
├── .gitignore # Existing (add dist-web/)
|
||||
└── test/ # Tests (existing + new page test)
|
||||
```
|
||||
|
||||
Data flow:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────┐
|
||||
│ Main Thread │
|
||||
│ │
|
||||
│ Upload: upload.ts ──► agent.ts ──► fetch()│
|
||||
│ Download: download.ts ──► agent.ts ──► fetch()
|
||||
│ │ │
|
||||
│ postMessage HTTP/2 │
|
||||
│ ▼ ▼
|
||||
│ ┌─────────────────┐ ┌──────────┐│
|
||||
│ │ Web Worker │ │ XFTP ││
|
||||
│ │ crypto.worker.ts │ │ Server ││
|
||||
│ │ ┌─────────────┐ │ └──────────┘│
|
||||
│ │ │ OPFS temp │ │ │
|
||||
│ │ └─────────────┘ │ │
|
||||
│ └─────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Both upload and download use `agent.ts` for orchestration (connection pooling, parallel chunk transfers, redirect handling). Upload uses a `readChunk` callback for Worker data access. Download uses an `onRawChunk` callback to route raw encrypted chunks to the Worker for decryption (see §7.2). ACK is the caller's responsibility — `downloadFileRaw` returns the resolved `FileDescription` without ACKing, so the caller can verify integrity before acknowledging.
|
||||
|
||||
## 3. CryptoBackend & Web Worker
|
||||
|
||||
### 3.1 Interface
|
||||
|
||||
```typescript
|
||||
// crypto-backend.ts
|
||||
export interface CryptoBackend {
|
||||
// Upload: encrypt file, store encrypted data in OPFS
|
||||
encrypt(data: Uint8Array, fileName: string,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<EncryptResult>
|
||||
|
||||
// Upload: read encrypted chunk from OPFS (called by agent.ts via readChunk callback)
|
||||
readChunk(offset: number, size: number): Promise<Uint8Array>
|
||||
|
||||
// Download: transit-decrypt raw chunk and store in OPFS
|
||||
decryptAndStoreChunk(
|
||||
dhSecret: Uint8Array, nonce: Uint8Array,
|
||||
body: Uint8Array, digest: Uint8Array, chunkNo: number
|
||||
): Promise<void>
|
||||
|
||||
// Download: verify digest + file-level decrypt all stored chunks
|
||||
// Only needs size/digest/key/nonce — not the full FileDescription (avoids sending private keys to Worker)
|
||||
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
|
||||
): Promise<{header: FileHeader, content: Uint8Array}>
|
||||
|
||||
cleanup(): Promise<void>
|
||||
}
|
||||
|
||||
// Structurally identical to EncryptedFileMetadata from agent.ts (§9.1).
|
||||
// Kept separate to avoid crypto-backend.ts importing from agent.ts
|
||||
// (which would pull in node:http2 via client.ts, breaking Worker bundling).
|
||||
// TypeScript structural typing makes them assignment-compatible.
|
||||
export interface EncryptResult {
|
||||
digest: Uint8Array
|
||||
key: Uint8Array
|
||||
nonce: Uint8Array
|
||||
chunkSizes: number[]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Factory
|
||||
|
||||
```typescript
|
||||
export function createCryptoBackend(): CryptoBackend {
|
||||
if (typeof Worker === 'undefined') {
|
||||
throw new Error('Web Workers required — update your browser')
|
||||
}
|
||||
return new WorkerBackend()
|
||||
}
|
||||
```
|
||||
|
||||
The Worker always uses OPFS for temp storage (single code path — no memory/disk branching). OPFS I/O overhead is negligible relative to crypto and network time. Each Worker session creates a unique directory in OPFS root named `session-<Date.now()>-<crypto.randomUUID()>`, containing `upload.bin` and `download.bin` as needed. `cleanup()` deletes the entire session directory. On Worker startup (before processing messages), sweep OPFS root and delete any `session-*` directories whose embedded timestamp (parsed from the name) is older than 1 hour — this handles stale files from crashed tabs. The OPFS API does not expose directory timestamps, so the name-encoded timestamp is the only reliable mechanism. This prevents cross-tab collisions and unbounded OPFS growth.
|
||||
|
||||
### 3.3 Worker message protocol
|
||||
|
||||
Every request carries a numeric `id`. Responses carry the same `id`. WorkerBackend maintains a `Map<number, {resolve, reject}>` to match responses to pending promises.
|
||||
|
||||
Main → Worker (fields marked `†` are Transferable — arrive as `ArrayBuffer` in Worker, must be wrapped with `new Uint8Array(...)` before use):
|
||||
- `{id: number, type: 'encrypt', data†: ArrayBuffer, fileName: string}` — encrypt file, store in OPFS
|
||||
- `{id: number, type: 'readChunk', offset: number, size: number}` — read encrypted chunk from OPFS
|
||||
- `{id: number, type: 'decryptAndStoreChunk', dhSecret: Uint8Array, nonce: Uint8Array, body†: ArrayBuffer, chunkDigest: Uint8Array, chunkNo: number}` — transit-decrypt + store in OPFS. `chunkDigest` is the per-chunk SHA-256 digest (verified by `decryptReceivedChunk`). Distinct from the file-level SHA-512 digest in `verifyAndDecrypt`.
|
||||
- `{id: number, type: 'verifyAndDecrypt', size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}` — verify digest + file-level decrypt all chunks. Only the four fields needed for verification/decryption are sent — not the full `FileDescription`, which contains private replica keys that the Worker doesn't need.
|
||||
- `{id: number, type: 'cleanup'}` — delete OPFS temp files
|
||||
|
||||
Worker → Main (fields marked `†` are Transferable):
|
||||
- `{id: number, type: 'progress', done: number, total: number}` — encryption/decryption progress (fire-and-forget, no promise)
|
||||
- `{id: number, type: 'encrypted', digest: Uint8Array, key: Uint8Array, nonce: Uint8Array, chunkSizes: number[]}` — all fields structured-cloned (not transferred)
|
||||
- `{id: number, type: 'chunk', data†: ArrayBuffer}` — readChunk response
|
||||
- `{id: number, type: 'stored'}` — decryptAndStore acknowledgment
|
||||
- `{id: number, type: 'decrypted', header: FileHeader, content†: ArrayBuffer}` — verifyAndDecrypt response
|
||||
- `{id: number, type: 'cleaned'}`
|
||||
- `{id: number, type: 'error', message: string}` — rejects the pending promise for this `id`
|
||||
|
||||
All messages carrying large `ArrayBuffer` payloads use `postMessage(msg, [transferables])` to transfer ownership instead of structured-clone copying. Only `ArrayBuffer` can be transferred — `Uint8Array`, `number[]`, and other types are always structured-cloned. This applies to: `encrypt` request (`data`), `readChunk` response (`data`), `decryptAndStoreChunk` request (`body`), and `verifyAndDecrypt` response (`content`). The `WorkerBackend` implementation must ensure the transferred `ArrayBuffer` covers the full `Uint8Array` — if `byteOffset !== 0` or `byteLength !== buffer.byteLength`, slice first: `data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)`. This is required for `decryptAndStore` request bodies: `sendXFTPCommand` returns `body = fullResp.subarray(XFTP_BLOCK_SIZE)`, which has `byteOffset = XFTP_BLOCK_SIZE`. Other payloads are full-buffer views (§6 step 3 creates `new Uint8Array(await file.arrayBuffer())`; Worker responses allocate fresh buffers) but `WorkerBackend` should guard unconditionally.
|
||||
|
||||
### 3.4 Worker internals
|
||||
|
||||
**Imports:** The Worker imports directly from `libsodium-wrappers-sumo` (for `await sodium.ready`), `src/crypto/file.js` (`encryptFile`, `encodeFileHeader`, `decryptChunks`), `src/crypto/digest.js` (`sha512`), `src/protocol/chunks.js` (`prepareChunkSizes`, `fileSizeLen`, `authTagSize`), `src/protocol/encoding.js` (`concatBytes`), and `src/download.js` (`decryptReceivedChunk`). `download.js` directly imports `src/protocol/client.js` (for `decryptTransportChunk`). These transitively pull in `src/crypto/secretbox.js`, `src/crypto/keys.js`, and `src/crypto/padding.js`. None of these import `src/agent.ts` or `src/client.ts` — those pull in `node:http2` via dynamic import which would break Worker bundling. Vite tree-shakes the transitive deps automatically. Note: `download.js` → `protocol/client.js` → `crypto/keys.js` transitively pulls in `@noble/curves` (~50-80KB). This is unavoidable since `decryptTransportChunk` needs `dh` from `keys.js`. If Worker bundle size becomes a concern, `decryptReceivedChunk` could be refactored out of `download.js` into a separate module that doesn't import `protocol/client.js`.
|
||||
|
||||
**ArrayBuffer → Uint8Array conversion:** All Transferable fields arrive in the Worker as `ArrayBuffer`. The Worker's message handler must wrap them before passing to library functions: `new Uint8Array(msg.data)` for encrypt, `new Uint8Array(msg.body)` for decryptAndStore. Non-transferred fields (`dhSecret`, `nonce`, `digest`, `chunkSizes`) arrive as their original types (`Uint8Array` / `number[]`) via structured clone.
|
||||
|
||||
The Worker's encrypt handler calls the same functions as `encryptFileForUpload` in agent.ts (key/nonce generation → `encryptFile` → `sha512` → `prepareChunkSizes`). This is not reimplementation — it's calling the same library functions from a different entry point.
|
||||
|
||||
**Libsodium init:** Both the Worker and the main thread must `await sodium.ready` before calling any crypto functions that use libsodium. The Worker does this once on startup before processing messages. The main thread needs it before `connectXFTP` (which uses libsodium via `verifyIdentityProof`) and before `downloadXFTPChunkRaw` (which uses libsodium via `generateX25519KeyPair` + `dh`). In practice, `main.ts` calls `await sodium.ready` at page load, before any XFTP calls.
|
||||
|
||||
Encrypt (mirrors `encryptFileForUpload` in agent.ts):
|
||||
1. Generate key (32B) + nonce (24B) via `crypto.getRandomValues`
|
||||
2. `fileHdr = encodeFileHeader({fileName, fileExtra: null})`
|
||||
3. `fileSize = BigInt(fileHdr.length + source.length)`
|
||||
4. `payloadSize = Number(fileSize) + fileSizeLen + authTagSize`
|
||||
5. `chunkSizes = prepareChunkSizes(payloadSize)`
|
||||
6. `encSize = BigInt(chunkSizes.reduce((a, b) => a + b, 0))`
|
||||
7. `encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)`
|
||||
8. `digest = sha512(encData)` — note: the `digest` field comment in `FileDescription` in `description.ts` says "SHA-256" but the actual hash is SHA-512 everywhere (`sha512` in agent.ts and download.ts). Fix the comment during implementation.
|
||||
9. Open OPFS upload file via `createSyncAccessHandle`, write `encData`, flush, close handle. Null out `encData` reference.
|
||||
10. Reopen the same OPFS file with `createSyncAccessHandle` as a persistent read handle (stored on the Worker module scope). This handle is used by all subsequent `readChunk` calls and closed on `cleanup`.
|
||||
11. Post back `{digest, key, nonce, chunkSizes}` (no encData transfer — data stays in OPFS)
|
||||
|
||||
readChunk:
|
||||
- Use the persistent read handle: `handle.read(buf, {at: offset})` → return slice as transferable ArrayBuffer. OPFS allows only one `FileSystemSyncAccessHandle` per file; the persistent handle avoids per-call open/close overhead.
|
||||
|
||||
decryptAndStoreChunk (removes transport encryption only — stored data is still file-level encrypted):
|
||||
1. `decryptReceivedChunk(dhSecret, nonce, new Uint8Array(body), chunkDigest)` → transit-decrypted chunk data (still file-level encrypted — only the transport layer is removed). Argument order matches signature `(dhSecret, cbNonce, encData, expectedDigest)` from download.ts. `body` arrives as `ArrayBuffer` via Transferable and must be wrapped; `dhSecret`, `nonce`, `chunkDigest` arrive as `Uint8Array` via structured clone.
|
||||
2. On first call, open the OPFS download temp file via `createSyncAccessHandle` and store as a persistent write handle. Record `{chunkNo, size: decrypted.length}` in an in-memory `chunkMeta: Map<number, {offset: number, size: number}>` — offset is the running sum of sizes for chunks stored so far (chunks may arrive out of order with `concurrency > 1`, so offset is assigned as `currentFileOffset`, then `currentFileOffset += size`)
|
||||
3. Write decrypted chunk to the persistent handle at the recorded offset
|
||||
|
||||
verifyAndDecrypt (mirrors size/digest checks in agent.ts `downloadFile`):
|
||||
1. Close the persistent download write handle (flush first), then reopen as a read handle. Read each chunk from OPFS into a `Uint8Array[]` array, ordered by `chunkNo`: for each entry in `chunkMeta` sorted by `chunkNo`, `handle.read(buf, {at: offset})` with the recorded offset and size
|
||||
2. Concatenate for verification: `combined = concatBytes(...chunks)`
|
||||
3. Verify total size: `combined.length === params.size`
|
||||
4. Verify SHA-512 digest: `sha512(combined)` matches `params.digest`
|
||||
5. Decrypt: `decryptChunks(BigInt(params.size), chunks, params.key, params.nonce)` — `params.size` is the encrypted file size (`fd.size` = `sum(chunkSizes)` = `decryptChunks`' first param `encSize`). Called directly instead of via `processDownloadedFile` (which expects a full `FileDescription`). Pass the original `chunks` array (not `combined`), as `decryptChunks` handles concatenation internally.
|
||||
6. Delete OPFS download temp file
|
||||
7. Return `{header, content}` via transferable ArrayBuffer
|
||||
|
||||
### 3.5 Browser requirements
|
||||
|
||||
The page requires a modern browser with Web Worker and OPFS support:
|
||||
- Chrome 102+, Firefox 114+, Safari 15.2+ (Workers + OPFS + ES module Workers — Firefox added module Worker support in 114)
|
||||
- If Worker or OPFS is unavailable, the page shows an error message rather than falling back silently.
|
||||
|
||||
No `DirectBackend` is needed — the page is browser-only, and tests run in vitest browser mode (real Chromium). The existing library tests (`test/browser.test.ts`) test the crypto/upload/download pipeline directly without Workers.
|
||||
|
||||
## 4. Server Configuration
|
||||
|
||||
### 4.1 Server lists
|
||||
|
||||
`web/servers.json` — single source of truth for preset server addresses (imported by both `servers.ts` and `vite.config.ts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"simplex": [
|
||||
"xftp://da1aH3nOT-9G8lV7bWamhxpDYdJ1xmW7j3JpGaDR5Ug=@xftp1.simplex.im",
|
||||
"xftp://5vog2Imy1ExJB_7zDZrkV1KDWi96jYFyy9CL6fndBVw=@xftp2.simplex.im",
|
||||
"xftp://PYa32DdYNFWi0uZZOprWQoQpIk5qyjRJ3EF7bVpbsn8=@xftp3.simplex.im",
|
||||
"xftp://k_GgQl40UZVV0Y4BX9ZTyMVqX5ZewcLW0waQIl7AYDE=@xftp4.simplex.im",
|
||||
"xftp://-bIo6o8wuVc4wpZkZD3tH-rCeYaeER_0lz1ffQcSJDs=@xftp5.simplex.im",
|
||||
"xftp://6nSvtY9pJn6PXWTAIMNl95E1Kk1vD7FM2TeOA64CFLg=@xftp6.simplex.im"
|
||||
],
|
||||
"flux": [
|
||||
"xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com",
|
||||
"xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com",
|
||||
"xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com",
|
||||
"xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com",
|
||||
"xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com",
|
||||
"xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`web/servers.ts`:
|
||||
|
||||
```typescript
|
||||
import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js'
|
||||
import presets from './servers.json'
|
||||
|
||||
declare const __XFTP_SERVERS__: string[]
|
||||
|
||||
const serverAddresses: string[] = typeof __XFTP_SERVERS__ !== 'undefined'
|
||||
? __XFTP_SERVERS__
|
||||
: [...presets.simplex, ...presets.flux]
|
||||
|
||||
export function getServers(): XFTPServer[] {
|
||||
return serverAddresses.map(parseXFTPServer)
|
||||
}
|
||||
|
||||
export function pickRandomServer(servers: XFTPServer[]): XFTPServer {
|
||||
return servers[Math.floor(Math.random() * servers.length)]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Build-time injection
|
||||
|
||||
`vite.config.ts` defines `__XFTP_SERVERS__`:
|
||||
- `mode === 'local'`: `["xftp://<test-fingerprint>@localhost:7000"]`
|
||||
- `mode === 'production'`: not defined → falls through to hardcoded list
|
||||
|
||||
### 4.3 Assumption
|
||||
|
||||
Production XFTP servers must have `[WEB]` section configured with a CA-signed certificate for browser TLS. Without this, browsers will reject the self-signed XFTP identity cert. The local test server uses `tests/fixtures/` certs which Chromium accepts via `ignoreHTTPSErrors`.
|
||||
|
||||
## 5. Page Structure & UI
|
||||
|
||||
### 5.1 Routing
|
||||
|
||||
`main.ts` checks `window.location.hash` once on page load:
|
||||
- Hash present → download mode
|
||||
- Hash absent → upload mode
|
||||
|
||||
No `hashchange` listener — the shareable link opens in a new tab. Simple page-load routing.
|
||||
|
||||
### 5.2 Upload UI states
|
||||
|
||||
1. **Landing**: Drag-drop zone centered, file picker button, size limit note
|
||||
2. **Uploading**: Circular progress (canvas), percentage, cancel button
|
||||
3. **Complete**: Shareable link (input + copy button), "Install SimpleX" CTA
|
||||
4. **Error**: Error message + retry button. On server-unreachable, auto-retry with exponential backoff (1s, 2s, 4s, up to 3 attempts) before showing the error state.
|
||||
|
||||
### 5.3 Download UI states
|
||||
|
||||
1. **Ready**: Approximate file size displayed (encrypted size from `fd.size` or `fd.redirect.size` — see §7 step 2; file name is unavailable — it's inside the encrypted content), download button
|
||||
2. **Downloading**: Circular progress, percentage
|
||||
3. **Complete**: Browser save dialog triggered automatically
|
||||
4. **Error**: Error message (expired, corrupted, unreachable)
|
||||
|
||||
### 5.4 Security summary (RFC §7.4)
|
||||
|
||||
Both upload-complete and download-ready states display a brief non-technical security summary:
|
||||
- Files are encrypted in the browser before upload — the server never sees file contents.
|
||||
- The link contains the decryption key in the hash fragment, which the browser never sends to any server.
|
||||
- For maximum security, use the SimpleX app.
|
||||
|
||||
### 5.5 File expiry
|
||||
|
||||
Display on upload-complete state: "Files are typically available for 48 hours." This is an approximation — actual expiry depends on each XFTP server's `[STORE_LOG]` retention configuration. The 48-hour figure matches the current preset server defaults.
|
||||
|
||||
### 5.6 Styling
|
||||
|
||||
Plain CSS, no framework. White background, centered content, responsive. Circular progress via `<canvas>` (arc drawing, percentage text in center).
|
||||
|
||||
File size limit: 100MB. Displayed on upload page.
|
||||
|
||||
### 5.7 CSP
|
||||
|
||||
`index.html` includes a `<meta>` Content-Security-Policy tag with a build-time placeholder:
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src __CSP_CONNECT_SRC__;">
|
||||
```
|
||||
|
||||
Vite's `transformIndexHtml` hook (in `vite.config.ts`) replaces `__CSP_CONNECT_SRC__` at build time with origins derived from the server list:
|
||||
- Local mode: `https://localhost:7000`
|
||||
- Production: `https://xftp1.simplex.im:443 https://xftp2.simplex.im:443 ...` (all 12 servers)
|
||||
|
||||
## 6. Upload Flow
|
||||
|
||||
`web/upload.ts`:
|
||||
|
||||
1. User drops/picks file → `File` object
|
||||
2. Validate `file.size <= 100 * 1024 * 1024` — show error if exceeded
|
||||
3. Read file: `new Uint8Array(await file.arrayBuffer())` — note: after `backend.encrypt()` transfers the buffer to the Worker, `fileData` is detached (zero-length). Peak memory is ~2× file size (main thread holds original until transfer, Worker holds encrypted copy before OPFS write). Acceptable for the 100MB limit; do not raise the limit without considering memory implications.
|
||||
4. Create `CryptoBackend` via factory
|
||||
5. Create `XFTPClientAgent`
|
||||
6. `backend.encrypt(fileData, file.name, onProgress)` → `EncryptResult`
|
||||
- Encryption progress shown on canvas (Worker posts progress messages)
|
||||
7. Pick one random server from configured list (V1: all chunks to same server)
|
||||
8. Call `uploadFile(agent, server, metadata, {onProgress, readChunk: (off, sz) => backend.readChunk(off, sz)})`:
|
||||
- `metadata` = `{digest, key, nonce, chunkSizes}` from EncryptResult
|
||||
- Network progress shown on canvas
|
||||
- Returns `{rcvDescription, sndDescription, uri}`
|
||||
9. Construct full URL: `window.location.origin + window.location.pathname + '#' + uri`
|
||||
10. Display link, copy button
|
||||
11. Cleanup: `backend.cleanup()`, `closeXFTPAgent(agent)`
|
||||
|
||||
**Cancel:** User can abort via cancel button. Sets an `AbortController` signal that:
|
||||
- Sends `{type: 'cleanup'}` to Worker
|
||||
- Closes the XFTPClientAgent (drops HTTP/2 connections)
|
||||
- Resets UI to landing state
|
||||
|
||||
## 7. Download Flow
|
||||
|
||||
`web/download.ts`:
|
||||
|
||||
1. Parse `window.location.hash.slice(1)` → `decodeDescriptionURI(fragment)` → `FileDescription`
|
||||
2. Display file size (`fd.size` bytes, formatted human-readable). Note: `fd.size` is the encrypted size (slightly larger than plaintext due to padding + auth tag). The plaintext size is not available until decryption — display it as an approximate file size. If `fd.redirect !== null`, size comes from `fd.redirect.size` (which is the inner encrypted size).
|
||||
3. User clicks "Download"
|
||||
4. Create `CryptoBackend` and `XFTPClientAgent`
|
||||
5. Call `downloadFileRaw(agent, fd, onRawChunk, {onProgress, concurrency: 3})`:
|
||||
- `onRawChunk` forwards each raw chunk to the Worker: `backend.decryptAndStoreChunk(raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo)`
|
||||
- `downloadFileRaw` handles redirect resolution internally (see §7.1), parallel downloads, and connection pooling
|
||||
- Returns the resolved `FileDescription` (inner fd for redirect case, original fd otherwise)
|
||||
6. `backend.verifyAndDecrypt({size: resolvedFd.size, digest: resolvedFd.digest, key: resolvedFd.key, nonce: resolvedFd.nonce})` → `{header, content}`
|
||||
- Verifies size + SHA-512 digest + file-level decryption inside Worker. Only the four needed fields are sent — private replica keys stay on the main thread.
|
||||
7. ACK: `ackFileChunks(agent, resolvedFd)` — best-effort, after verification succeeds
|
||||
8. Sanitize `header.fileName` before use: strip path separators (`/`, `\`), replace null/control characters (U+0000-U+001F, U+007F), strip Unicode bidi override characters (U+202A-U+202E, U+2066-U+2069 — prevents `doc.pdf.exe` appearing as `doc.exe.pdf`), limit length to 255 chars. The filename is user-controlled (set by the uploader) and arrives via decrypted content. Then trigger browser save: `new Blob([content])` → `<a download="${sanitizedName}">` click
|
||||
9. Cleanup: `backend.cleanup()`, `closeXFTPAgent(agent)`
|
||||
|
||||
### 7.1 Redirect handling
|
||||
|
||||
Handled inside `downloadFileRaw` in agent.ts — the web page doesn't see it. When `fd.redirect !== null`:
|
||||
|
||||
1. Download redirect chunks via `downloadXFTPChunkRaw` (parallel, same as regular chunks)
|
||||
2. Transit-decrypt + verify + file-level decrypt on main thread (redirect data is always small — a few KB of YAML, so main thread decryption is fine)
|
||||
3. Parse YAML → inner `FileDescription`, validate against `fd.redirect.{size, digest}`
|
||||
4. ACK redirect chunks (best-effort)
|
||||
5. Continue downloading inner description's chunks, calling `onRawChunk` for each
|
||||
|
||||
### 7.2 Architecture note: download refactoring
|
||||
|
||||
Both upload and download use `agent.ts` for orchestration. The key difference is where the crypto/network split happens:
|
||||
|
||||
- **Upload**: agent.ts reads encrypted chunks from the Worker via `readChunk` callback, sends them over the network.
|
||||
- **Download**: agent.ts receives raw encrypted responses from the network via `downloadXFTPChunkRaw` (DH key exchange + network only, no decryption), passes them to the web page via `onRawChunk` callback, which routes them to the Worker for transit decryption.
|
||||
|
||||
This split keeps all expensive crypto off the main thread. Transit decryption uses a custom JS Salsa20 implementation (`xorKeystream` in secretbox.ts) that would block the UI for ~50-200ms on a 4MB chunk. File-level decryption (`decryptChunks`) is similarly expensive. Both happen in the Worker.
|
||||
|
||||
The cheap operations stay on the main thread: DH key exchange (`generateX25519KeyPair` + `dh` — ~1ms via libsodium WASM), XFTP command encoding/decoding, connection management.
|
||||
|
||||
## 8. Build & Dev Setup
|
||||
|
||||
### 8.1 vite.config.ts (new, separate from vitest.config.ts)
|
||||
|
||||
```typescript
|
||||
import {defineConfig, type Plugin} from 'vite'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
import presets from './web/servers.json'
|
||||
|
||||
function parseHost(addr: string): string {
|
||||
const m = addr.match(/@(.+)$/)
|
||||
if (!m) throw new Error('bad server address: ' + addr)
|
||||
const host = m[1].split(',')[0]
|
||||
return host.includes(':') ? host : host + ':443'
|
||||
}
|
||||
|
||||
function cspPlugin(servers: string[]): Plugin {
|
||||
const origins = servers.map(s => 'https://' + parseHost(s)).join(' ')
|
||||
return {
|
||||
name: 'csp-connect-src',
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html, ctx) {
|
||||
if (ctx.server) {
|
||||
// Dev mode: remove CSP meta tag entirely — Vite HMR needs inline scripts
|
||||
return html.replace(/<meta\s[^>]*?Content-Security-Policy[\s\S]*?>/i, '')
|
||||
}
|
||||
return html.replace('__CSP_CONNECT_SRC__', origins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const define: Record<string, string> = {}
|
||||
let servers: string[]
|
||||
|
||||
if (mode === 'local') {
|
||||
const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
|
||||
const fp = createHash('sha256').update(der).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
servers = [`xftp://${fp}@localhost:7000`]
|
||||
define['__XFTP_SERVERS__'] = JSON.stringify(servers)
|
||||
} else {
|
||||
servers = [...presets.simplex, ...presets.flux]
|
||||
}
|
||||
|
||||
return {
|
||||
root: 'web',
|
||||
build: {outDir: '../dist-web'},
|
||||
define,
|
||||
worker: {format: 'es'},
|
||||
plugins: [cspPlugin(servers)],
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 8.2 package.json scripts
|
||||
|
||||
```json
|
||||
"dev": "vite --mode local",
|
||||
"build:local": "vite build --mode local",
|
||||
"build:prod": "vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"check:web": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.worker.json --noEmit"
|
||||
```
|
||||
|
||||
Note: `check:web` type-checks `src/` twice (once per config) — acceptable for this small library.
|
||||
|
||||
Add `vite` as an explicit devDependency (`^6.0.0` — matching the version vitest 3.x depends on transitively). Relying on transitive resolution is fragile across package managers.
|
||||
|
||||
### 8.3 TypeScript configuration
|
||||
|
||||
The existing `tsconfig.json` has `rootDir: "src"` and `include: ["src/**/*.ts"]` — this is for library compilation only (output to `dist/`). Vite handles `web/` TypeScript compilation independently via esbuild, so the main tsconfig is unchanged. `web/*.ts` files import from `../src/*.js` using relative paths.
|
||||
|
||||
Add two tsconfigs for `web/` type-checking — split by environment to avoid type pollution between DOM and WebWorker globals:
|
||||
|
||||
`tsconfig.web.json` — main-thread files (DOM globals: `document`, `window`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["web/**/*.ts", "src/**/*.ts"],
|
||||
"exclude": ["web/crypto.worker.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
`tsconfig.worker.json` — Worker file (`self`, `FileSystemSyncAccessHandle`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "WebWorker"]
|
||||
},
|
||||
"include": ["web/crypto.worker.ts", "src/**/*.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
Both configs set `"types": []` to prevent auto-inclusion of `@types/node` and `"moduleResolution": "bundler"` for Vite-compatible resolution (JSON imports, `.js` extension mapping). The base config's `"moduleResolution": "node"` would cause false type errors on `import ... from './servers.json'`. Both override `@types/node`, which would pollute DOM/WebWorker environments with Node.js globals (`process`, `Buffer`, etc.). This means `src/client.ts`'s dynamic `import("node:http2")` will produce a type error in these configs. This is acceptable — `src/client.ts` provides `createNodeTransport` which is never used in browser code (Vite tree-shakes it out), and full `src/` type-checking is handled by the base `tsconfig.json`. If the error is distracting, add `src/client.ts` to both configs' `exclude` arrays.
|
||||
|
||||
Both extend the library tsconfig (inheriting `strict`, `module`, etc.) and include `src/**/*.ts` so imports from `../src/*.js` resolve. `"noEmit": true` means they're only used for type-checking — Vite handles actual compilation. The inherited `"exclude": ["node_modules", "dist", "test"]` intentionally excludes `test/` — test files are type-checked by their own vitest/playwright configs, not by `check:web`.
|
||||
|
||||
### 8.4 Dev workflow
|
||||
|
||||
`npm run dev` → Vite dev server at `localhost:5173`, configured for local test server. Start `xftp-server` on port 7000 separately (or via the existing globalSetup).
|
||||
|
||||
Note: The CSP meta tag's `default-src 'self'` blocks Vite's injected HMR inline scripts in dev mode. The `cspPlugin` handles this by removing the entire CSP `<meta>` tag in serve mode (dev server), so HMR works without restrictions. Production builds always have the correct CSP.
|
||||
|
||||
## 9. Library Changes (agent.ts + client.ts)
|
||||
|
||||
Changes to support the web page: upload `readChunk` callback, download `onRawChunk` callback with parallel chunk downloads.
|
||||
|
||||
### 9.1 Type changes
|
||||
|
||||
Split the existing `EncryptedFileInfo` (which currently has `encData`, `digest`, `key`, `nonce`, `chunkSizes` as direct fields) into a metadata-only base and an extension:
|
||||
|
||||
```typescript
|
||||
// Metadata-only variant (no encData — data lives in Worker/OPFS)
|
||||
export interface EncryptedFileMetadata {
|
||||
digest: Uint8Array
|
||||
key: Uint8Array
|
||||
nonce: Uint8Array
|
||||
chunkSizes: number[]
|
||||
}
|
||||
|
||||
// Full variant (existing, extends metadata with data)
|
||||
export interface EncryptedFileInfo extends EncryptedFileMetadata {
|
||||
encData: Uint8Array
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 uploadFile signature change
|
||||
|
||||
Replace positional optional params with an options bag. Add optional `readChunk`. When provided, `encrypted.encData` is not accessed.
|
||||
|
||||
```typescript
|
||||
export interface UploadOptions {
|
||||
onProgress?: (uploaded: number, total: number) => void
|
||||
redirectThreshold?: number
|
||||
readChunk?: (offset: number, size: number) => Promise<Uint8Array>
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
encrypted: EncryptedFileMetadata,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResult>
|
||||
```
|
||||
|
||||
Inside `uploadFile`:
|
||||
- Chunk read: if `options?.readChunk` is provided, use it. Otherwise, verify `'encData' in encrypted` at runtime (throws `"uploadFile: readChunk required when encData is absent"` if missing), then use `(off, sz) => Promise.resolve((encrypted as EncryptedFileInfo).encData.subarray(off, off + sz))`. This guards against calling `uploadFile` with `EncryptedFileMetadata` but no `readChunk`. For each chunk, call `readChunk(offset, size)` once and use the returned `Uint8Array` for both `getChunkDigest(chunkData)` and `uploadXFTPChunk(..., chunkData)` — do not call `readChunk` twice per chunk.
|
||||
- Progress total: `const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)` — replaces `encrypted.encData.length` (line 129) since `EncryptedFileMetadata` has no `encData`. The values are identical: `encData.length === sum(chunkSizes)`.
|
||||
- `buildDescription` parameter type: change from `EncryptedFileInfo` to `EncryptedFileMetadata` — it only accesses `chunkSizes`, `digest`, `key`, `nonce` (not `encData`).
|
||||
|
||||
`uploadRedirectDescription` (internal) is unchanged — redirect descriptions are always small and created in-memory by `encryptFileForUpload`.
|
||||
|
||||
### 9.3 Backward compatibility
|
||||
|
||||
The signature change from positional params `(agent, server, encrypted, onProgress?, redirectThreshold?)` to `(agent, server, encrypted, options?)` is a breaking change for callers that pass `onProgress` or `redirectThreshold`. In practice, the only callers are the browser test (which passes no options — no change needed) and the web page (new code). `EncryptedFileInfo` extends `EncryptedFileMetadata`, so existing callers that pass `EncryptedFileInfo` work without change.
|
||||
|
||||
### 9.4 client.ts: downloadXFTPChunkRaw
|
||||
|
||||
Split `downloadXFTPChunk` at the network/crypto boundary. The new function does DH key exchange and network I/O but skips transit decryption:
|
||||
|
||||
```typescript
|
||||
export interface RawChunkResponse {
|
||||
dhSecret: Uint8Array
|
||||
nonce: Uint8Array
|
||||
body: Uint8Array
|
||||
}
|
||||
|
||||
export async function downloadXFTPChunkRaw(
|
||||
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array
|
||||
): Promise<RawChunkResponse> {
|
||||
const {publicKey, privateKey} = generateX25519KeyPair()
|
||||
const cmd = encodeFGET(encodePubKeyX25519(publicKey))
|
||||
const {response, body} = await sendXFTPCommand(c, rpKey, fId, cmd)
|
||||
if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
|
||||
const dhSecret = dh(response.rcvDhKey, privateKey)
|
||||
return {dhSecret, nonce: response.nonce, body}
|
||||
}
|
||||
```
|
||||
|
||||
`RawChunkResponse` contains only what client.ts produces (DH secret, nonce, encrypted body). The chunk metadata (`chunkNo`, `digest`) is added by agent.ts when constructing `RawDownloadedChunk` (see §9.5).
|
||||
|
||||
The existing `downloadXFTPChunk` is refactored to call `downloadXFTPChunkRaw` + `decryptReceivedChunk`:
|
||||
|
||||
```typescript
|
||||
export async function downloadXFTPChunk(
|
||||
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(c, rpKey, fId)
|
||||
return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
|
||||
}
|
||||
```
|
||||
|
||||
### 9.5 agent.ts: downloadFileRaw, ackFileChunks, RawDownloadedChunk
|
||||
|
||||
New type combining client.ts's `RawChunkResponse` with chunk metadata from agent.ts:
|
||||
|
||||
```typescript
|
||||
export interface RawDownloadedChunk {
|
||||
chunkNo: number
|
||||
dhSecret: Uint8Array
|
||||
nonce: Uint8Array
|
||||
body: Uint8Array
|
||||
digest: Uint8Array
|
||||
}
|
||||
```
|
||||
|
||||
New function providing download orchestration with a raw chunk callback. Handles connection pooling, parallel downloads, redirect resolution, and progress. Does **not** ACK — the caller ACKs after verification.
|
||||
|
||||
```typescript
|
||||
export interface DownloadRawOptions {
|
||||
onProgress?: (downloaded: number, total: number) => void
|
||||
concurrency?: number // max parallel chunk downloads, default 1
|
||||
}
|
||||
|
||||
export async function downloadFileRaw(
|
||||
agent: XFTPClientAgent,
|
||||
fd: FileDescription,
|
||||
onRawChunk: (chunk: RawDownloadedChunk) => Promise<void>,
|
||||
options?: DownloadRawOptions
|
||||
): Promise<FileDescription>
|
||||
```
|
||||
|
||||
Returns the resolved `FileDescription` — for redirect files this is the inner fd, for non-redirect files this is the original fd. The caller uses this for verification and ACK.
|
||||
|
||||
Internal structure:
|
||||
|
||||
1. Validate `fd` via `validateFileDescription` (may double-validate if caller already validated via `decodeDescriptionURI` — harmless)
|
||||
2. If `fd.redirect !== null`: resolve redirect on main thread (redirect data is small):
|
||||
a. Download redirect chunks via `downloadXFTPChunk` (not raw — main thread decryption is fine for a few KB)
|
||||
b. Verify size + digest, `processDownloadedFile` → YAML bytes
|
||||
c. Parse inner `FileDescription`, validate against `fd.redirect.{size, digest}`
|
||||
d. ACK redirect chunks (best-effort — redirect chunks are small and separate from the file chunks)
|
||||
e. Replace `fd` with inner description
|
||||
3. Pre-connect: call `getXFTPServerClient(agent, server)` for each unique server before launching concurrent workers. This ensures the client connection exists in the agent's map, avoiding a race condition where multiple concurrent workers all see the client as missing and each call `connectXFTP` independently (leaking all but the last connection). Known limitation: if a connection drops mid-download and multiple workers attempt reconnection simultaneously, the same TOCTOU race reappears. This is a pre-existing issue in `getXFTPServerClient`; a proper fix (per-key connection promise) is out of scope for this plan but should be tracked for follow-up.
|
||||
4. Download file chunks in parallel (concurrency-limited via sliding window):
|
||||
- Create a queue of chunk indices `[0, 1, ..., N-1]`. Launch `min(concurrency, N)` async workers, each pulling the next index from the queue until empty. Each worker loops: pull index → derive key → `getXFTPServerClient` → `downloadXFTPChunkRaw` → `await onRawChunk(...)` → update progress → next index. `await Promise.all(workers)` to wait for completion.
|
||||
- For each chunk: derive key (`decodePrivKeyEd25519` → `ed25519KeyPairFromSeed`), get client (`getXFTPServerClient`), call `downloadXFTPChunkRaw`, `await onRawChunk(...)` with result + `chunkNo` + `chunk.digest`
|
||||
- Each concurrency slot awaits its `onRawChunk` before starting the next download on that slot. With `concurrency > 1`, multiple `onRawChunk` calls may be in-flight concurrently (one per slot). The Worker handles this correctly — messages are queued and processed sequentially.
|
||||
- Update progress after each chunk: `downloaded += chunk.chunkSize; onProgress?.(downloaded, resolvedFd.size)` — both values use encrypted sizes for consistency
|
||||
5. Return the resolved `fd`
|
||||
|
||||
New helper for ACKing after verification:
|
||||
|
||||
```typescript
|
||||
export async function ackFileChunks(
|
||||
agent: XFTPClientAgent, fd: FileDescription
|
||||
): Promise<void> {
|
||||
for (const chunk of fd.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) continue
|
||||
try {
|
||||
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The existing `downloadFile` is refactored to use `downloadFileRaw` internally:
|
||||
|
||||
```typescript
|
||||
export async function downloadFile(
|
||||
agent: XFTPClientAgent,
|
||||
fd: FileDescription,
|
||||
onProgress?: (downloaded: number, total: number) => void
|
||||
): Promise<DownloadResult> {
|
||||
const chunks: Uint8Array[] = []
|
||||
const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
|
||||
chunks[raw.chunkNo - 1] = decryptReceivedChunk(
|
||||
raw.dhSecret, raw.nonce, raw.body, raw.digest
|
||||
)
|
||||
}, {onProgress})
|
||||
// verify + file-level decrypt using resolvedFd (inner fd for redirect case)
|
||||
const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
|
||||
if (combined.length !== resolvedFd.size) throw new Error("downloadFile: file size mismatch")
|
||||
const digest = sha512(combined)
|
||||
if (!digestEqual(digest, resolvedFd.digest)) throw new Error("downloadFile: file digest mismatch")
|
||||
// processDownloadedFile re-concatenates chunks internally — this mirrors the
|
||||
// existing downloadFile pattern (verify on concatenated data, then pass chunks
|
||||
// array to decryptChunks which concatenates again). Acceptable overhead for
|
||||
// correctness: verification must happen on transit-decrypted data before
|
||||
// file-level decryption transforms it.
|
||||
const result = processDownloadedFile(resolvedFd, chunks)
|
||||
await ackFileChunks(agent, resolvedFd)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Existing callers retain serial behavior (`concurrency` defaults to 1). The web page opts into parallelism by passing `concurrency: 3`. The browser test (`test/browser.test.ts`) continues to work unchanged. The chunks array is initialized empty (`[]`) and populated by sparse index assignment (`chunks[raw.chunkNo - 1] = ...`), so it correctly handles both redirect and non-redirect cases regardless of the outer fd's chunk count. `digestEqual` is an existing module-private helper in agent.ts (line 327) that performs constant-time byte comparison.
|
||||
|
||||
### 9.6 Backward compatibility (download)
|
||||
|
||||
`downloadFile` signature is unchanged — existing callers are unaffected. The refactoring adds `downloadFileRaw`, `ackFileChunks`, and `RawDownloadedChunk` as new exports from agent.ts, and `downloadXFTPChunkRaw` + `RawChunkResponse` as new exports from client.ts.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 Existing tests (unchanged)
|
||||
|
||||
- `npm run test:browser` — vitest browser round-trip (library-level)
|
||||
- `cabal test --test-option='--match=/XFTP Web Client/'` — Haskell per-function tests
|
||||
|
||||
### 10.2 New: page E2E test
|
||||
|
||||
Add `test/page.spec.ts` using `@playwright/test` (not vitest browser mode — vitest tests run IN the browser and can't control page navigation; Playwright tests run in Node.js and control the browser). Add `@playwright/test` as a devDependency.
|
||||
|
||||
Add `playwright.config.ts` at the project root (`xftp-web/`):
|
||||
- `webServer: { command: 'vite build --mode local && vite preview', url: 'http://localhost:4173', reuseExistingServer: !process.env.CI }` — the `url` property tells Playwright to wait until the preview server is ready before running tests
|
||||
- `use.ignoreHTTPSErrors: true` (test server uses self-signed cert)
|
||||
- `use.launchOptions: { args: ['--ignore-certificate-errors'] }` — required because Playwright's `ignoreHTTPSErrors` only affects page navigation, not `fetch()` calls from in-page JavaScript. Without this flag, the page's `createBrowserTransport` fetch to `https://localhost:7000` would fail TLS validation.
|
||||
- `globalSetup`: `'./test/globalSetup.ts'` (starts xftp-server, shared with vitest)
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@playwright/test'
|
||||
|
||||
test('page upload + download round-trip', async ({page}) => {
|
||||
await page.goto(PAGE_URL)
|
||||
// Set file input via page.setInputFiles()
|
||||
// Wait for upload link to appear: page.waitForSelector('[data-testid="share-link"]')
|
||||
// Extract hash from link text
|
||||
// Navigate to PAGE_URL + '#' + hash
|
||||
// Wait for download complete state
|
||||
// Verify file was offered for save (check download event)
|
||||
})
|
||||
```
|
||||
|
||||
Add script: `"test:page": "playwright test test/page.spec.ts"`
|
||||
|
||||
This tests the real bundle including Worker loading, OPFS, and CSP. The existing `test/browser.test.ts` continues to test the library-level pipeline (vitest browser mode, no Workers).
|
||||
|
||||
### 10.3 Manual testing
|
||||
|
||||
`npm run dev` → open `localhost:5173` in browser → drag file → get link → open link in new tab → download. Requires xftp-server running on port 7000 (local mode).
|
||||
|
||||
## 11. Files
|
||||
|
||||
**Create:**
|
||||
- `xftp-web/web/index.html` — page entry point (includes CSP meta tag)
|
||||
- `xftp-web/web/main.ts` — router + libsodium init
|
||||
- `xftp-web/web/upload.ts` — upload UI + orchestration
|
||||
- `xftp-web/web/download.ts` — download UI + orchestration
|
||||
- `xftp-web/web/progress.ts` — circular progress canvas component
|
||||
- `xftp-web/web/servers.json` — preset server addresses (shared by servers.ts and vite.config.ts)
|
||||
- `xftp-web/web/servers.ts` — server configuration (imports servers.json)
|
||||
- `xftp-web/web/crypto-backend.ts` — CryptoBackend interface + WorkerBackend + factory
|
||||
- `xftp-web/web/crypto.worker.ts` — Web Worker implementation
|
||||
- `xftp-web/web/style.css` — styles
|
||||
- `xftp-web/vite.config.ts` — page build config (CSP generation, server list)
|
||||
- `xftp-web/tsconfig.web.json` — IDE/CI type-checking for `web/` main-thread files (DOM)
|
||||
- `xftp-web/tsconfig.worker.json` — IDE/CI type-checking for `web/crypto.worker.ts` (WebWorker)
|
||||
- `xftp-web/playwright.config.ts` — Playwright E2E test config (webServer, globalSetup)
|
||||
- `xftp-web/test/page.spec.ts` — page E2E test (Playwright)
|
||||
|
||||
**Modify:**
|
||||
- `xftp-web/src/agent.ts` — add `EncryptedFileMetadata` type, `uploadFile` options bag with `readChunk`, `downloadFileRaw` with `onRawChunk` callback + parallel downloads, `ackFileChunks`, `RawDownloadedChunk` type, refactor `downloadFile` on top of `downloadFileRaw`, add `import {decryptReceivedChunk} from "./download.js"` (needed by refactored `downloadFile`)
|
||||
- `xftp-web/src/client.ts` — add `downloadXFTPChunkRaw`, `RawChunkResponse` type, refactor `downloadXFTPChunk` to use raw variant
|
||||
- `xftp-web/package.json` — add dev/build/check:web/test:page scripts, add `vite` + `@playwright/test` devDeps
|
||||
- `xftp-web/src/protocol/description.ts` — fix stale "SHA-256" comment on `FileDescription.digest` to "SHA-512"
|
||||
- `xftp-web/.gitignore` — add `dist-web/`
|
||||
|
||||
## 12. Implementation Order
|
||||
|
||||
1. **Library refactoring** — `client.ts`: add `downloadXFTPChunkRaw`; `agent.ts`: add `downloadFileRaw` + parallel downloads, `uploadFile` options bag with `readChunk`; refactor existing `downloadFile` on top of `downloadFileRaw`. Run existing tests to verify no regressions.
|
||||
2. **Vite config + HTML shell** — `vite.config.ts`, `index.html`, `main.ts`, verify dev server works
|
||||
3. **Server config** — `servers.ts` with both local and production server lists
|
||||
4. **CryptoBackend + Worker** — interface, WorkerBackend, Worker implementation, OPFS logic
|
||||
5. **Upload flow** — `upload.ts` with drag-drop, encrypt via Worker, upload via agent, show link
|
||||
6. **Download flow** — `download.ts` with URL parsing, download via agent `downloadFileRaw`, Worker decrypt, browser save
|
||||
7. **Progress component** — `progress.ts` canvas drawing
|
||||
8. **Styling** — `style.css`
|
||||
9. **Testing** — page E2E test, manual browser verification
|
||||
10. **Build scripts** — `build:local`, `build:prod` in package.json
|
||||
@@ -0,0 +1,53 @@
|
||||
# XFTPClientAgent Pattern
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Changes: client.ts
|
||||
3. Changes: agent.ts
|
||||
4. Changes: test/browser.test.ts
|
||||
5. Verification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Add `XFTPClientAgent` — a per-server connection pool matching the Haskell pattern. The agent caches `XFTPClient` instances by server URL. All orchestration functions (`uploadFile`, `downloadFile`, `deleteFile`) take `agent` as first parameter and use `getXFTPServerClient(agent, server)` instead of calling `connectXFTP` directly. Connections stay open on success; the caller creates and closes the agent.
|
||||
|
||||
`connectXFTP` and `closeXFTP` stay exported (used by `XFTPWebTests.hs` Haskell tests). The `browserClients` hack, per-function `connections: Map`, and `getOrConnect` are deleted.
|
||||
|
||||
## Changes: client.ts
|
||||
|
||||
**Add** after types section: `XFTPClientAgent` interface, `newXFTPAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `closeXFTPAgent`.
|
||||
|
||||
**Delete**: `browserClients` Map and all `isNode` browser-cache checks in `connectXFTP` and `closeXFTP`.
|
||||
|
||||
**Revert `closeXFTP`** to unconditional `c.transport.close()` (browser transport.close() is already a no-op).
|
||||
|
||||
`connectXFTP` stays exported (backward compat) but becomes a raw low-level function — no caching.
|
||||
|
||||
## Changes: agent.ts
|
||||
|
||||
**Imports**: replace `connectXFTP`/`closeXFTP` with `getXFTPServerClient`/`closeXFTPAgent` etc.
|
||||
|
||||
**Re-export** from agent.ts: `newXFTPAgent`, `closeXFTPAgent`, `XFTPClientAgent`.
|
||||
|
||||
**`uploadFile`**: add `agent: XFTPClientAgent` as first param. Replace `connectXFTP` → `getXFTPServerClient`. Remove `finally { closeXFTP }`. Pass `agent` to `uploadRedirectDescription`.
|
||||
|
||||
**`uploadRedirectDescription`**: change from `(client, server, innerFd)` to `(agent, server, innerFd)`. Get client via `getXFTPServerClient`.
|
||||
|
||||
**`downloadFile`**: add `agent` param. Delete local `connections: Map`. Replace `getOrConnect` → `getXFTPServerClient`. Remove finally cleanup. Pass `agent` to `downloadWithRedirect`.
|
||||
|
||||
**`downloadWithRedirect`**: add `agent` param. Same replacements. Remove try/catch cleanup. Recursive call passes `agent`.
|
||||
|
||||
**`deleteFile`**: add `agent` param. Same pattern.
|
||||
|
||||
**Delete**: `getOrConnect` function entirely.
|
||||
|
||||
## Changes: test/browser.test.ts
|
||||
|
||||
Create agent before operations, pass to upload/download, close in finally.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npx vitest --run` — browser round-trip test passes
|
||||
2. No remaining `browserClients`, `getOrConnect`, or per-function `connections: Map` locals
|
||||
3. `connectXFTP` and `closeXFTP` still exported (XFTPWebTests.hs compat)
|
||||
4. All orchestration functions take `agent` as first param
|
||||
@@ -0,0 +1,859 @@
|
||||
# XFTP Web Page E2E Tests Plan
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Test Infrastructure](#2-test-infrastructure)
|
||||
3. [Test Infrastructure - Page Objects](#3-test-infrastructure---page-objects)
|
||||
4. [Upload Flow Tests](#4-upload-flow-tests)
|
||||
5. [Download Flow Tests](#5-download-flow-tests)
|
||||
6. [Edge Cases](#6-edge-cases)
|
||||
7. [Implementation Order](#7-implementation-order)
|
||||
8. [Test Utilities](#8-test-utilities)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This document specifies comprehensive Playwright E2E tests for the XFTP web page. The existing test (`page.spec.ts`) performs a basic upload/download round-trip. This plan extends coverage to:
|
||||
|
||||
- **Upload flow**: File selection (picker + drag-drop), validation, progress, cancellation, link sharing, error handling
|
||||
- **Download flow**: Invalid link handling, download button, progress, file save, error states
|
||||
- **Edge cases**: Boundary file sizes, special characters, network failures, multi-chunk files with redirect, UI information display
|
||||
|
||||
**Key constraints**:
|
||||
- Tests run against a local XFTP server (started via `globalSetup.ts`)
|
||||
- Server port is dynamic (read from `/tmp/xftp-test-server.port`)
|
||||
- Browser uses `--ignore-certificate-errors` for self-signed certs
|
||||
- OPFS and Web Workers are required (Chromium supports both)
|
||||
|
||||
**Test file location**: `/code/simplexmq/xftp-web/test/page.spec.ts`
|
||||
|
||||
**Architecture**: Tests use the Page Object Model pattern to encapsulate UI interactions, making tests read as domain-specific scenarios rather than raw Playwright API calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Infrastructure
|
||||
|
||||
### 2.1 Current Setup
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── playwright.config.ts # Playwright config (webServer, globalSetup)
|
||||
├── test/
|
||||
│ ├── globalSetup.ts # Starts xftp-server, writes port to PORT_FILE
|
||||
│ ├── page.spec.ts # E2E tests (to be extended)
|
||||
│ └── pages/ # Page Objects (new)
|
||||
│ ├── UploadPage.ts
|
||||
│ └── DownloadPage.ts
|
||||
```
|
||||
|
||||
### 2.2 Prerequisites
|
||||
|
||||
- `globalSetup.ts` starts the XFTP server and writes port to `PORT_FILE`
|
||||
- Tests must read the port dynamically: `readFileSync(PORT_FILE, 'utf-8').trim()`
|
||||
- Vite builds and serves the page at `http://localhost:4173`
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Infrastructure - Page Objects
|
||||
|
||||
Page Objects encapsulate page-specific selectors and actions, providing a clean API for tests. This follows the standard Page Object Model pattern used in simplex-chat and most professional test suites.
|
||||
|
||||
### 3.1 UploadPage
|
||||
|
||||
```typescript
|
||||
// test/pages/UploadPage.ts
|
||||
import {Page, Locator, expect} from '@playwright/test'
|
||||
|
||||
export class UploadPage {
|
||||
readonly page: Page
|
||||
readonly dropZone: Locator
|
||||
readonly fileInput: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly cancelButton: Locator
|
||||
readonly completeStage: Locator
|
||||
readonly shareLink: Locator
|
||||
readonly copyButton: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly expiryNote: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.dropZone = page.locator('#drop-zone')
|
||||
this.fileInput = page.locator('#file-input')
|
||||
this.progressStage = page.locator('#upload-progress')
|
||||
this.progressCanvas = page.locator('#progress-container canvas')
|
||||
this.statusText = page.locator('#upload-status')
|
||||
this.cancelButton = page.locator('#cancel-btn')
|
||||
this.completeStage = page.locator('#upload-complete')
|
||||
this.shareLink = page.locator('[data-testid="share-link"]')
|
||||
this.copyButton = page.locator('#copy-btn')
|
||||
this.errorStage = page.locator('#upload-error')
|
||||
this.errorMessage = page.locator('#error-msg')
|
||||
this.retryButton = page.locator('#retry-btn')
|
||||
this.expiryNote = page.locator('.expiry')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('http://localhost:4173')
|
||||
}
|
||||
|
||||
async selectFile(name: string, content: Buffer, mimeType = 'application/octet-stream') {
|
||||
await this.fileInput.setInputFiles({name, mimeType, buffer: content})
|
||||
}
|
||||
|
||||
async selectTextFile(name: string, content: string) {
|
||||
await this.selectFile(name, Buffer.from(content, 'utf-8'), 'text/plain')
|
||||
}
|
||||
|
||||
async selectLargeFile(name: string, sizeBytes: number) {
|
||||
// Create large file in browser to avoid memory issues in test process
|
||||
await this.page.evaluate(({name, size}) => {
|
||||
const input = document.getElementById('file-input') as HTMLInputElement
|
||||
const buffer = new ArrayBuffer(size)
|
||||
new Uint8Array(buffer).fill(0x55)
|
||||
const file = new File([buffer], name, {type: 'application/octet-stream'})
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(file)
|
||||
input.files = dt.files
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}))
|
||||
}, {name, size: sizeBytes})
|
||||
}
|
||||
|
||||
async dragDropFile(name: string, content: Buffer) {
|
||||
// Drag-drop uses same file input handler internally
|
||||
await this.selectFile(name, content)
|
||||
}
|
||||
|
||||
async waitForEncrypting(timeout = 10_000) {
|
||||
await expect(this.statusText).toContainText('Encrypting', {timeout})
|
||||
}
|
||||
|
||||
async waitForUploading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Uploading', {timeout})
|
||||
}
|
||||
|
||||
async waitForShareLink(timeout = 60_000): Promise<string> {
|
||||
await expect(this.shareLink).toBeVisible({timeout})
|
||||
return await this.shareLink.inputValue()
|
||||
}
|
||||
|
||||
async clickCopy() {
|
||||
await this.copyButton.click()
|
||||
await expect(this.copyButton).toContainText('Copied!')
|
||||
}
|
||||
|
||||
async clickCancel() {
|
||||
await this.cancelButton.click()
|
||||
}
|
||||
|
||||
async clickRetry() {
|
||||
await this.retryButton.click()
|
||||
}
|
||||
|
||||
async expectError(messagePattern: string | RegExp) {
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectDropZoneVisible() {
|
||||
await expect(this.dropZone).toBeVisible()
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectCompleteWithExpiry() {
|
||||
await expect(this.completeStage).toBeVisible()
|
||||
await expect(this.expiryNote).toContainText('48 hours')
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
|
||||
getHashFromLink(url: string): string {
|
||||
return new URL(url).hash
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 DownloadPage
|
||||
|
||||
```typescript
|
||||
// test/pages/DownloadPage.ts
|
||||
import {Page, Locator, expect, Download} from '@playwright/test'
|
||||
|
||||
export class DownloadPage {
|
||||
readonly page: Page
|
||||
readonly readyStage: Locator
|
||||
readonly downloadButton: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.readyStage = page.locator('#dl-ready')
|
||||
this.downloadButton = page.locator('#dl-btn')
|
||||
this.progressStage = page.locator('#dl-progress')
|
||||
this.progressCanvas = page.locator('#dl-progress-container canvas')
|
||||
this.statusText = page.locator('#dl-status')
|
||||
this.errorStage = page.locator('#dl-error')
|
||||
this.errorMessage = page.locator('#dl-error-msg')
|
||||
this.retryButton = page.locator('#dl-retry-btn')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto(hash: string) {
|
||||
await this.page.goto(`http://localhost:4173${hash}`)
|
||||
}
|
||||
|
||||
async gotoWithLink(fullUrl: string) {
|
||||
const hash = new URL(fullUrl).hash
|
||||
await this.goto(hash)
|
||||
}
|
||||
|
||||
async expectFileReady() {
|
||||
await expect(this.readyStage).toBeVisible()
|
||||
await expect(this.downloadButton).toBeVisible()
|
||||
}
|
||||
|
||||
async expectFileSizeDisplayed() {
|
||||
await expect(this.readyStage).toContainText(/\d+(?:\.\d+)?\s*(?:KB|MB|B)/)
|
||||
}
|
||||
|
||||
async clickDownload(): Promise<Download> {
|
||||
const downloadPromise = this.page.waitForEvent('download')
|
||||
await this.downloadButton.click()
|
||||
return downloadPromise
|
||||
}
|
||||
|
||||
async waitForDownloading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Downloading', {timeout})
|
||||
}
|
||||
|
||||
async waitForDecrypting(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Decrypting', {timeout})
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectInitialError(messagePattern: string | RegExp) {
|
||||
// For malformed links - error shown in card without #dl-error stage
|
||||
await expect(this.page.locator('.card .error')).toBeVisible()
|
||||
await expect(this.page.locator('.card .error')).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectRuntimeError(messagePattern: string | RegExp) {
|
||||
// For runtime download errors - uses #dl-error stage
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Test Fixtures
|
||||
|
||||
```typescript
|
||||
// test/fixtures.ts
|
||||
import {test as base} from '@playwright/test'
|
||||
import {UploadPage} from './pages/UploadPage'
|
||||
import {DownloadPage} from './pages/DownloadPage'
|
||||
import {readFileSync} from 'fs'
|
||||
|
||||
// Extend Playwright test with page objects
|
||||
export const test = base.extend<{
|
||||
uploadPage: UploadPage
|
||||
downloadPage: DownloadPage
|
||||
}>({
|
||||
uploadPage: async ({page}, use) => {
|
||||
const uploadPage = new UploadPage(page)
|
||||
await uploadPage.goto()
|
||||
await use(uploadPage)
|
||||
},
|
||||
downloadPage: async ({page}, use) => {
|
||||
await use(new DownloadPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export {expect} from '@playwright/test'
|
||||
|
||||
// Test data helpers
|
||||
export function createTestContent(size: number, fill = 0x41): Buffer {
|
||||
return Buffer.alloc(size, fill)
|
||||
}
|
||||
|
||||
export function createTextContent(text: string): Buffer {
|
||||
return Buffer.from(text, 'utf-8')
|
||||
}
|
||||
|
||||
export function uniqueFileName(base: string, ext = 'txt'): string {
|
||||
return `${base}-${Date.now()}.${ext}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Upload Flow Tests
|
||||
|
||||
### 4.1 File Selection - File Picker Button
|
||||
|
||||
**Test ID**: `upload-file-picker`
|
||||
|
||||
```typescript
|
||||
test('upload via file picker button', async ({uploadPage}) => {
|
||||
await uploadPage.expectDropZoneVisible()
|
||||
|
||||
await uploadPage.selectTextFile('picker-test.txt', 'test content ' + Date.now())
|
||||
await uploadPage.waitForEncrypting()
|
||||
await uploadPage.waitForUploading()
|
||||
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toMatch(/^http:\/\/localhost:\d+\/#/)
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 File Selection - Drag and Drop
|
||||
|
||||
**Test ID**: `upload-drag-drop`
|
||||
|
||||
```typescript
|
||||
test('upload via drag and drop', async ({uploadPage}) => {
|
||||
await uploadPage.dragDropFile('dragdrop-test.txt', createTextContent('drag drop test'))
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toContain('#')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 File Size Validation - Too Large
|
||||
|
||||
**Test ID**: `upload-file-too-large`
|
||||
|
||||
```typescript
|
||||
test('upload rejects file over 100MB', async ({uploadPage}) => {
|
||||
await uploadPage.selectLargeFile('large.bin', 100 * 1024 * 1024 + 1)
|
||||
await uploadPage.expectError('too large')
|
||||
await uploadPage.expectError('100 MB')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.4 File Size Validation - Empty File
|
||||
|
||||
**Test ID**: `upload-file-empty`
|
||||
|
||||
```typescript
|
||||
test('upload rejects empty file', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('empty.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.5 Progress Display
|
||||
|
||||
**Test ID**: `upload-progress-display`
|
||||
|
||||
```typescript
|
||||
test('upload shows progress during encryption and upload', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('progress-test.bin', createTestContent(500 * 1024))
|
||||
|
||||
await uploadPage.expectProgressVisible()
|
||||
await uploadPage.waitForEncrypting()
|
||||
await uploadPage.waitForUploading()
|
||||
await uploadPage.waitForShareLink()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.6 Cancel Button
|
||||
|
||||
**Test ID**: `upload-cancel`
|
||||
|
||||
```typescript
|
||||
test('cancel button aborts upload and returns to landing', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('cancel-test.bin', createTestContent(1024 * 1024))
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
await uploadPage.clickCancel()
|
||||
|
||||
await uploadPage.expectDropZoneVisible()
|
||||
await expect(uploadPage.shareLink).toBeHidden()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.7 Share Link Display and Copy
|
||||
|
||||
**Test ID**: `upload-share-link-copy`
|
||||
|
||||
```typescript
|
||||
test('share link copy button works', async ({uploadPage, context}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
|
||||
await uploadPage.selectTextFile('copy-test.txt', 'copy test content')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await uploadPage.clickCopy()
|
||||
|
||||
// Verify clipboard (may fail in headless)
|
||||
try {
|
||||
const clipboardText = await uploadPage.page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(clipboardText).toBe(link)
|
||||
} catch {
|
||||
// Clipboard API may not be available
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4.8 Error Handling and Retry
|
||||
|
||||
**Test ID**: `upload-error-retry`
|
||||
|
||||
```typescript
|
||||
test('error state shows retry button', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('error-test.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
await expect(uploadPage.retryButton).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Download Flow Tests
|
||||
|
||||
### 5.1 Invalid Link Handling - Malformed Hash
|
||||
|
||||
**Test ID**: `download-invalid-hash-malformed`
|
||||
|
||||
```typescript
|
||||
test('download shows error for malformed hash', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#not-valid-base64!!!')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
await expect(downloadPage.downloadButton).not.toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### 5.2 Invalid Link Handling - Valid Base64 but Invalid Structure
|
||||
|
||||
**Test ID**: `download-invalid-hash-structure`
|
||||
|
||||
```typescript
|
||||
test('download shows error for invalid structure', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#AAAA')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 Download Button Click
|
||||
|
||||
**Test ID**: `download-button-click`
|
||||
|
||||
```typescript
|
||||
test('download button initiates download', async ({uploadPage, downloadPage}) => {
|
||||
// Upload first
|
||||
await uploadPage.selectTextFile('dl-btn-test.txt', 'download test content')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
// Navigate to download
|
||||
await downloadPage.gotoWithLink(link)
|
||||
await downloadPage.expectFileReady()
|
||||
|
||||
// Click download
|
||||
const download = await downloadPage.clickDownload()
|
||||
expect(download.suggestedFilename()).toBe('dl-btn-test.txt')
|
||||
})
|
||||
```
|
||||
|
||||
### 5.4 Progress Display
|
||||
|
||||
**Test ID**: `download-progress-display`
|
||||
|
||||
```typescript
|
||||
test('download shows progress', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('dl-progress.bin', createTestContent(500 * 1024))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const downloadPromise = downloadPage.clickDownload()
|
||||
|
||||
await downloadPage.expectProgressVisible()
|
||||
await downloadPage.waitForDownloading()
|
||||
|
||||
await downloadPromise
|
||||
})
|
||||
```
|
||||
|
||||
### 5.5 File Save Verification
|
||||
|
||||
**Test ID**: `download-file-save`
|
||||
|
||||
```typescript
|
||||
test('downloaded file content matches upload', async ({uploadPage, downloadPage}) => {
|
||||
const content = 'verification content ' + Date.now()
|
||||
const fileName = 'verify.txt'
|
||||
|
||||
await uploadPage.selectTextFile(fileName, content)
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const downloadedContent = (await import('fs')).readFileSync(path, 'utf-8')
|
||||
expect(downloadedContent).toBe(content)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases
|
||||
|
||||
### 6.1 Very Small Files
|
||||
|
||||
**Test ID**: `edge-small-file`
|
||||
|
||||
```typescript
|
||||
test('upload and download 1-byte file', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('tiny.bin', Buffer.from([0x42]))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe('tiny.bin')
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const content = (await import('fs')).readFileSync(path)
|
||||
expect(content.length).toBe(1)
|
||||
expect(content[0]).toBe(0x42)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.2 Files Near 100MB Limit
|
||||
|
||||
**Test ID**: `edge-near-limit`
|
||||
|
||||
```typescript
|
||||
test.slow()
|
||||
test('upload file at exactly 100MB', async ({uploadPage}) => {
|
||||
await uploadPage.selectLargeFile('exactly-100mb.bin', 100 * 1024 * 1024)
|
||||
|
||||
// Should succeed (not show error)
|
||||
await expect(uploadPage.errorStage).toBeHidden({timeout: 5000})
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
// Wait for completion (may take a while)
|
||||
await uploadPage.waitForShareLink(300_000)
|
||||
})
|
||||
```
|
||||
|
||||
### 6.3 Special Characters in Filename
|
||||
|
||||
**Test ID**: `edge-special-chars-filename`
|
||||
|
||||
```typescript
|
||||
test('upload and download file with unicode filename', async ({uploadPage, downloadPage}) => {
|
||||
const fileName = 'test-\u4e2d\u6587-\u0420\u0443\u0441\u0441\u043a\u0438\u0439.txt'
|
||||
|
||||
await uploadPage.selectTextFile(fileName, 'unicode filename test')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
})
|
||||
|
||||
test('upload and download file with spaces', async ({uploadPage, downloadPage}) => {
|
||||
const fileName = 'my document (final) v2.txt'
|
||||
|
||||
await uploadPage.selectTextFile(fileName, 'spaces test')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
})
|
||||
|
||||
test('filename with path separators is sanitized', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectTextFile('../../../etc/passwd', 'path traversal test')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).not.toContain('/')
|
||||
expect(download.suggestedFilename()).not.toContain('\\')
|
||||
})
|
||||
```
|
||||
|
||||
### 6.4 Network Errors (Mocked)
|
||||
|
||||
**Test ID**: `edge-network-error`
|
||||
|
||||
```typescript
|
||||
test('upload handles network error gracefully', async ({uploadPage}) => {
|
||||
// Intercept and abort POST requests
|
||||
await uploadPage.page.route('**/localhost:*', route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.abort('failed')
|
||||
} else {
|
||||
route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await uploadPage.selectTextFile('network-error.txt', 'network error test')
|
||||
await uploadPage.expectError(/.+/) // Any error message
|
||||
})
|
||||
```
|
||||
|
||||
### 6.5 Binary File Content Integrity
|
||||
|
||||
**Test ID**: `edge-binary-content`
|
||||
|
||||
```typescript
|
||||
test('binary file with all byte values', async ({uploadPage, downloadPage}) => {
|
||||
// Create buffer with all 256 byte values
|
||||
const buffer = Buffer.alloc(256)
|
||||
for (let i = 0; i < 256; i++) buffer[i] = i
|
||||
|
||||
await uploadPage.selectFile('all-bytes.bin', buffer)
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const content = (await import('fs')).readFileSync(path)
|
||||
expect(content.length).toBe(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(content[i]).toBe(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.6 Multiple Concurrent Downloads
|
||||
|
||||
**Test ID**: `edge-concurrent-downloads`
|
||||
|
||||
```typescript
|
||||
test('concurrent downloads from same link', async ({browser}) => {
|
||||
const context = await browser.newContext({ignoreHTTPSErrors: true})
|
||||
const page1 = await context.newPage()
|
||||
const upload = new UploadPage(page1)
|
||||
|
||||
await upload.goto()
|
||||
await upload.selectTextFile('concurrent.txt', 'concurrent download test')
|
||||
const link = await upload.waitForShareLink()
|
||||
const hash = upload.getHashFromLink(link)
|
||||
|
||||
// Open two tabs and download concurrently
|
||||
const page2 = await context.newPage()
|
||||
const page3 = await context.newPage()
|
||||
const dl2 = new DownloadPage(page2)
|
||||
const dl3 = new DownloadPage(page3)
|
||||
|
||||
await dl2.goto(hash)
|
||||
await dl3.goto(hash)
|
||||
|
||||
const [download2, download3] = await Promise.all([
|
||||
dl2.clickDownload(),
|
||||
dl3.clickDownload()
|
||||
])
|
||||
|
||||
expect(download2.suggestedFilename()).toBe('concurrent.txt')
|
||||
expect(download3.suggestedFilename()).toBe('concurrent.txt')
|
||||
|
||||
await context.close()
|
||||
})
|
||||
```
|
||||
|
||||
### 6.7 Redirect File Handling (Multi-chunk)
|
||||
|
||||
**Test ID**: `edge-redirect-file`
|
||||
|
||||
```typescript
|
||||
test.slow()
|
||||
test('upload and download multi-chunk file with redirect', async ({uploadPage, downloadPage}) => {
|
||||
// Use ~5MB file to get multiple chunks
|
||||
await uploadPage.selectLargeFile('multi-chunk.bin', 5 * 1024 * 1024)
|
||||
const link = await uploadPage.waitForShareLink(120_000)
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe('multi-chunk.bin')
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const stat = (await import('fs')).statSync(path)
|
||||
expect(stat.size).toBe(5 * 1024 * 1024)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.8 UI Information Display
|
||||
|
||||
**Test ID**: `edge-ui-info`
|
||||
|
||||
```typescript
|
||||
test('upload complete shows expiry and security note', async ({uploadPage}) => {
|
||||
await uploadPage.selectTextFile('ui-test.txt', 'ui test')
|
||||
await uploadPage.waitForShareLink()
|
||||
|
||||
await uploadPage.expectCompleteWithExpiry()
|
||||
await uploadPage.expectSecurityNote()
|
||||
})
|
||||
|
||||
test('download page shows file size and security note', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('size-test.bin', createTestContent(1024))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
await downloadPage.expectFileSizeDisplayed()
|
||||
await downloadPage.expectSecurityNote()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order
|
||||
|
||||
### Phase 1: Core Infrastructure (Priority: High)
|
||||
1. Create `test/pages/UploadPage.ts` with Page Object
|
||||
2. Create `test/pages/DownloadPage.ts` with Page Object
|
||||
3. Create `test/fixtures.ts` with extended test function
|
||||
4. Refactor existing test to use Page Objects
|
||||
|
||||
### Phase 2: Core Happy Path (Priority: High)
|
||||
5. `upload-file-picker` - Basic upload via file picker
|
||||
6. `download-button-click` - Basic download
|
||||
7. `download-file-save` - Content verification
|
||||
|
||||
### Phase 3: Validation (Priority: High)
|
||||
8. `upload-file-too-large` - Size validation
|
||||
9. `upload-file-empty` - Empty file validation
|
||||
10. `download-invalid-hash-malformed` - Invalid link handling
|
||||
11. `download-invalid-hash-structure` - Invalid structure handling
|
||||
|
||||
### Phase 4: Progress and Cancel (Priority: Medium)
|
||||
12. `upload-progress-display` - Progress visibility
|
||||
13. `upload-cancel` - Cancel functionality
|
||||
14. `download-progress-display` - Download progress
|
||||
|
||||
### Phase 5: Link Sharing (Priority: Medium)
|
||||
15. `upload-share-link-copy` - Copy button functionality
|
||||
16. `upload-drag-drop` - Drag-drop upload
|
||||
|
||||
### Phase 6: Edge Cases (Priority: Low)
|
||||
17. `edge-small-file` - 1-byte file
|
||||
18. `edge-special-chars-filename` - Unicode/special characters
|
||||
19. `edge-binary-content` - Binary content integrity
|
||||
20. `edge-near-limit` - 100MB file (slow test)
|
||||
21. `edge-network-error` - Network error handling
|
||||
|
||||
### Phase 7: Error Recovery and Advanced (Priority: Low)
|
||||
22. `upload-error-retry` - Retry after error
|
||||
23. `edge-concurrent-downloads` - Concurrent access
|
||||
24. `edge-redirect-file` - Multi-chunk file with redirect (slow)
|
||||
25. `edge-ui-info` - Expiry message, security notes
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Utilities
|
||||
|
||||
### 8.1 Shared Test Setup
|
||||
|
||||
```typescript
|
||||
// test/page.spec.ts
|
||||
import {test, expect, createTestContent, createTextContent, uniqueFileName} from './fixtures'
|
||||
|
||||
test.describe('Upload Flow', () => {
|
||||
test('upload via file picker', async ({uploadPage}) => {
|
||||
// Tests use uploadPage fixture which navigates automatically
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Download Flow', () => {
|
||||
test('download works', async ({uploadPage, downloadPage}) => {
|
||||
// Both pages available via fixtures
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Cases', () => {
|
||||
// Edge case tests
|
||||
})
|
||||
```
|
||||
|
||||
### 8.2 File Structure
|
||||
|
||||
```
|
||||
xftp-web/test/
|
||||
├── fixtures.ts # Playwright fixtures with page objects
|
||||
├── pages/
|
||||
│ ├── UploadPage.ts # Upload page object
|
||||
│ └── DownloadPage.ts # Download page object
|
||||
├── page.spec.ts # All E2E tests
|
||||
└── globalSetup.ts # Server startup (existing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Test Matrix
|
||||
|
||||
| Test ID | Category | Priority | Estimated Time | Dependencies |
|
||||
|---------|----------|----------|----------------|--------------|
|
||||
| upload-file-picker | Upload | High | 30s | - |
|
||||
| upload-drag-drop | Upload | Medium | 30s | - |
|
||||
| upload-file-too-large | Upload | High | 5s | - |
|
||||
| upload-file-empty | Upload | High | 5s | - |
|
||||
| upload-progress-display | Upload | Medium | 45s | - |
|
||||
| upload-cancel | Upload | Medium | 30s | - |
|
||||
| upload-share-link-copy | Upload | Medium | 30s | - |
|
||||
| upload-error-retry | Upload | Low | 30s | - |
|
||||
| download-invalid-hash-malformed | Download | High | 5s | - |
|
||||
| download-invalid-hash-structure | Download | High | 5s | - |
|
||||
| download-button-click | Download | High | 45s | upload |
|
||||
| download-progress-display | Download | Medium | 60s | upload |
|
||||
| download-file-save | Download | High | 45s | upload |
|
||||
| edge-small-file | Edge | Low | 30s | - |
|
||||
| edge-near-limit | Edge | Low | 300s | - |
|
||||
| edge-special-chars-filename | Edge | Low | 30s | - |
|
||||
| edge-network-error | Edge | Low | 45s | - |
|
||||
| edge-binary-content | Edge | Low | 30s | - |
|
||||
| edge-concurrent-downloads | Edge | Low | 60s | upload |
|
||||
| edge-redirect-file | Edge | Low | 120s | - |
|
||||
| edge-ui-info | Edge | Low | 60s | upload |
|
||||
|
||||
**Total estimated time**: ~18 minutes (excluding 100MB and 5MB tests)
|
||||
@@ -0,0 +1,221 @@
|
||||
# XFTP Web Hello Header — Session Re-handshake for Browser Connection Reuse
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Browser HTTP/2 connection pooling reuses TLS connections across page navigations (same origin = same connection pool). The XFTP server maintains per-TLS-connection session state in `TMap SessionId Handshake` keyed by `tlsUniq tls`. When a browser navigates from the upload page to the download page (or reloads), the new page sends a fresh ClientHello on the reused HTTP/2 connection. The server is already in `HandshakeAccepted` state for that connection, so it routes the request to `processRequest`, which expects a 16384-byte command block but receives a 34-byte ClientHello → `ERR BLOCK`.
|
||||
|
||||
**Root cause**: The server cannot distinguish a ClientHello from a command on an already-handshaked connection because both arrive on the same HTTP/2 connection (same `tlsUniq`), and there is no content-level discriminator (ClientHello is unpadded, but the server never gets to parse it — the size check in `processRequest` rejects it first).
|
||||
|
||||
**Browser limitation**: `fetch()` provides zero control over HTTP/2 connection pooling. There is no browser API to force a new connection or detect connection reuse before a request is sent.
|
||||
|
||||
## 2. Solution Summary
|
||||
|
||||
Add an HTTP header `xftp-web-hello` to web ClientHello requests. When the server sees this header on an already-handshaked connection (`HandshakeAccepted` state), it re-runs `processHello` **reusing the existing session keys** (same X25519 key pair from the original handshake). The client then completes the normal handshake flow (sends ClientHandshake, receives ack) and proceeds with commands.
|
||||
|
||||
Key properties:
|
||||
- Server reuses existing `serverPrivKey` — no new key material generated on re-handshake, so `thAuth` remains consistent with any in-flight commands on concurrent HTTP/2 streams.
|
||||
- Header is only checked when `sniUsed` is true (web/browser connections). Native XFTP clients are unaffected.
|
||||
- CORS preflight already allows all headers (`Access-Control-Allow-Headers: *`).
|
||||
- Web clients always send this header on ClientHello — it's harmless on first connection (`Nothing` state) and enables re-handshake on reused connections (`HandshakeAccepted` state).
|
||||
|
||||
## 3. Detailed Technical Design
|
||||
|
||||
### 3.1 Server change: parameterize `processHello` (`src/Simplex/FileTransfer/Server.hs`)
|
||||
|
||||
The entire server change is parameterizing the existing `processHello` with `Maybe C.PrivateKeyX25519`. Zero new functions.
|
||||
|
||||
#### Current code (lines 165-191):
|
||||
|
||||
```haskell
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions
|
||||
XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams) -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
processHello = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
| sniUsed -> do
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- atomically . C.generateKeyPair =<< asks random
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
-- ...build and send ServerHandshake...
|
||||
pure Nothing
|
||||
```
|
||||
|
||||
#### After (diff is ~10 lines):
|
||||
|
||||
```haskell
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions
|
||||
XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, request, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
-- ^^^^^^^ bind request
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello Nothing
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams)
|
||||
| webHello -> processHello (serverPrivKey <$> thAuth thParams)
|
||||
| otherwise -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
webHello = sniUsed && any (\(t, _) -> tokenKey t == "xftp-web-hello") (fst $ H.requestHeaders request)
|
||||
processHello pk_ = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
| sniUsed -> do
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- maybe
|
||||
(atomically . C.generateKeyPair =<< asks random)
|
||||
(\pk -> pure (C.publicKey pk, pk))
|
||||
pk_
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
-- ...rest unchanged...
|
||||
pure Nothing
|
||||
```
|
||||
|
||||
#### What changes:
|
||||
|
||||
1. **Bind `request`** in the `XFTPTransportRequest` pattern (+1 field)
|
||||
2. **Add `webHello`** binding in `where` clause (1 line) — checks header only when `sniUsed`
|
||||
3. **Add `pk_` parameter** to `processHello` (change signature)
|
||||
4. **Replace key generation** with `maybe` that generates fresh keys when `pk_ = Nothing`, or derives public from existing private when `pk_ = Just pk` (3 lines replace 1 line)
|
||||
5. **Add guard** in `HandshakeAccepted` branch (2 lines replace 1 line)
|
||||
6. **Call site** `Nothing -> processHello Nothing` (+1 word)
|
||||
7. **One import** added: `Network.HPACK.Token (tokenKey)`
|
||||
|
||||
#### Imports to add:
|
||||
|
||||
```haskell
|
||||
import Network.HPACK.Token (tokenKey)
|
||||
```
|
||||
|
||||
`OverloadedStrings` (already enabled in Server.hs) provides the `IsString` instance for `CI ByteString`, so `tokenKey t == "xftp-web-hello"` works without importing `Data.CaseInsensitive`. Verified on Hackage: `requestHeaders :: Request -> HeaderTable`, `tokenKey :: Token -> CI ByteString`.
|
||||
|
||||
### 3.2 Re-handshake flow
|
||||
|
||||
When `webHello` is true in `HandshakeAccepted` state:
|
||||
|
||||
1. `processHello (serverPrivKey <$> thAuth thParams)` is called with `Just pk` (existing private key)
|
||||
2. `(k, pk) <- pure (C.publicKey pk, pk)` — reuses same key pair, no generation
|
||||
3. `TM.insert sessionId (HandshakeSent pk) sessions` — transitions state back to `HandshakeSent` with same `pk`
|
||||
4. Server sends `ServerHandshake` response (same format as initial handshake)
|
||||
5. Client sends `ClientHandshake` on next stream → enters `Just (HandshakeSent pk) -> processClientHandshake pk` → normal flow
|
||||
6. `processClientHandshake` stores `HandshakeAccepted thParams` with same `serverPrivKey = pk`
|
||||
|
||||
### 3.3 Web client change (`xftp-web/src/client.ts`)
|
||||
|
||||
Add optional `headers?` parameter to `Transport.post()`, thread it through `fetch()` and `session.request()`, and pass `{"xftp-web-hello": "1"}` in the ClientHello call in `connectXFTP`.
|
||||
|
||||
### 3.4 What does NOT change
|
||||
|
||||
- **CORS**: Already has `Access-Control-Allow-Headers: *` (Server.hs:106).
|
||||
- **Native Haskell client**: Uses `[]` headers. No header = existing behavior.
|
||||
- **Protocol wire format**: ClientHello, ServerHandshake, ClientHandshake, commands — all unchanged.
|
||||
- **`processRequest`**, **`processClientHandshake`**, **`sendError`**, **`encodeXftp`** — unchanged.
|
||||
|
||||
### 3.5 Haskell test (`tests/XFTPServerTests.hs`)
|
||||
|
||||
Add `testWebReHandshake` next to the existing `testWebHandshake` (line 504). It reuses the same SNI + HTTP/2 setup pattern, performs a full handshake, then sends a second ClientHello with the `xftp-web-hello` header on the same connection and verifies the server responds with a valid ServerHandshake (same `sessionId`), then completes the second handshake.
|
||||
|
||||
```haskell
|
||||
-- Register in xftpServerTests (after line 86):
|
||||
it "should re-handshake on same connection with xftp-web-hello header" testWebReHandshake
|
||||
|
||||
-- Test (after testWebHandshake):
|
||||
testWebReHandshake :: Expectation
|
||||
testWebReHandshake =
|
||||
withXFTPServerSNI $ \_ -> do
|
||||
Fingerprint fp <- loadFileFingerprint "tests/fixtures/ca.crt"
|
||||
let keyHash = C.KeyHash fp
|
||||
cfg = defaultTransportClientConfig {clientALPN = Just ["h2"], useSNI = True}
|
||||
runTLSTransportClient defaultSupportedParamsHTTPS Nothing cfg Nothing "localhost" xftpTestPort (Just keyHash) $ \(tls :: TLS 'TClient) -> do
|
||||
let h2cfg = HC.defaultHTTP2ClientConfig {HC.bodyHeadSize = 65536}
|
||||
h2 <- either (error . show) pure =<< HC.attachHTTP2Client h2cfg (THDomainName "localhost") xftpTestPort mempty 65536 tls
|
||||
g <- C.newRandom
|
||||
-- First handshake (same as testWebHandshake)
|
||||
challenge1 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq1 = H2.requestBuilder "POST" "/" [] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge1}))
|
||||
resp1 <- either (error . show) pure =<< HC.sendRequest h2 helloReq1 (Just 5000000)
|
||||
shs1 <- either error pure $ smpDecode =<< C.unPad (bodyHead (HC.respBody resp1))
|
||||
let XFTPServerHandshake {sessionId = sid1} = shs1
|
||||
clientHsPadded <- either (error . show) pure $ C.pad (smpEncode (XFTPClientHandshake {xftpVersion = VersionXFTP 1, keyHash})) xftpBlockSize
|
||||
resp1b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp1b)) `shouldBe` 0
|
||||
-- Second handshake on same connection with xftp-web-hello header
|
||||
challenge2 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq2 = H2.requestBuilder "POST" "/" [("xftp-web-hello", "1")] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge2}))
|
||||
resp2 <- either (error . show) pure =<< HC.sendRequest h2 helloReq2 (Just 5000000)
|
||||
shs2 <- either error pure $ smpDecode =<< C.unPad (bodyHead (HC.respBody resp2))
|
||||
let XFTPServerHandshake {sessionId = sid2} = shs2
|
||||
sid2 `shouldBe` sid1 -- same TLS connection → same sessionId
|
||||
-- Complete second handshake
|
||||
resp2b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp2b)) `shouldBe` 0
|
||||
```
|
||||
|
||||
The only difference from `testWebHandshake`: the second `helloReq2` passes `[("xftp-web-hello", "1")]` instead of `[]`. The test verifies:
|
||||
1. Server responds with `ServerHandshake` (not `ERR BLOCK`)
|
||||
2. Same `sessionId` (same TLS connection)
|
||||
3. Second `ClientHandshake` completes with empty ACK
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Step 1: Server — parameterize `processHello`
|
||||
|
||||
Apply the diff from Section 3.1 to `src/Simplex/FileTransfer/Server.hs`.
|
||||
|
||||
### Step 2: Test — add `testWebReHandshake`
|
||||
|
||||
Add the test from Section 3.5 to `tests/XFTPServerTests.hs`.
|
||||
|
||||
### Step 3: Client — add `xftp-web-hello` header
|
||||
|
||||
Add optional `headers?` to `Transport.post()`, pass `{"xftp-web-hello": "1"}` on ClientHello in `connectXFTP`.
|
||||
|
||||
### Step 4: Test
|
||||
|
||||
Run Haskell tests (`cabal test`) and E2E Playwright tests (`npx playwright test` in `xftp-web/`).
|
||||
|
||||
## 5. Race Condition Analysis
|
||||
|
||||
### Single-tab navigation (the common case)
|
||||
|
||||
1. Upload page completes, all fetch() requests finish
|
||||
2. Browser navigates to download page (or reloads)
|
||||
3. All upload-page fetches are aborted on page unload
|
||||
4. Download page sends ClientHello with `xftp-web-hello` header
|
||||
5. Server is in `HandshakeAccepted` → `processHello (Just pk)` → `HandshakeSent pk` (same key)
|
||||
6. No concurrent streams → no race
|
||||
|
||||
**Safe.**
|
||||
|
||||
### Multi-tab (edge case)
|
||||
|
||||
Tab A (upload) and Tab B (download) share the same HTTP/2 connection.
|
||||
|
||||
1. Tab A has active command streams (e.g., FPUT upload in progress)
|
||||
2. Tab B sends ClientHello with header
|
||||
3. Server reads `HandshakeAccepted` atomically for both streams
|
||||
4. Tab A's stream already has its `thParams` snapshot → proceeds with `processRequest` using old `thParams`
|
||||
5. Tab B's stream triggers `processHello (Just pk)` → stores `HandshakeSent pk` (same pk!)
|
||||
6. Tab A's in-progress FPUT continues with snapshot `thParams` → completes normally (same `serverPrivKey`)
|
||||
7. Tab A's NEXT command reads `HandshakeSent` from TMap → enters `processClientHandshake` → fails (command body ≠ ClientHandshake format) → HANDSHAKE error
|
||||
|
||||
**Tab A's in-flight commands succeed. Tab A's subsequent commands fail with HANDSHAKE error.** This is the inherent multi-tab problem — unavoidable with per-connection session state and HTTP/2 connection sharing. The failure is clean (HANDSHAKE error, not silent corruption).
|
||||
|
||||
## 6. Security Considerations
|
||||
|
||||
- **No new key material**: Re-handshake reuses existing `serverPrivKey`. No opportunity for key confusion or downgrade.
|
||||
- **Identity re-verification**: Server re-signs the web challenge with its long-term signing key. Client verifies identity again.
|
||||
- **Header cannot escalate privileges**: The header only triggers re-handshake (which the server was already capable of doing on first connection). It does not bypass any authentication.
|
||||
- **Timing**: Re-handshake takes the same code path as initial handshake, so timing side-channels are unchanged.
|
||||
@@ -0,0 +1,948 @@
|
||||
# XFTP Web Error Handling and Connection Resilience
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The XFTP web client is fundamentally fragile: any transient error (browser opening a new HTTP/2 connection, network hiccup, server restart) causes an unrecoverable failure with a cryptic error message. There is no retry logic, no fetch timeout, no error categorization, and the upload uses a single server instead of distributing chunks across preset servers. This makes the app frustrating — it works most of the time but fails unpredictably, which is worse than being completely broken.
|
||||
|
||||
### Confirmed root cause (from diagnostic logs)
|
||||
|
||||
When the browser opens a new HTTP/2 connection mid-operation, the new connection has a different TLS SessionId with no handshake state in the server's `TMap SessionId Handshake`. The server's `Nothing` branch in `xftpServerHandshakeV1` (Server.hs:169) unconditionally calls `processHello`, which tries to decode the command body as `XFTPClientHello`, fails, and sends a raw padded "HANDSHAKE" error string. The client cannot parse this as a proper transmission (first byte 'H' = 72 is read as batch count), producing `"expected batch count 1, got 72"`.
|
||||
|
||||
Server log confirming the SessionId change:
|
||||
```
|
||||
DEBUG dispatch: Accepted+command sessId="ZSo1GGETgIvjbB7CWHbvGPpbMjx_b2IlC1eTI6aKfqc="
|
||||
...20 successful commands...
|
||||
DEBUG dispatch: Nothing sessId="mJC7Sck9xxW5UsXoPGoUWduuHghSVgf6CnD6ZC6SBhU=" webHello=False
|
||||
```
|
||||
|
||||
### Why re-handshake is required (cannot be made optional)
|
||||
|
||||
1. **SessionId is baked into signed command data.** `encodeAuthTransmission` signs `concat(encode(sessionId), tInner)` with Ed25519. Server's `tDecodeServer` (Protocol.hs:2242) verifies `sessId == sessionId`. New connection = different sessionId = signature mismatch.
|
||||
2. **Server generates per-session DH keys.** `processHello` creates fresh X25519 keypair stored in `HandshakeSent`. For SMP browser clients (future), `verifyCmdAuth` (Protocol.hs:1322) requires the matching `serverPrivKey` from `thAuth`.
|
||||
3. **This applies to both XFTP and future SMP browser clients** — the session management approach is the same.
|
||||
|
||||
### Why multiple preset servers cannot work
|
||||
|
||||
Upload (`agent.ts:105-157`) takes a single `server: XFTPServer` parameter and uploads ALL chunks to it. `web/upload.ts:133` calls `pickRandomServer(servers)` which selects ONE random server from all presets. The multi-server preset configuration is pointless — only one server is ever used per upload. The design intent (RFC section 11.6: "upload in parallel to 8 randomly selected servers") is not implemented. This must be fixed in Phase 2 (section 3.7).
|
||||
|
||||
## 2. Solution Summary
|
||||
|
||||
### Phase 1: Error handling and connection resilience
|
||||
|
||||
1. **Server: strict dispatch for allowed protocol combinations** — reject all invalid combinations
|
||||
2. **Client: automatic retry with re-handshake** on SESSION/HANDSHAKE errors
|
||||
3. **Client: fetch timeout** with configurable duration
|
||||
4. **UI: error categorization and retry** — auto-retry temporary, human-readable permanent
|
||||
5. **Client: connection state with Promise-based lock and per-server queues** — `ServerConnection` with `client: Promise<XFTPClient>` + `queue: Promise<void>`
|
||||
6. **Client: fix cache key** — include keyHash
|
||||
|
||||
### Phase 2: Multi-server upload (after Phase 1)
|
||||
|
||||
7. **Multi-server upload with server selection and failover** — distribute chunks across servers, retry FNEW on different server if one fails
|
||||
|
||||
## 3. Detailed Technical Design
|
||||
|
||||
### 3.1 Server: strict dispatch for allowed protocol combinations
|
||||
|
||||
**Principle:** Everything not explicitly done by existing Haskell/TS clients is prohibited. It is better to fail on impossible combinations than to be permissive — permissiveness complicates debugging and creates attack vectors via unexpected behaviors.
|
||||
|
||||
**Allowed behaviors by client type:**
|
||||
|
||||
| Client | SNI | webHello header | Hello body | When |
|
||||
|--------|-----|----------------|------------|------|
|
||||
| Haskell | No | No | Empty | New connection only |
|
||||
| Web | Yes | Yes | Non-empty (XFTPClientHello) | New OR existing connection |
|
||||
|
||||
**Minimal surgical change.** The existing dispatch (Server.hs:169-189) already correctly handles `HandshakeSent` and `HandshakeAccepted` — their guards cover all valid and invalid combinations. The ONLY missing case is `Nothing` + web client sending a command on a stale session.
|
||||
|
||||
`processHello` (Server.hs:194-217) already internally routes: `B.null bodyHead` → Haskell hello, `sniUsed` → web hello decode, else → HANDSHAKE. For stale web sessions, it currently tries to decode a command body as `XFTPClientHello`, fails, and throws HANDSHAKE. The fix: detect this case BEFORE calling processHello and throw SESSION instead, so the client knows to re-handshake (not that its hello was malformed).
|
||||
|
||||
**Change: add one guard to `Nothing` branch, remove debug logging.**
|
||||
|
||||
```haskell
|
||||
-- Before (1 line):
|
||||
Nothing -> processHello Nothing
|
||||
|
||||
-- After (3 lines):
|
||||
Nothing
|
||||
| sniUsed && not webHello -> throwE SESSION -- web command on stale session
|
||||
| otherwise -> processHello Nothing -- normal hello (web or Haskell)
|
||||
```
|
||||
|
||||
`throwE SESSION` is caught by `either sendError pure r` (line 190). `sendError` pads `smpEncode SESSION` = `"SESSION"` (Transport.hs:298) to `xftpBlockSize`. The client's padded error detection (section 3.2) catches this as a retriable error and triggers re-handshake. SESSION is a valid `XFTPErrorType` constructor (Transport.hs:225) — no new helpers needed.
|
||||
|
||||
**All other branches remain unchanged.** `HandshakeSent` guards (`webHello` → processHello, `otherwise` → processClientHandshake with body size check inside) are correct. `HandshakeAccepted` guards (`webHello`, `webHandshake`, `otherwise` → command) are correct.
|
||||
|
||||
### 3.2 Client: automatic retry with re-handshake
|
||||
|
||||
**Location:** `sendXFTPCommand` in `client.ts`
|
||||
|
||||
**Design:** Retry loop inside `sendXFTPCommand`. Maximum 3 attempts. On retriable error, close old client, re-handshake, retry.
|
||||
|
||||
**Error classification:**
|
||||
|
||||
| Error | Type | Retriable? | Human-readable message |
|
||||
|-------|------|-----------|----------------------|
|
||||
| Padded "HANDSHAKE" | Temporary | Yes (auto) | "Connection interrupted, reconnecting..." |
|
||||
| Padded "SESSION" | Temporary | Yes (auto) | "Session expired, reconnecting..." |
|
||||
| `FRErr SESSION` | Temporary | Yes (auto) | "Session expired, reconnecting..." |
|
||||
| `FRErr HANDSHAKE` | Temporary | Yes (auto) | "Connection interrupted, reconnecting..." |
|
||||
| `fetch()` TypeError | Temporary | Yes (auto) | "Network error, retrying..." |
|
||||
| AbortError (timeout) | Temporary | Yes (auto) | "Server timeout, retrying..." |
|
||||
| `FRErr AUTH` | Permanent | No | "File is invalid, expired, or has been removed" |
|
||||
| `FRErr NO_FILE` | Permanent | No | "File not found — it may have expired" |
|
||||
| `FRErr SIZE` | Permanent | No | "File size exceeds server limit" |
|
||||
| `FRErr QUOTA` | Permanent | No | "Server storage quota exceeded" |
|
||||
| `FRErr BLOCKED` | Permanent | No | "File has been blocked by server" |
|
||||
| `FRErr DIGEST` | Permanent | No | "File integrity check failed" |
|
||||
| `FRErr INTERNAL` | Permanent | No | "Server internal error" |
|
||||
| `CMD *` | Permanent | No | "Protocol error" |
|
||||
|
||||
**Retry behavior:**
|
||||
- Auto-retry up to 3 times for temporary errors, transparent to user
|
||||
- After 3 failures: show human-readable error with diagnosis, offer manual retry button
|
||||
- Permanent errors: show human-readable error immediately, NO manual retry button (user can reload page)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
async function sendXFTPCommand(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
privateKey: Uint8Array,
|
||||
entityId: Uint8Array,
|
||||
cmdBytes: Uint8Array,
|
||||
chunkData?: Uint8Array,
|
||||
maxRetries: number = 3
|
||||
): Promise<{response: FileResponse, body: Uint8Array}> {
|
||||
let clientP = getXFTPServerClient(agent, server)
|
||||
let client = await clientP
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await sendXFTPCommandOnce(client, privateKey, entityId, cmdBytes, chunkData)
|
||||
} catch (e) {
|
||||
if (!isRetriable(e)) {
|
||||
// Permanent error (AUTH, NO_FILE, etc.) — connection is fine, don't touch it
|
||||
throw categorizeError(e)
|
||||
}
|
||||
if (attempt === maxRetries) {
|
||||
// Retriable error exhausted — connection is bad, remove stale promise
|
||||
removeStaleConnection(agent, server, clientP)
|
||||
throw categorizeError(e)
|
||||
}
|
||||
clientP = reconnectClient(agent, server)
|
||||
client = await clientP
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
```
|
||||
|
||||
**`sendXFTPCommandOnce`** — renamed from current `sendXFTPCommand`. Two changes:
|
||||
|
||||
1. **Padded error detection** (before `decodeTransmission`):
|
||||
|
||||
```typescript
|
||||
// After getting respBlock, before decodeTransmission:
|
||||
const raw = blockUnpad(respBlock)
|
||||
if (raw.length < 20) {
|
||||
const text = new TextDecoder().decode(raw)
|
||||
if (/^[A-Z_]+$/.test(text)) {
|
||||
throw new XFTPRetriableError(text) // "HANDSHAKE" or "SESSION"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **FRErr classification** (replaces current unconditional throw):
|
||||
|
||||
```typescript
|
||||
// After decodeResponse, instead of throw new Error("Server error: " + err.type):
|
||||
if (response.type === "FRErr") {
|
||||
const err = response.err
|
||||
if (err.type === "SESSION" || err.type === "HANDSHAKE") {
|
||||
throw new XFTPRetriableError(err.type)
|
||||
}
|
||||
throw new XFTPPermanentError(err.type, humanReadableMessage(err))
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Client: fetch timeout
|
||||
|
||||
**Location:** `createBrowserTransport` and `createNodeTransport` in `client.ts`
|
||||
|
||||
**Design:** `AbortController` with configurable timeout on every `fetch()`.
|
||||
|
||||
```typescript
|
||||
interface TransportConfig {
|
||||
timeoutMs: number // default 30000, lower for tests
|
||||
}
|
||||
|
||||
function createBrowserTransport(baseUrl: string, config: TransportConfig): Transport {
|
||||
return {
|
||||
async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
||||
try {
|
||||
const resp = await fetch(effectiveUrl, {
|
||||
method: "POST", headers, body,
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!resp.ok) throw new Error(`Server request failed: ${resp.status}`)
|
||||
return new Uint8Array(await resp.arrayBuffer())
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
close() {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Node.js transport, use `setTimeout` on the HTTP/2 request stream.
|
||||
|
||||
Default: 30s for production, 5s for tests. Threaded through `connectXFTP` → `createTransport`.
|
||||
|
||||
### 3.4 UI: error categorization and retry
|
||||
|
||||
**Behavior (Option D):**
|
||||
|
||||
- **Temporary errors:** Auto-retry loop (3 attempts). After 3 failures, show human-readable diagnosis with manual retry button. Diagnosis examples: "Server timeout — the server may be temporarily unavailable", "Connection interrupted — your network may be unstable".
|
||||
- **Permanent errors:** Show human-readable error immediately, NO retry button. User can reload page if they want to retry. Examples: "File is invalid, expired, or has been removed" (AUTH), "File not found" (NO_FILE).
|
||||
|
||||
**Current UI retry buttons:**
|
||||
- `upload.ts:73-75` — retry calls `startUpload(pendingFile)` from scratch
|
||||
- `download.ts:60` — retry calls `startDownload()` from scratch
|
||||
|
||||
**Improvement:** Track uploaded/downloaded chunk indices. On manual retry, skip completed chunks:
|
||||
|
||||
```typescript
|
||||
// Upload: track which chunks completed
|
||||
const completedChunks: Set<number> = new Set()
|
||||
for (let i = 0; i < specs.length; i++) {
|
||||
if (completedChunks.has(i)) continue
|
||||
// ... create + upload chunk
|
||||
completedChunks.add(i)
|
||||
}
|
||||
|
||||
// Download: already naturally resumable — each chunk is independent
|
||||
```
|
||||
|
||||
### 3.5 Client: connection state with Promise-based lock and per-server queues
|
||||
|
||||
**Design:** Each server gets a `ServerConnection` record containing a `Promise<XFTPClient>` (the connection lock) and a `Promise<void>` (the sequential command queue). The `XFTPClientAgent` maps server keys to these records.
|
||||
|
||||
The promise IS the lock — every consumer awaits the same promise. When reconnect is needed, the promise is replaced atomically.
|
||||
|
||||
```typescript
|
||||
interface ServerConnection {
|
||||
client: Promise<XFTPClient> // resolves to connected client; replaced on reconnect
|
||||
queue: Promise<void> // tail of sequential command chain
|
||||
}
|
||||
|
||||
interface XFTPClientAgent {
|
||||
connections: Map<string, ServerConnection>
|
||||
}
|
||||
|
||||
function newXFTPAgent(): XFTPClientAgent {
|
||||
return {connections: new Map()}
|
||||
}
|
||||
```
|
||||
|
||||
**Connection lifecycle — `getXFTPServerClient` and `reconnectClient`:**
|
||||
|
||||
```typescript
|
||||
function getXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
|
||||
const key = formatXFTPServer(server)
|
||||
let conn = agent.connections.get(key)
|
||||
if (!conn) {
|
||||
const p = connectXFTP(server)
|
||||
conn = {client: p, queue: Promise.resolve()}
|
||||
agent.connections.set(key, conn)
|
||||
// On connection failure, remove from map so next call retries
|
||||
p.catch(() => {
|
||||
const cur = agent.connections.get(key)
|
||||
if (cur && cur.client === p) agent.connections.delete(key)
|
||||
})
|
||||
}
|
||||
return conn.client
|
||||
}
|
||||
|
||||
function reconnectClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
|
||||
const key = formatXFTPServer(server)
|
||||
const old = agent.connections.get(key)
|
||||
// Close old client (fire-and-forget)
|
||||
old?.client.then(c => c.transport.close(), () => {})
|
||||
// Replace with new connection promise — all concurrent callers will await this
|
||||
// Queue survives reconnect — pending operations stay ordered
|
||||
const p = connectXFTP(server)
|
||||
const conn: ServerConnection = {client: p, queue: old?.queue ?? Promise.resolve()}
|
||||
agent.connections.set(key, conn)
|
||||
p.catch(() => {
|
||||
const cur = agent.connections.get(key)
|
||||
if (cur && cur.client === p) agent.connections.delete(key)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
function closeXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): void {
|
||||
const key = formatXFTPServer(server)
|
||||
const conn = agent.connections.get(key)
|
||||
if (conn) {
|
||||
agent.connections.delete(key)
|
||||
conn.client.then(c => c.transport.close(), () => {})
|
||||
}
|
||||
}
|
||||
|
||||
function closeXFTPAgent(agent: XFTPClientAgent): void {
|
||||
for (const conn of agent.connections.values()) {
|
||||
conn.client.then(c => c.transport.close(), () => {})
|
||||
}
|
||||
agent.connections.clear()
|
||||
}
|
||||
```
|
||||
|
||||
**Precise semantics:**
|
||||
|
||||
1. `getXFTPServerClient(agent, server)` — returns existing `conn.client` promise if present, otherwise creates a new `ServerConnection` with fresh connection and empty queue
|
||||
2. When error detected, first caller calls `reconnectClient` which replaces `conn.client` with a new connection promise. The queue is preserved across reconnect.
|
||||
3. All concurrent callers awaiting the OLD promise receive the error
|
||||
4. They then call `getXFTPServerClient` which returns the NEW promise
|
||||
5. If reconnection fails, auto-cleanup (`p.catch(() => delete)`) removes the entry so the next caller starts fresh
|
||||
|
||||
**Stale error cleanup rule:** When a caller exhausts retries for a retriable error, it removes the failed entry from the map (only if no concurrent caller has already replaced it via `reconnectClient`). This prevents the next caller from receiving a stale rejected promise. Permanent errors (AUTH, NO_FILE, etc.) do NOT remove the connection — the transport is fine, only the command failed.
|
||||
|
||||
```typescript
|
||||
function removeStaleConnection(
|
||||
agent: XFTPClientAgent, server: XFTPServer, failedP: Promise<XFTPClient>
|
||||
): void {
|
||||
const key = formatXFTPServer(server)
|
||||
const conn = agent.connections.get(key)
|
||||
// Only remove if current promise is the one that failed — not if already replaced by reconnect
|
||||
if (conn && conn.client === failedP) {
|
||||
agent.connections.delete(key)
|
||||
failedP.then(c => c.transport.close(), () => {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Per-server sequential queue:** `queue` is a `Promise<void>` — the tail of the sequential operation chain. Each new operation `.then()`s onto it. It's `void` because callers hold their own typed promises; the queue only tracks completion order:
|
||||
|
||||
```typescript
|
||||
async function enqueueCommand<T>(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
fn: () => Promise<T> // no client param — fn uses command wrappers (agent+server)
|
||||
): Promise<T> {
|
||||
const key = formatXFTPServer(server)
|
||||
// Ensure connection exists (with auto-cleanup on failure)
|
||||
await getXFTPServerClient(agent, server)
|
||||
const conn = agent.connections.get(key)! // guaranteed to exist after getXFTPServerClient
|
||||
// Chain onto the queue — fn runs after previous operation completes
|
||||
let resolve_: (v: T) => void, reject_: (e: any) => void
|
||||
const result = new Promise<T>((res, rej) => { resolve_ = res; reject_ = rej })
|
||||
conn.queue = conn.queue.then(
|
||||
() => fn().then(resolve_!, reject_!),
|
||||
() => fn().then(resolve_!, reject_!)
|
||||
).then(() => {}, () => {}) // swallow errors in the chain
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Commands to the same server execute one at a time via the queue. Commands to different servers execute concurrently because each has its own queue. `enqueueCommand` provides sequencing; `sendXFTPCommand` (called inside `fn` via command wrappers) provides retry. They compose as: `enqueueCommand` sequences calls to wrappers that internally use `sendXFTPCommand`.
|
||||
|
||||
**Download change:** Group chunks by server, process each server's chunks sequentially, servers in parallel. Uses `for` loop for per-server sequencing (same pattern as Stage 2 upload). `enqueueCommand` is available for cases where different callers target the same server.
|
||||
|
||||
```typescript
|
||||
const byServer = new Map<string, FileChunk[]>()
|
||||
for (const chunk of resolvedFd.chunks) {
|
||||
const srv = chunk.replicas[0]?.server ?? ""
|
||||
if (!byServer.has(srv)) byServer.set(srv, [])
|
||||
byServer.get(srv)!.push(chunk)
|
||||
}
|
||||
await Promise.all([...byServer.entries()].map(async ([srv, chunks]) => {
|
||||
const server = parseXFTPServer(srv)
|
||||
for (const chunk of chunks) {
|
||||
const seed = decodePrivKeyEd25519(chunk.replicas[0].replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
const raw = await downloadXFTPChunkRaw(agent, server, kp.privateKey, chunk.replicas[0].replicaId)
|
||||
await onRawChunk({chunkNo: chunk.chunkNo, dhSecret: raw.dhSecret, nonce: raw.nonce, body: raw.body, digest: chunk.digest})
|
||||
downloaded += chunk.chunkSize
|
||||
onProgress?.(downloaded, resolvedFd.size)
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### 3.6 Fix cache key
|
||||
|
||||
**Bug:** `getXFTPServerClient` (client.ts:110) uses `"https://" + server.host + ":" + server.port` as cache key, ignoring `keyHash`. Two servers with same host:port but different keyHash share a cached connection, bypassing identity verification.
|
||||
|
||||
**Fix:** Use `formatXFTPServer(server)` as cache key (includes keyHash). Already available in `protocol/address.ts:52-54`.
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
const key = "https://" + server.host + ":" + server.port
|
||||
|
||||
// After:
|
||||
const key = formatXFTPServer(server)
|
||||
```
|
||||
|
||||
Note: With the redesign in 3.5, the cache key fix is inherent — the `connections` Map uses `formatXFTPServer(server)` everywhere.
|
||||
|
||||
### 3.7 Phase 2: Multi-server upload with server selection and failover
|
||||
|
||||
**Problem:** Current upload (`agent.ts:105-157`) takes a single `server: XFTPServer` and uploads ALL chunks to it. The 12 preset servers (6 SimpleX + 6 Flux) are pointless — only one is ever used.
|
||||
|
||||
**Design goal:** Distribute chunks across servers. Retry FNEW on a different server if one fails. Once working servers are found, prefer them (heuristic: server unlikely to fail mid-process, more likely to be broken initially due to maintenance/downtime).
|
||||
|
||||
**Reference implementation:** Haskell `Agent.hs:457-486` (`createChunk` / `createWithNextSrv`) + `Client.hs:2335-2385` (`getNextServer_` / `withNextSrv`).
|
||||
|
||||
#### Haskell algorithm summary
|
||||
|
||||
Two-stage architecture:
|
||||
|
||||
1. **Allocate stage (serial per file in Haskell):** For each chunk, call FNEW on a randomly-selected server. If FNEW fails, pick a different server and retry. Track tried hosts to avoid retrying the same server. After all chunks are assigned to servers, spawn one upload worker per server.
|
||||
|
||||
2. **Upload stage (parallel per server):** Each server worker uploads its assigned chunks sequentially (FPUT). On FPUT failure, retry on the same server with backoff (because the chunk replica already exists on that server). No server failover for FPUT.
|
||||
|
||||
Server selection constraints (hierarchical, `getNextServer_` Client.hs:2335-2350):
|
||||
1. Prefer servers from unused operators (operator diversity)
|
||||
2. Prefer servers with unused hosts (host diversity)
|
||||
3. Random pick from the most-constrained candidate set
|
||||
4. If all exhausted, reset tried set and start over
|
||||
|
||||
#### Web client adaptation
|
||||
|
||||
The web client doesn't have operators or a database. Simplified algorithm with two stages:
|
||||
|
||||
**Stage 1 — Allocate:** Create chunk records on servers (FNEW). Unlike Haskell which is serial here, web FNEW runs concurrently within a concurrency limit. FNEW is a small command — concurrent FNEW on the same connection is not a problem, and concurrent FNEW across servers improves upload startup time.
|
||||
|
||||
**Stage 2 — Upload:** Upload chunk data (FPUT). Parallel across servers, sequential per server (reuses per-server queues from 3.5). FPUT retries on the same server with backoff — no server rotation because the chunk replica already exists on that server. Stage 2 reads chunk data by offset (via `readChunk`), so `SentChunk` must be extended with `chunkOffset: number` (from ChunkSpec).
|
||||
|
||||
```typescript
|
||||
interface UploadState {
|
||||
untriedServers: XFTPServer[] // servers not yet attempted — initially all servers
|
||||
workingServers: XFTPServer[] // servers that succeeded FNEW
|
||||
}
|
||||
|
||||
const MAX_FNEW_ATTEMPTS = 5 // per chunk: try up to 5 different servers
|
||||
|
||||
async function uploadFile(
|
||||
agent: XFTPClientAgent,
|
||||
allServers: XFTPServer[],
|
||||
encrypted: EncryptedFileMetadata,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResult> {
|
||||
const state: UploadState = {untriedServers: [...allServers], workingServers: []}
|
||||
const specs = prepareChunkSpecs(encrypted.chunkSizes)
|
||||
const concurrency = options?.concurrency ?? 4
|
||||
|
||||
// Stage 1: Allocate — concurrent FNEW within concurrency limit
|
||||
const sentChunks: SentChunk[] = new Array(specs.length)
|
||||
const queue = specs.map((spec, i) => ({spec, chunkNo: i + 1, index: i}))
|
||||
let idx = 0
|
||||
async function allocateWorker() {
|
||||
while (idx < queue.length) {
|
||||
const item = queue[idx++]
|
||||
const {server, chunk} = await createChunkWithFailover(
|
||||
agent, allServers, state, concurrency, item.spec, item.chunkNo
|
||||
)
|
||||
sentChunks[item.index] = chunk
|
||||
}
|
||||
}
|
||||
const allocateWorkers = Array.from(
|
||||
{length: Math.min(concurrency, queue.length)},
|
||||
() => allocateWorker()
|
||||
)
|
||||
await Promise.all(allocateWorkers)
|
||||
|
||||
// Stage 2: Upload — parallel across servers, sequential per server
|
||||
// readChunk reads from the encrypted file by offset (same as Phase 1 uploadFile)
|
||||
let uploaded = 0
|
||||
const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)
|
||||
const byServer = groupBy(sentChunks, c => formatXFTPServer(c.server))
|
||||
await Promise.all([...byServer.entries()].map(async ([srvKey, chunks]) => {
|
||||
for (const chunk of chunks) {
|
||||
const chunkData = await readChunk(chunk.chunkOffset, chunk.chunkSize)
|
||||
await uploadXFTPChunk(agent, chunk.server, chunk.senderKey, chunk.senderId, chunkData)
|
||||
uploaded += chunk.chunkSize
|
||||
options?.onProgress?.(uploaded, total)
|
||||
}
|
||||
}))
|
||||
|
||||
return buildDescriptions(encrypted, sentChunks)
|
||||
}
|
||||
```
|
||||
|
||||
**`createChunkWithFailover`** — server selection with per-chunk retry limit:
|
||||
|
||||
```typescript
|
||||
async function createChunkWithFailover(
|
||||
agent: XFTPClientAgent,
|
||||
allServers: XFTPServer[],
|
||||
state: UploadState,
|
||||
concurrency: number,
|
||||
spec: ChunkSpec,
|
||||
chunkNo: number
|
||||
): Promise<{server: XFTPServer, chunk: SentChunk}> {
|
||||
const maxAttempts = Math.min(allServers.length, MAX_FNEW_ATTEMPTS)
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const server = pickServer(allServers, state, concurrency)
|
||||
try {
|
||||
const chunk = await createAndPrepareChunk(agent, server, spec, chunkNo)
|
||||
// Success — add to working set (if not already there)
|
||||
if (!state.workingServers.some(s => formatXFTPServer(s) === formatXFTPServer(server))) {
|
||||
state.workingServers.push(server)
|
||||
}
|
||||
return {server, chunk}
|
||||
} catch (e) {
|
||||
// Remove from working if it was there
|
||||
state.workingServers = state.workingServers.filter(
|
||||
s => formatXFTPServer(s) !== formatXFTPServer(server)
|
||||
)
|
||||
if (attempt === maxAttempts - 1) throw e
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
```
|
||||
|
||||
**`pickServer`** — two-list selection:
|
||||
|
||||
```typescript
|
||||
function pickServer(
|
||||
allServers: XFTPServer[],
|
||||
state: UploadState,
|
||||
concurrency: number
|
||||
): XFTPServer {
|
||||
// Once enough working servers found, only use those
|
||||
if (state.workingServers.length >= concurrency) {
|
||||
return randomPick(state.workingServers)
|
||||
}
|
||||
// Still exploring — pick from untried
|
||||
if (state.untriedServers.length > 0) {
|
||||
const idx = Math.floor(Math.random() * state.untriedServers.length)
|
||||
return state.untriedServers.splice(idx, 1)[0] // remove from untried
|
||||
}
|
||||
// All tried — reset untried to non-working servers and retry
|
||||
state.untriedServers = allServers.filter(
|
||||
s => !state.workingServers.some(w => formatXFTPServer(w) === formatXFTPServer(s))
|
||||
)
|
||||
if (state.untriedServers.length > 0) {
|
||||
const idx = Math.floor(Math.random() * state.untriedServers.length)
|
||||
return state.untriedServers.splice(idx, 1)[0]
|
||||
}
|
||||
// Every server is working — pick any working
|
||||
return randomPick(state.workingServers)
|
||||
}
|
||||
```
|
||||
|
||||
**Algorithm:** Two lists — `untriedServers` (initially all) and `workingServers` (initially empty). When `workingServers.length < concurrency`, pick from `untriedServers` (removing on pick). On FNEW success, add to `workingServers`. On FNEW failure, server is already removed from `untriedServers`; remove from `workingServers` if present. When `untriedServers` is empty, reset it to all non-working servers. Once `workingServers.length >= concurrency`, pick randomly only from `workingServers`.
|
||||
|
||||
**Termination condition:** Each chunk tries at most `min(serverCount, 5)` different servers. If all attempts fail, the chunk fails and the upload fails with the last error. Rationale: if 5 out of 12 servers are down, something systemic is wrong and continuing is unlikely to help. Timeouts count as failures — the timed-out server is removed from working and a different server is picked next.
|
||||
|
||||
**Key differences from Haskell:**
|
||||
- No operator concept — just host diversity via random selection
|
||||
- No database — state tracked in-memory during upload
|
||||
- FNEW runs concurrently (Haskell is serial) — improves startup time
|
||||
- FNEW is cheap and retried with server rotation; FPUT retries on same server
|
||||
|
||||
**Download changes (also Phase 2):** Default concurrency should be 4 (matching Haskell). Download already groups by server in 3.5. If `replicas[0]` download fails, try `replicas[1]`, `replicas[2]`, etc. (fallback across replicas).
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Error handling and connection resilience
|
||||
|
||||
Steps are ordered by dependency and should be implemented one by one.
|
||||
|
||||
#### Step 1: Fix cache key (3.6)
|
||||
- Change cache key to `formatXFTPServer(server)` in `getXFTPServerClient` and `closeXFTPServerClient`
|
||||
- Add import for `formatXFTPServer`
|
||||
- Run existing tests to verify no regression
|
||||
|
||||
#### Step 2: Typed error detection for padded server errors (3.2 client-side)
|
||||
- Add `XFTPRetriableError` class
|
||||
- In `sendXFTPCommand`, detect padded error strings before `decodeTransmission`
|
||||
- Classify `FRErr` responses as retriable or permanent with human-readable messages
|
||||
- Run existing tests
|
||||
|
||||
#### Step 3: Fetch timeout (3.3)
|
||||
- Add `TransportConfig` with `timeoutMs`
|
||||
- Thread config through `createTransport` → `connectXFTP` → command wrappers
|
||||
- Add `AbortController` to browser `fetch()` and `setTimeout` to Node.js HTTP/2
|
||||
- Add vitest test: timeout triggers after configured duration
|
||||
- Run existing tests
|
||||
|
||||
#### Step 4: Connection state with Promise-based lock and per-server queues (3.5)
|
||||
- Introduce `ServerConnection` record: `{client: Promise<XFTPClient>, queue: Promise<void>}`
|
||||
- Replace `XFTPClientAgent.clients: Map<string, XFTPClient>` with `connections: Map<string, ServerConnection>`
|
||||
- Implement `reconnectClient` — replaces `conn.client` with new promise, preserves queue
|
||||
- Implement `enqueueCommand` — chains operation onto server's queue
|
||||
- Implement `removeStaleConnection` — removes entry only if current promise is the failed one
|
||||
- Auto-cleanup: `p.catch(() => delete)` removes failed connections so next caller starts fresh
|
||||
- Adapt `closeXFTPServerClient` and `closeXFTPAgent`
|
||||
- Add vitest tests:
|
||||
- Concurrent calls to same server produce single connection
|
||||
- Failed promise is cleaned up, next caller gets fresh connection
|
||||
|
||||
#### Step 5: Automatic retry in sendXFTPCommand (3.2)
|
||||
- Add retry loop with reconnect
|
||||
- Change `sendXFTPCommand` signature: takes `agent + server` instead of `client`; export it (needed by tests and by agent.ts callers)
|
||||
- Rename current `sendXFTPCommand` → `sendXFTPCommandOnce` (private); add padded error detection + FRErr classification (throw `XFTPRetriableError` for SESSION/HANDSHAKE, `XFTPPermanentError` for AUTH/NO_FILE/etc.)
|
||||
- All command wrappers (`createXFTPChunk`, `uploadXFTPChunk`, etc.) pass agent + server
|
||||
- Update agent.ts call sites: remove `getXFTPServerClient` calls before command wrappers (in `uploadFile`, `uploadRedirectDescription`, `downloadFileRaw`, `resolveRedirect`, `deleteFile`)
|
||||
- Max 3 retries for retriable errors, immediate throw for permanent
|
||||
- On retriable error: call `reconnectClient` and retry. On retriable error exhausted: call `removeStaleConnection` to clean up. On permanent error: throw immediately without touching connection
|
||||
- Add vitest tests:
|
||||
- Server started with delay → first attempt fails, retry succeeds
|
||||
- 3 retries exhausted → error propagates with human-readable message
|
||||
- Non-retriable error (AUTH) → no retry, immediate failure
|
||||
|
||||
#### Step 6: Server-side stale session handling (3.1)
|
||||
- Add one guard to `Nothing` branch: `sniUsed && not webHello -> throwE SESSION`
|
||||
- Remove debug `hPutStrLn stderr` lines (all 6 occurrences in dispatch)
|
||||
- All other branches unchanged
|
||||
- Run Haskell tests + Playwright tests
|
||||
|
||||
#### Step 7: Download with per-server grouping
|
||||
- Modify `downloadFileRaw` to group chunks by server, sequential within each server (`for` loop), parallel across servers (`Promise.all`)
|
||||
- Add vitest test: concurrent downloads from different servers run in parallel
|
||||
|
||||
#### Step 8: UI error improvements (3.4)
|
||||
- Temporary errors: auto-retry loop (3 attempts), then show human-readable diagnosis + manual retry button
|
||||
- Permanent errors: show human-readable error, NO retry button
|
||||
- Manual retry resumes from last successful chunk (not full restart)
|
||||
|
||||
#### Step 9: Remove debug logging
|
||||
- Remove all `console.log('[DEBUG ...]')` and `hPutStrLn stderr "DEBUG ..."` lines
|
||||
- Keep `console.error('[XFTP] ...')` error logging
|
||||
|
||||
### Phase 2: Multi-server upload
|
||||
|
||||
Implement after Phase 1 is complete and tested.
|
||||
|
||||
#### Step 10: Multi-server upload with failover (3.7)
|
||||
- Extend `SentChunk` with `chunkOffset: number` (from ChunkSpec) and `server: XFTPServer` (assigned during allocate) — Stage 2 reads data by offset and groups chunks by server
|
||||
- Change `uploadFile` signature: takes `allServers: XFTPServer[]` instead of single `server`
|
||||
- Implement `UploadState` with `untriedServers` and `workingServers`
|
||||
- Implement `createChunkWithFailover` and `pickServer`: two-list selection (untried → working once enough found), max `min(serverCount, 5)` attempts per chunk
|
||||
- Allocate stage: concurrent FNEW within concurrency limit (default 4)
|
||||
- Upload stage: parallel across servers, sequential per server (reuse queue from Step 7)
|
||||
- Update `web/upload.ts`: pass `getServers()` instead of `pickRandomServer(getServers())`
|
||||
- Update description building: each chunk references its actual server
|
||||
- Add vitest tests:
|
||||
- File split across N servers (verify different servers in description)
|
||||
- One server down → chunks redistributed to others
|
||||
- All servers down → error after exhausting 5 attempts per chunk
|
||||
|
||||
#### Step 11: Download concurrency and replica fallback
|
||||
- Change default download concurrency from 1 to 4
|
||||
- If `replicas[0]` download fails, try `replicas[1]`, `replicas[2]`, etc.
|
||||
- Uses per-server queues from Step 7
|
||||
|
||||
## 5. Testing Plan
|
||||
|
||||
### Principle
|
||||
|
||||
Prefer low-level vitest tests over Playwright E2E. Each new function gets one focused test. Pure functions tested without mocks; connection management tested with mock `connectXFTP`; server behavior tested with real server. Total: 13 tests across 4 files.
|
||||
|
||||
Tests A-C run in browser context (`@vitest/browser` with Chromium headless), configured in `vitest.config.ts`. Test D (integration) requires a separate Node.js vitest config since it uses `node:http2`. Existing `globalSetup.ts` provides a real XFTP server for integration tests.
|
||||
|
||||
### Test file A: `test/errors.test.ts` — pure, no server
|
||||
|
||||
Tests error classification and padded error detection (Steps 2, 5).
|
||||
|
||||
**T1. `isRetriable` classifies errors correctly**
|
||||
```typescript
|
||||
// Retriable:
|
||||
expect(isRetriable(new XFTPRetriableError("SESSION"))).toBe(true)
|
||||
expect(isRetriable(new XFTPRetriableError("HANDSHAKE"))).toBe(true)
|
||||
expect(isRetriable(new TypeError("fetch failed"))).toBe(true) // network error
|
||||
expect(isRetriable(Object.assign(new Error(), {name: "AbortError"}))).toBe(true) // timeout
|
||||
// Not retriable:
|
||||
expect(isRetriable(new XFTPPermanentError("AUTH", "..."))).toBe(false)
|
||||
expect(isRetriable(new XFTPPermanentError("NO_FILE", "..."))).toBe(false)
|
||||
expect(isRetriable(new XFTPPermanentError("INTERNAL", "..."))).toBe(false)
|
||||
```
|
||||
|
||||
**T2. `categorizeError` produces human-readable messages**
|
||||
```typescript
|
||||
// categorizeError receives thrown errors (from sendXFTPCommandOnce or transport)
|
||||
const e = categorizeError(new XFTPPermanentError("AUTH", "File is invalid, expired, or has been removed"))
|
||||
expect(e.message).toContain("expired")
|
||||
// Verify every permanent error type maps to a non-empty human-readable message
|
||||
for (const errType of ["AUTH", "NO_FILE", "SIZE", "QUOTA", "BLOCKED", "DIGEST", "INTERNAL"]) {
|
||||
expect(humanReadableMessage({type: errType}).length).toBeGreaterThan(0)
|
||||
}
|
||||
// Retriable errors also get human-readable messages after exhaustion
|
||||
const re = categorizeError(new XFTPRetriableError("SESSION"))
|
||||
expect(re.message).toContain("expired") // "Session expired, reconnecting..."
|
||||
```
|
||||
|
||||
**T3. Padded error detection extracts error string from padded block**
|
||||
```typescript
|
||||
import {blockPad, blockUnpad} from '../src/protocol/transmission.js'
|
||||
// Simulate server sending padded "SESSION"
|
||||
const padded = blockPad(new TextEncoder().encode("SESSION"))
|
||||
const raw = blockUnpad(padded)
|
||||
expect(raw.length).toBeLessThan(20)
|
||||
expect(new TextDecoder().decode(raw)).toBe("SESSION")
|
||||
// Normal transmission block (batch count + large-encoded data) is NOT a short string
|
||||
const sessionId = new Uint8Array(32) // dummy
|
||||
const normalBlock = encodeTransmission(sessionId, new Uint8Array(0), new Uint8Array(0), encodePING())
|
||||
const normalRaw = blockUnpad(normalBlock)
|
||||
expect(normalRaw.length).toBeGreaterThan(20) // not mistaken for padded error
|
||||
```
|
||||
|
||||
### Test file B: `test/connection.test.ts` — mock connectXFTP, no server
|
||||
|
||||
Tests connection management functions (Steps 4, 5). Uses `vi.mock` to replace `connectXFTP` with a controllable promise factory.
|
||||
|
||||
**T4. `getXFTPServerClient` coalesces concurrent calls**
|
||||
```typescript
|
||||
// Mock connectXFTP to return a deferred promise
|
||||
const {promise, resolve} = promiseWithResolvers<XFTPClient>()
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(promise)
|
||||
const agent = newXFTPAgent()
|
||||
const p1 = getXFTPServerClient(agent, server)
|
||||
const p2 = getXFTPServerClient(agent, server)
|
||||
expect(p1).toBe(p2) // same promise, single connection
|
||||
resolve(mockClient)
|
||||
expect(await p1).toBe(mockClient)
|
||||
```
|
||||
|
||||
**T5. `getXFTPServerClient` auto-cleans failed connections**
|
||||
```typescript
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(Promise.reject(new Error("down")))
|
||||
const agent = newXFTPAgent()
|
||||
const p1 = getXFTPServerClient(agent, server)
|
||||
await expect(p1).rejects.toThrow("down")
|
||||
// After microtask, entry is removed
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(agent.connections.has(formatXFTPServer(server))).toBe(false)
|
||||
// Next call creates fresh connection
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(Promise.resolve(mockClient))
|
||||
const p2 = getXFTPServerClient(agent, server)
|
||||
expect(p2).not.toBe(p1)
|
||||
```
|
||||
|
||||
**T6. `removeStaleConnection` respects promise identity**
|
||||
```typescript
|
||||
const agent = newXFTPAgent()
|
||||
const p1 = Promise.resolve(mockClient)
|
||||
agent.connections.set(key, {client: p1, queue: Promise.resolve()})
|
||||
// Replace with reconnect
|
||||
const p2 = Promise.resolve(mockClient2)
|
||||
agent.connections.set(key, {client: p2, queue: Promise.resolve()})
|
||||
// removeStaleConnection with old promise does NOT remove new entry
|
||||
removeStaleConnection(agent, server, p1)
|
||||
expect(agent.connections.has(key)).toBe(true)
|
||||
expect(agent.connections.get(key)!.client).toBe(p2)
|
||||
// removeStaleConnection with current promise removes it
|
||||
removeStaleConnection(agent, server, p2)
|
||||
expect(agent.connections.has(key)).toBe(false)
|
||||
```
|
||||
|
||||
**T7. `reconnectClient` replaces promise but preserves queue**
|
||||
```typescript
|
||||
const agent = newXFTPAgent()
|
||||
const origQueue = Promise.resolve()
|
||||
agent.connections.set(key, {client: Promise.resolve(mockClient), queue: origQueue})
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(Promise.resolve(mockClient2))
|
||||
reconnectClient(agent, server)
|
||||
const conn = agent.connections.get(key)!
|
||||
expect(await conn.client).toBe(mockClient2) // new client
|
||||
expect(conn.queue).toBe(origQueue) // queue preserved
|
||||
```
|
||||
|
||||
**T8. Retry loop: retriable error triggers reconnect, permanent error does not**
|
||||
|
||||
Mock approach: `vi.mock('../src/client.js')` to mock `connectXFTP` (exported). `reconnectClient` is not exported — its behavior is controlled indirectly via `connectXFTP` mock (it calls `connectXFTP` internally). Verify retry count via `connectXFTP` call count. Note: vitest module mocking may need adjustment depending on ESM transform behavior — if intra-module calls bypass the mock, extract `connectXFTP` to a separate module or use dependency injection for testing.
|
||||
|
||||
```typescript
|
||||
// Script: first connectXFTP returns client whose post throws retriable,
|
||||
// second connectXFTP (from reconnect) returns client whose post succeeds
|
||||
vi.mocked(connectXFTP)
|
||||
.mockResolvedValueOnce({
|
||||
...mockClient,
|
||||
transport: { post: async () => { throw new XFTPRetriableError("SESSION") }, close: () => {} }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...mockClient,
|
||||
transport: { post: async () => okResponseBlock, close: () => {} }
|
||||
})
|
||||
|
||||
const agent = newXFTPAgent()
|
||||
const result = await sendXFTPCommand(agent, server, dummyKey, dummyId, encodePING())
|
||||
expect(result.response.type).toBe("FROk")
|
||||
expect(vi.mocked(connectXFTP)).toHaveBeenCalledTimes(2) // initial + 1 reconnect
|
||||
|
||||
// Reset — all 3 retries exhausted: connectXFTP called 3 times (initial + 2 reconnects)
|
||||
vi.mocked(connectXFTP).mockClear()
|
||||
vi.mocked(connectXFTP).mockResolvedValue({
|
||||
...mockClient,
|
||||
transport: { post: async () => { throw new XFTPRetriableError("SESSION") }, close: () => {} }
|
||||
})
|
||||
const agent2 = newXFTPAgent()
|
||||
await expect(sendXFTPCommand(agent2, server, dummyKey, dummyId, encodePING()))
|
||||
.rejects.toThrow(/reconnecting|expired/)
|
||||
expect(vi.mocked(connectXFTP)).toHaveBeenCalledTimes(3) // initial + 2 reconnects
|
||||
|
||||
// Reset — permanent error: connectXFTP called once (initial only, no reconnect)
|
||||
vi.mocked(connectXFTP).mockClear()
|
||||
vi.mocked(connectXFTP).mockResolvedValue({
|
||||
...mockClient,
|
||||
transport: { post: async () => authErrorBlock, close: () => {} }
|
||||
})
|
||||
const agent3 = newXFTPAgent()
|
||||
await expect(sendXFTPCommand(agent3, server, dummyKey, dummyId, encodePING()))
|
||||
.rejects.toThrow(/expired/)
|
||||
expect(vi.mocked(connectXFTP)).toHaveBeenCalledTimes(1) // initial only, no reconnect
|
||||
```
|
||||
|
||||
### Test file C: `test/server-selection.test.ts` — pure, no server
|
||||
|
||||
Tests `pickServer` state machine (Step 10). Determinism: seed `Math.random` or test invariants not specific picks.
|
||||
|
||||
**T9. `pickServer` picks from untried when working < concurrency**
|
||||
```typescript
|
||||
const servers = [s1, s2, s3, s4, s5]
|
||||
const state: UploadState = {untriedServers: [...servers], workingServers: []}
|
||||
const picked = pickServer(servers, state, 4)
|
||||
// picked is from untried, and was removed from untried
|
||||
expect(state.untriedServers.length).toBe(4)
|
||||
expect(state.untriedServers).not.toContainEqual(picked)
|
||||
```
|
||||
|
||||
**T10. `pickServer` picks only from working when working >= concurrency**
|
||||
```typescript
|
||||
const state: UploadState = {
|
||||
untriedServers: [s5], // still has untried
|
||||
workingServers: [s1, s2, s3, s4]
|
||||
}
|
||||
const picked = pickServer(servers, state, 4)
|
||||
// Must pick from working, NOT from untried
|
||||
expect([s1, s2, s3, s4]).toContainEqual(picked)
|
||||
expect(state.untriedServers.length).toBe(1) // untried unchanged
|
||||
```
|
||||
|
||||
**T11. `pickServer` resets untried when exhausted**
|
||||
```typescript
|
||||
const state: UploadState = {
|
||||
untriedServers: [], // all tried
|
||||
workingServers: [s1, s2] // only 2 working, concurrency=4
|
||||
}
|
||||
const picked = pickServer(servers, state, 4)
|
||||
// Should have reset untried to non-working servers and picked from them
|
||||
expect([s3, s4, s5]).toContainEqual(picked)
|
||||
expect(state.untriedServers.length).toBe(2) // 3 non-working minus 1 picked
|
||||
```
|
||||
|
||||
### Test file D: `test/integration.test.ts` — real server, Node.js mode
|
||||
|
||||
Requires separate vitest config with `browser: {enabled: false}` since these tests use `node:http2` directly. Alternatively, add `test/vitest.node.config.ts` that includes only `test/integration.test.ts` and runs in Node.js.
|
||||
|
||||
**T12. Stale session returns padded SESSION error (requires Step 6)**
|
||||
```typescript
|
||||
import http2 from 'node:http2'
|
||||
// Connect and handshake normally via the client
|
||||
const client = await connectXFTP(server)
|
||||
// Create a raw HTTP/2 session (new TLS SessionId, no handshake state on server)
|
||||
const session = http2.connect(client.baseUrl, {rejectUnauthorized: false})
|
||||
// Build a dummy command block using the old client's sessionId.
|
||||
// Content doesn't matter — server detects stale session before parsing command.
|
||||
const dummyKey = new Uint8Array(64) // Ed25519 private key (dummy)
|
||||
const dummyId = new Uint8Array(24) // entity ID (dummy)
|
||||
const cmdBlock = encodeAuthTransmission(client.sessionId, new Uint8Array(0), dummyId, encodePING(), dummyKey)
|
||||
const resp = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
const req = session.request({":method": "POST", ":path": "/"})
|
||||
const chunks: Buffer[] = []
|
||||
req.on("data", (c: Buffer) => chunks.push(c))
|
||||
req.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
||||
req.on("error", reject)
|
||||
req.end(Buffer.from(cmdBlock))
|
||||
})
|
||||
// Server should return padded "SESSION" (not crash, not "HANDSHAKE")
|
||||
const raw = blockUnpad(resp.subarray(0, XFTP_BLOCK_SIZE))
|
||||
expect(new TextDecoder().decode(raw)).toBe("SESSION")
|
||||
session.close()
|
||||
closeXFTP(client)
|
||||
```
|
||||
|
||||
**T13. Fetch timeout fires within configured duration**
|
||||
```typescript
|
||||
// connectXFTP with 1ms timeout — handshake requires multiple round trips,
|
||||
// so even on localhost it will exceed 1ms and trigger abort
|
||||
await expect(
|
||||
connectXFTP(server, {timeoutMs: 1})
|
||||
).rejects.toThrow(/abort|timeout/i)
|
||||
```
|
||||
|
||||
### What existing tests already cover (no new tests needed)
|
||||
|
||||
| Behavior | Covered by |
|
||||
|----------|-----------|
|
||||
| Cache key fix (Step 1) | Existing round-trip test — uses `formatXFTPServer` after refactor |
|
||||
| Basic upload/download | 24 Playwright tests + 1 vitest browser test |
|
||||
| File size limits, unicode filenames | Playwright edge case tests |
|
||||
| Server startup/teardown | `globalSetup.ts` / `globalTeardown.ts` |
|
||||
| Handshake + identity verification | `connectXFTP` in existing round-trip test |
|
||||
|
||||
### Test ordering
|
||||
|
||||
Tests must be added alongside their implementation step:
|
||||
- **Step 2**: Add T1, T2, T3 (test/errors.test.ts)
|
||||
- **Step 3**: Add T13 (test/integration.test.ts) — requires Node.js vitest config
|
||||
- **Step 4**: Add T4, T5, T6, T7 (test/connection.test.ts)
|
||||
- **Step 5**: Add T8 (test/connection.test.ts)
|
||||
- **Step 6**: Add T12 (test/integration.test.ts) — requires server change + Node.js vitest config
|
||||
- **Step 10**: Add T9, T10, T11 (test/server-selection.test.ts)
|
||||
|
||||
## 6. Context for Implementation Sessions
|
||||
|
||||
### Files to re-read on session start
|
||||
|
||||
**TypeScript (xftp-web/src/):**
|
||||
- `client.ts` — `XFTPClient`, `XFTPClientAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `connectXFTP`, `sendXFTPCommand`, `createBrowserTransport`, `createNodeTransport`, all command wrappers
|
||||
- `agent.ts` — `uploadFile`, `downloadFileRaw`, `downloadFile`, `resolveRedirect`, `encryptFileForUpload`
|
||||
- `protocol/transmission.ts` — `encodeAuthTransmission`, `decodeTransmission`, `blockPad`, `blockUnpad`
|
||||
- `protocol/commands.ts` — `XFTPErrorType`, `FileResponse`, `decodeResponse`, `decodeXFTPError`
|
||||
- `protocol/handshake.ts` — `decodeServerHandshake` (padded error detection heuristic)
|
||||
- `protocol/address.ts` — `XFTPServer`, `parseXFTPServer`, `formatXFTPServer`
|
||||
- `web/upload.ts` — UI error handling, retry button
|
||||
- `web/download.ts` — UI error handling, retry button
|
||||
- `web/servers.ts` — `getServers`, `pickRandomServer`
|
||||
|
||||
**TypeScript (xftp-web/test/):**
|
||||
- `browser.test.ts` — vitest Node.js test template (uses real Haskell server)
|
||||
- `globalSetup.ts` — server startup, config generation, port file
|
||||
- `page.spec.ts` — Playwright page tests
|
||||
|
||||
**Haskell (reference for multi-server):**
|
||||
- `src/Simplex/FileTransfer/Agent.hs` — `createChunk` (lines 457-486, allocate stage), `runXFTPSndPrepareWorker` (lines 391-430, serial allocate in Haskell), `runXFTPSndWorker` (lines 494-548, per-server upload worker)
|
||||
- `src/Simplex/Messaging/Agent/Client.hs` — `getNextServer_` (lines 2335-2350), `withNextSrv` (lines 2366-2385), `pickServer` (lines 2309-2314)
|
||||
|
||||
**Haskell (server):**
|
||||
- `src/Simplex/FileTransfer/Server.hs` — `xftpServerHandshakeV1` (lines 165-244), `processRequest` (lines 403-435)
|
||||
- `src/Simplex/Messaging/Protocol.hs` — `tDecodeServer` (lines 2239-2265) — sessionId verification at line 2242
|
||||
|
||||
### Key design constraints
|
||||
|
||||
1. `tDecodeServer` (Protocol.hs:2242) verifies `sessId == sessionId` — commands signed with old sessionId WILL fail on new connection
|
||||
2. Server generates per-session DH key in `processHello` (Server.hs:207) — cannot be shared across sessions
|
||||
3. `fetch()` provides zero control over HTTP/2 connection reuse — browser decides
|
||||
4. `xftp-web-hello` header is only checked in dispatch (Server.hs:192), NOT inside `processHello`
|
||||
5. Handshake-phase errors are raw padded strings; command-phase errors are proper ERR transmissions
|
||||
6. Ed25519 signature verification (`TASignature` path, Protocol.hs:1314) does NOT use `thAuth` — but SMP will
|
||||
7. Reconnect must re-handshake to get new sessionId AND new server DH key
|
||||
8. The new `throwE SESSION` guard (Step 6) sends a raw padded "SESSION" string — no sessionId framing. Client detects this via padded error heuristic (section 3.2), not via sessionId mismatch
|
||||
9. FNEW is cheap (creates chunk record on server) — retry with different server on failure
|
||||
10. FPUT retries on same server (chunk replica already exists there) — close connection + backoff
|
||||
|
||||
## 7. Plan Maintenance
|
||||
|
||||
This plan must be updated as implementation proceeds:
|
||||
- Mark completed steps with date
|
||||
- Record any deviations from the plan with rationale
|
||||
- Add new issues discovered during implementation
|
||||
- Update file references if code moves
|
||||
@@ -0,0 +1,327 @@
|
||||
# CLI-Web Link Compatibility
|
||||
|
||||
## Problem
|
||||
|
||||
CLI and web clients are isolated: CLI outputs `.xftp` description files, web outputs
|
||||
`https://host/#<encoded>` links. A file uploaded via one cannot be downloaded via the other.
|
||||
|
||||
## Solution Summary
|
||||
|
||||
Make CLI produce and consume web-compatible links so that:
|
||||
- CLI `send` always outputs a web link (in addition to `.xftp` files)
|
||||
- CLI `recv` accepts a web link URL as input (alternative to `.xftp` file path)
|
||||
- Browser can download files uploaded by CLI and vice versa
|
||||
|
||||
The web page host is derived from the XFTP server address - the server that hosts the file
|
||||
also hosts the download page. Making XFTP servers actually serve the web page is a separate
|
||||
concern (not covered here), but the link format anticipates it.
|
||||
|
||||
The YAML file description format is already identical between CLI and web.
|
||||
The only gap is the URI encoding layer: DEFLATE-raw compression + base64url + URL structure.
|
||||
|
||||
## Current State
|
||||
|
||||
### Web link format
|
||||
|
||||
```
|
||||
https://<xftp-server-host>/#<base64url(deflateRaw(YAML))>
|
||||
```
|
||||
|
||||
Encoding chain (agent.ts:64-68):
|
||||
1. `encodeFileDescription(fd)` -> YAML string
|
||||
2. `TextEncoder.encode(yaml)` -> bytes
|
||||
3. `pako.deflateRaw(bytes)` -> compressed
|
||||
4. `base64urlEncode(compressed)` -> URI fragment (no `#`)
|
||||
|
||||
For multi-chunk files exceeding ~400 chars in URI, a redirect description is uploaded:
|
||||
the real file description is encrypted, uploaded as a separate XFTP file, and a smaller
|
||||
"redirect" description (pointing to it) is put in the URI.
|
||||
|
||||
### CLI file format
|
||||
|
||||
```
|
||||
xftp send FILE -> writes rcv1.xftp (raw YAML), snd.xftp.private
|
||||
xftp recv FILE.xftp -> reads raw YAML from file
|
||||
```
|
||||
|
||||
No URI support. No compression. No redirect descriptions.
|
||||
|
||||
### Existing Haskell `FileDescriptionURI`
|
||||
|
||||
`Description.hs:243-266` defines a `simplex:/file#/?desc=<URL-encoded raw YAML>` format.
|
||||
This is the SimpleX Chat app format - NOT the web page format. It uses URL-encoded raw YAML
|
||||
(no DEFLATE compression), and has a different URL structure.
|
||||
|
||||
## Detailed Tech Design
|
||||
|
||||
### 1. File Header (Filename) Compatibility
|
||||
|
||||
The filename is carried **inside the encrypted file data**, not in the file description YAML.
|
||||
Both CLI and web use the same `FileHeader` structure and binary encoding - full interop.
|
||||
|
||||
#### FileHeader type
|
||||
|
||||
Haskell (`Types.hs:36-46`):
|
||||
```haskell
|
||||
data FileHeader = FileHeader { fileName :: Text, fileExtra :: Maybe Text }
|
||||
instance Encoding FileHeader where
|
||||
smpEncode FileHeader {fileName, fileExtra} = smpEncode (fileName, fileExtra)
|
||||
```
|
||||
|
||||
TypeScript (`crypto/file.ts:11-24`):
|
||||
```typescript
|
||||
interface FileHeader { fileName: string; fileExtra: string | null }
|
||||
function encodeFileHeader(hdr: FileHeader): Uint8Array {
|
||||
return concatBytes(encodeString(hdr.fileName), encodeMaybe(encodeString, hdr.fileExtra))
|
||||
}
|
||||
```
|
||||
|
||||
Both produce identical binary: `[1-byte UTF-8 length][fileName bytes]['0']` (for null fileExtra).
|
||||
Max filename: 255 UTF-8 bytes (1-byte length prefix).
|
||||
|
||||
#### Encrypted file structure
|
||||
|
||||
Both CLI and web produce the same encrypted stream:
|
||||
```
|
||||
XSalsa20-Poly1305 encrypted:
|
||||
[8-byte Int64 fileSize] [FileHeader] [file content] ['#' padding]
|
||||
+ [16-byte auth tag]
|
||||
|
||||
Where fileSize = len(FileHeader) + len(file content)
|
||||
```
|
||||
|
||||
The 8-byte length prefix and padding are handled identically:
|
||||
- Haskell: `Crypto.hs:43-56` (`encryptFile`) / `Crypto.hs:81-87` (`decryptFirstChunk`)
|
||||
- TypeScript: `crypto/file.ts:51-70` (`encryptFile`) / `crypto/file.ts:81-94` (`decryptChunks`)
|
||||
|
||||
On decryption, `unPadLazy`/`splitLen` strips the 8-byte length prefix, then `parseFileHeader`
|
||||
extracts the filename from the remaining decrypted bytes (up to 1024 bytes examined, both sides).
|
||||
|
||||
#### CLI upload: sets real filename (ok)
|
||||
|
||||
`Client/Main.hs:246-247,273`:
|
||||
```haskell
|
||||
let (_, fileNameStr) = splitFileName filePath
|
||||
fileName = T.pack fileNameStr
|
||||
...
|
||||
fileHdr = smpEncode FileHeader {fileName, fileExtra = Nothing}
|
||||
```
|
||||
|
||||
Extracts the actual filename from the path and embeds it in the encrypted header.
|
||||
|
||||
#### CLI download: uses filename from header (ok)
|
||||
|
||||
`Crypto.hs:62-66` (single chunk) / `Crypto.hs:72-74` (multi-chunk):
|
||||
```haskell
|
||||
(FileHeader {fileName}, rest) <- parseFileHeader decryptedContent
|
||||
destFile <- withExceptT FTCEFileIOError $ getDestFile fileName
|
||||
```
|
||||
|
||||
`Client/Main.hs:435-441` (`getFilePath`):
|
||||
- If output dir specified: saves to `<dir>/<fileName>`
|
||||
- If no dir: saves to `~/Downloads/<fileName>`
|
||||
|
||||
The filename from the decrypted header determines the output file name.
|
||||
|
||||
#### Web upload: sets real filename (ok)
|
||||
|
||||
`upload.ts:121` -> `agent.ts:86`:
|
||||
```typescript
|
||||
const fileHdr = encodeFileHeader({fileName, fileExtra: null})
|
||||
```
|
||||
|
||||
Where `fileName` comes from `file.name` (browser File API).
|
||||
|
||||
#### Web download: uses filename from header (ok)
|
||||
|
||||
`download.ts:97,102`:
|
||||
```typescript
|
||||
const fileName = sanitizeFileName(header.fileName)
|
||||
a.download = encodeURIComponent(fileName)
|
||||
```
|
||||
|
||||
The web client additionally sanitizes the filename (strips path separators, control chars,
|
||||
bidi overrides, limits to 255 chars).
|
||||
|
||||
#### Web redirect description: empty filename (correct)
|
||||
|
||||
`agent.ts:193`: `encryptFileForUpload(yamlBytes, "")` - redirect descriptions use empty filename
|
||||
because they are internal artifacts, not user files. This is handled correctly on both sides:
|
||||
the redirect content is decrypted and parsed as YAML, not saved as a file.
|
||||
|
||||
#### Cross-client interop: fully compatible (ok)
|
||||
|
||||
| Scenario | Filename flow | Status |
|
||||
|----------|--------------|--------|
|
||||
| CLI upload -> CLI download | `splitFileName` -> header -> `getDestFile` | Works |
|
||||
| Web upload -> Web download | `File.name` -> header -> `sanitizeFileName` | Works |
|
||||
| CLI upload -> Web download | `splitFileName` -> header -> `sanitizeFileName` | **Compatible** |
|
||||
| Web upload -> CLI download | `File.name` -> header -> `getDestFile` | **Compatible** |
|
||||
|
||||
The binary encoding is identical (smpEncode). No changes needed for filename interop.
|
||||
The CLI should consider adding filename sanitization similar to the web client for safety.
|
||||
|
||||
### 2. Web Link Host Derivation
|
||||
|
||||
The web page URL domain comes from the XFTP server address, not from a CLI flag:
|
||||
|
||||
- **Non-redirected description**: use the server host of the first chunk's first replica.
|
||||
E.g., `xftp://abc=@xftp1.simplex.im` -> `https://xftp1.simplex.im/#<encoded>`
|
||||
|
||||
- **Redirected description**: use the server host of the redirect chunk (the outer description's
|
||||
chunk that stores the encrypted inner description).
|
||||
|
||||
The server address format is `xftp://<keyhash>@<host>[,<host2>,...][:<port>]`.
|
||||
The web link uses `https://<host>` (port 443 implied).
|
||||
|
||||
This means the CLI does not need a `--web-url` flag - the server address fully determines
|
||||
the link. The XFTP server serving the web page is a separate deployment concern.
|
||||
|
||||
### 3. Web URI Encoding/Decoding in Haskell
|
||||
|
||||
Add two functions (new module or in `Description.hs`):
|
||||
|
||||
```haskell
|
||||
-- Encode file description as web URI fragment (no leading #)
|
||||
encodeWebURI :: FileDescription 'FRecipient -> ByteString
|
||||
-- 1. Y.encode . encodeFileDescription -> YAML bytes
|
||||
-- 2. deflateRaw (raw DEFLATE, no zlib/gzip header) via zlib package
|
||||
-- 3. base64url encode (with padding, matching Data.ByteString.Base64.URL)
|
||||
|
||||
-- Decode web URI fragment (no leading #) to file description
|
||||
decodeWebURI :: ByteString -> Either String (ValidFileDescription 'FRecipient)
|
||||
-- 1. base64url decode
|
||||
-- 2. inflateRaw (raw DEFLATE decompress)
|
||||
-- 3. Y.decodeEither' -> YAMLFileDescription -> FileDescription
|
||||
-- 4. validateFileDescription
|
||||
|
||||
-- Build full web link from file description
|
||||
-- Extracts server host from first chunk replica (or redirect chunk)
|
||||
fileWebLink :: FileDescription 'FRecipient -> (String, ByteString)
|
||||
-- Returns (webHost, uriFragment)
|
||||
-- Caller assembles: "https://" <> webHost <> "/#" <> uriFragment
|
||||
```
|
||||
|
||||
**Dependency**: Add `zlib` to `simplexmq.cabal` (for raw DEFLATE).
|
||||
The codebase already has `zstd` for message compression - `zlib` is standard and small.
|
||||
|
||||
The `zlib` Haskell package provides `Codec.Compression.Zlib.Raw` for raw DEFLATE
|
||||
(no header/trailer), matching `pako.deflateRaw()` / `pako.inflateRaw()`.
|
||||
|
||||
### 4. Redirect Description Support
|
||||
|
||||
The CLI currently does NOT create redirect descriptions. For single-server single-recipient
|
||||
uploads, most file descriptions fit in a reasonable URI even for multi-chunk files. But for
|
||||
large files (many chunks x long server hostnames), the URI can exceed practical limits.
|
||||
|
||||
**Approach**: Match the web client threshold.
|
||||
- After encoding the URI, if `length > 400` and chunks > 1, upload a redirect description.
|
||||
- The redirect upload uses the same XFTP upload flow: encrypt YAML -> upload as file -> create
|
||||
outer description pointing to it.
|
||||
- This matches `agent.ts:152-155` exactly.
|
||||
- The redirect chunk's server becomes the web link host.
|
||||
|
||||
For CLI download from a redirect URI, the existing `cliReceiveFile` needs extension:
|
||||
- After decoding the file description, check `redirect` field.
|
||||
- If present: download and decrypt the redirect chunks first to get the inner description,
|
||||
then download the actual file using the inner description.
|
||||
- The web client already does this (`resolveRedirect` in agent.ts:320-346).
|
||||
|
||||
### 5. CLI Command Changes
|
||||
|
||||
#### `xftp send` - always output web link
|
||||
|
||||
```
|
||||
xftp send FILE [DIR] [-n COUNT] [-s SERVERS]
|
||||
```
|
||||
|
||||
- Upload file as usual
|
||||
- Generate web link: `https://<server-host>/#<encodeWebURI(rcvDescription)>`
|
||||
- If URI exceeds threshold, upload redirect description first
|
||||
- Print web link to stdout (in addition to `.xftp` file paths)
|
||||
- Only generates link for the first recipient (web links are single-recipient)
|
||||
|
||||
**Output change**:
|
||||
```
|
||||
Sender file description: ./file.xftp/snd.xftp.private
|
||||
Pass file descriptions to the recipient(s):
|
||||
./file.xftp/rcv1.xftp
|
||||
|
||||
Web link:
|
||||
https://xftp1.simplex.im/#eJy0VduO2zYQ...
|
||||
```
|
||||
|
||||
#### `xftp recv` - accept URL as input
|
||||
|
||||
```
|
||||
xftp recv <FILE.xftp | URL> [DIR]
|
||||
```
|
||||
|
||||
- If input starts with `http://` or `https://`, extract hash fragment after `#`
|
||||
- Decode: base64url -> inflateRaw -> YAML -> FileDescription
|
||||
- Resolve redirect if present
|
||||
- Download and decrypt as usual
|
||||
|
||||
The URL must be quoted on the command line (`"https://...#..."`) because `#` is a shell
|
||||
comment character when unquoted.
|
||||
|
||||
Implementation: modify `receiveP` parser to accept URL, add `decodeWebURI` path in
|
||||
`cliReceiveFile` alongside existing `getFileDescription'`.
|
||||
|
||||
### 6. YAML Format Compatibility
|
||||
|
||||
Already identical. The web `description.ts` explicitly matches Haskell `Data.Yaml` output:
|
||||
- Same field names (alphabetical key order)
|
||||
- Same base64url encoding for binary fields (with `=` padding)
|
||||
- Same server replica colon-delimited format: `chunkNo:replicaId:replicaKey[:digest][:chunkSize]`
|
||||
- Same size encoding (`kb`/`mb`/`gb` suffixes)
|
||||
- Same redirect structure
|
||||
|
||||
**Verification**: The Playwright test suite already tests upload->download round-trips.
|
||||
Adding a cross-client test (CLI upload -> web download, or web upload -> CLI download) would
|
||||
validate interop end-to-end.
|
||||
|
||||
### 7. Server Compatibility
|
||||
|
||||
No server changes needed. Both clients use the same XFTP protocol (FGET, FPUT, FNEW, FACK, FDEL).
|
||||
The web client adds `xftp-web-hello: 1` header for the hello handshake, but the actual file
|
||||
operations are identical wire-format.
|
||||
|
||||
The only consideration: CLI uses native HTTP/2 (via `http2` Haskell package), web uses
|
||||
browser `fetch()` API over HTTP/2. Both produce identical XFTP protocol frames.
|
||||
|
||||
**Note**: Making XFTP servers actually serve the web download page at `https://<host>/` is a
|
||||
separate deployment/infrastructure task. This plan only establishes the link format convention
|
||||
so that links are ready to work once servers serve the page.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Web URI codec in Haskell
|
||||
|
||||
1. Add `zlib` dependency to `simplexmq.cabal`
|
||||
2. Add `encodeWebURI` / `decodeWebURI` / `fileWebLink` to `Simplex.FileTransfer.Description`
|
||||
(or a new `Simplex.FileTransfer.Description.WebURI` module)
|
||||
3. `fileWebLink` extracts host from first chunk's first replica server address
|
||||
4. Add unit tests: encode a known FileDescription, verify output matches web client encoding
|
||||
5. Add round-trip test: encode -> decode -> compare
|
||||
|
||||
### Phase 2: CLI `recv` accepts URL
|
||||
|
||||
1. Modify `ReceiveOptions` to accept `Either FilePath WebURL` for `fileDescription`
|
||||
2. In `cliReceiveFile`: if URL, extract fragment after `#`, call `decodeWebURI`
|
||||
3. Add redirect resolution: if `redirect /= Nothing`, download redirect chunks,
|
||||
decrypt, parse inner description, then proceed with download
|
||||
4. Test: upload via web page -> copy link -> `xftp recv <link>`
|
||||
|
||||
### Phase 3: CLI `send` outputs web link
|
||||
|
||||
1. After upload, call `fileWebLink` to get (host, fragment)
|
||||
2. If fragment exceeds threshold, upload redirect description first, rebuild link
|
||||
3. Print `https://<host>/#<fragment>` to stdout
|
||||
4. Test: `xftp send FILE` -> open link in browser -> download
|
||||
|
||||
### Phase 4: Cross-client integration test
|
||||
|
||||
1. Add test: CLI send -> extract link from stdout -> Playwright browser download -> verify
|
||||
2. Add test: Playwright browser upload -> extract link -> CLI recv -> verify
|
||||
3. These can be shell-script or Haskell test-suite tests that spawn both clients
|
||||
@@ -0,0 +1,415 @@
|
||||
# Fix subQ deadlock: blocking writeTBQueue inside connLock
|
||||
|
||||
## Problem
|
||||
|
||||
Users report that message reception silently and permanently stops across all connections, with no error alerts. The app appears functional but no messages arrive. Recovery requires restart.
|
||||
|
||||
Root cause: a deadlock between worker threads holding `connLock` and the `agentSubscriber` (sole `subQ` reader).
|
||||
|
||||
### The deadlock mechanism
|
||||
|
||||
`subQ` (`TBQueue ATransmission`, capacity 4096 on mobile / 1024 on desktop) is the single pipeline between the agent layer and the chat layer. The `agentSubscriber` thread (`Commands.hs:4373`) is its **sole reader**.
|
||||
|
||||
Three code sites hold `connLock` and call blocking `writeTBQueue subQ` without a fullness check. When `subQ` is full, these block while holding the lock. If `agentSubscriber` simultaneously needs the same `connLock` (via `sendMessagesB_` → `withConnLocks`), it blocks too — creating a circular wait:
|
||||
|
||||
- **Worker**: holds `connLock(X)`, waits for `subQ` space (needs `agentSubscriber` to read)
|
||||
- **agentSubscriber**: sole `subQ` reader, waits for `connLock(X)` (needs worker to release)
|
||||
- **Result**: permanent silent deadlock — no exception, no alert, all connections blocked
|
||||
|
||||
### Confirmed deadlock scenarios
|
||||
|
||||
**Scenario 1**: Delivery worker during queue rotation test
|
||||
|
||||
```
|
||||
Delivery worker: agentSubscriber (sole subQ reader):
|
||||
withConnLock(X) [2187] readTBQueue subQ → processAgentMessageConn
|
||||
...DB operations... → sendPendingGroupMessages (on CON/SENT/QCONT)
|
||||
notify → writeTBQueue subQ [2238] → batchSendConnMessages → deliverMessagesB
|
||||
[BLOCKED — subQ full] → withAgent sendMessagesB [synchronous]
|
||||
→ sendMessagesB_ → withConnLocks({..X..}) [1708]
|
||||
[BLOCKED — connLock(X) held]
|
||||
```
|
||||
|
||||
**Scenario 2**: Async command worker during message ACK with notification
|
||||
|
||||
```
|
||||
Async cmd worker: agentSubscriber (sole subQ reader):
|
||||
tryWithLock "ICAck" [1930→1824] readTBQueue subQ → processAgentMessageConn
|
||||
→ withConnLock(X) → sendPendingGroupMessages
|
||||
→ ack → ackQueueMessage [1899] → sendMessagesB_ → withConnLocks({..X..})
|
||||
→ sendMsgNtf [2381] [BLOCKED — connLock(X) held]
|
||||
→ writeTBQueue subQ [2386]
|
||||
[BLOCKED — subQ full]
|
||||
```
|
||||
|
||||
**Scenario 3**: Synchronous `ackMessage'` API (same mechanism as Scenario 2 but from external API caller)
|
||||
|
||||
```
|
||||
ackMessage' caller: agentSubscriber (sole subQ reader):
|
||||
withConnLock(X) [2254] → sendMessagesB_ → withConnLocks({..X..})
|
||||
→ ack → ackQueueMessage [2267] [BLOCKED — connLock(X) held]
|
||||
→ sendMsgNtf [2381]
|
||||
→ writeTBQueue subQ [2386]
|
||||
[BLOCKED — subQ full]
|
||||
```
|
||||
|
||||
### ConnId overlap verified
|
||||
|
||||
No guard prevents a connection undergoing queue rotation (AM_QTEST_) or ACK processing from being included in `sendMessagesB_`'s batch. During these operations, the connection has `connStatus == ConnReady`, passing all filters in `memberSendAction`.
|
||||
|
||||
### Cascade amplification
|
||||
|
||||
Once any single deadlock triggers, `subQ` never drains. ALL other threads that attempt `writeTBQueue subQ` block progressively — their locks are held forever too. The entire threading system freezes within seconds.
|
||||
|
||||
### Affected code sites (blocking `writeTBQueue subQ` inside `connLock`)
|
||||
|
||||
| Site | File | Lock line | Write line | Events written |
|
||||
|------|------|-----------|------------|----------------|
|
||||
| `runSmpQueueMsgDelivery::notify` | Agent.hs | 2187 | 2238 | SWITCH SPCompleted, ERR INTERNAL |
|
||||
| `runSmpQueueMsgDelivery::internalErr/notifyDel` | Agent.hs | 2187 | 2238 (via notifyDel→notify) | ERR INTERNAL + delMsg |
|
||||
| `ackQueueMessage::sendMsgNtf` | Agent.hs | 2254 or 1930 | 2386 | MSGNTF |
|
||||
|
||||
### Safe patterns that already exist in the codebase
|
||||
|
||||
1. **`isFullTBQueue` + pending TVar** (used at `runCommandProcessing` lines 1782-1784/1937, and `runProcessSMP` lines 3027-3029/3216):
|
||||
```haskell
|
||||
-- Before processing (e.g. line 1782):
|
||||
pending <- newTVarIO []
|
||||
-- During processing — safe notify (e.g. line 1937):
|
||||
notify cmd =
|
||||
let t = (corrId, connId, AEvt (sAEntity @e) cmd)
|
||||
in atomically $ ifM (isFullTBQueue subQ) (modifyTVar' pendingCmds (t :)) (writeTBQueue subQ t)
|
||||
-- After processing — flush (e.g. line 1784):
|
||||
mapM_ (atomically . writeTBQueue subQ) . reverse =<< readTVarIO pending
|
||||
```
|
||||
|
||||
2. **`nonBlockingWriteTBQueue`** (used at Client.hs:789, NtfSubSupervisor.hs:507):
|
||||
```haskell
|
||||
nonBlockingWriteTBQueue q x = do
|
||||
sent <- atomically $ tryWriteTBQueue q x
|
||||
unless sent $ void $ forkIO $ atomically $ writeTBQueue q x
|
||||
```
|
||||
Note: `nonBlockingWriteTBQueue` does NOT preserve ordering — the spawned background thread may complete out of order relative to subsequent direct writes from the same calling thread.
|
||||
|
||||
### Exhaustive proof: no other deadlock scenarios exist
|
||||
|
||||
All 15 `withConnLock` sites in Agent.hs were analyzed. Only 3 write to `subQ`:
|
||||
|
||||
| withConnLock site | Writes subQ? | Safe? |
|
||||
|-------------------|-------------|-------|
|
||||
| switchConnectionAsync' (899) | No | ✓ |
|
||||
| setConnShortLinkAsync' (995) | No | ✓ |
|
||||
| setConnShortLink' (1031) | No | ✓ |
|
||||
| deleteConnShortLink' (1075) | No | ✓ |
|
||||
| allowConnection' (1407) | No | ✓ |
|
||||
| acceptContact' (1417) | No | ✓ |
|
||||
| sendMessagesB_ (1708, `withConnLocks`) | No | ✓ |
|
||||
| tryWithLock/runSmpCommand (1930) | Yes (1937) | ✓ — `isFullTBQueue` check |
|
||||
| tryMoveableWithLock/runSmpCommand (1931) | Yes (1937) | ✓ — `isFullTBQueue` check |
|
||||
| **runSmpQueueMsgDelivery AM_QTEST_ (2187)** | **Yes (2238)** | **✗ — DEADLOCK** |
|
||||
| **ackMessage' (2254)** | **Yes (2386)** | **✗ — DEADLOCK** |
|
||||
| switchConnection' (2298) | No | ✓ |
|
||||
| abortConnectionSwitch' (2328) | No | ✓ |
|
||||
| synchronizeRatchet' (2351) | No | ✓ |
|
||||
| suspendConnection' (2390) | No | ✓ |
|
||||
| **processSMP (3037)** | Yes (3216) | ✓ — `isFullTBQueue` check |
|
||||
|
||||
Note: `processSMP` (line 3037) holds `connLock` and its local `notify` (line 3216) writes to `subQ`, but it uses the safe `isFullTBQueue` pattern. Its `ack` (line 3196) uses `enqueueCmd` (DB-only), NOT `ackQueueMessage`. The actual `ackQueueMessage` runs later from the async command worker via ICAck/ICAckDel.
|
||||
|
||||
Other lock pairs checked — no circular dependencies:
|
||||
- `connLock × DB MVar`: DB never acquires connLock
|
||||
- `entityLock × connLock`: consistent ordering (entity first in chat, conn in agent)
|
||||
- `connLock(X) × connLock(Y)`: single agentSubscriber thread, one `withConnLocks` at a time
|
||||
|
||||
---
|
||||
|
||||
## Deadlock call graph: agentSubscriber → connLock
|
||||
|
||||
All deadlock paths require `agentSubscriber` to synchronously acquire `connLock`. Exhaustive analysis shows that **every such path converges on a single agent function**: `sendMessagesB_` → `withConnLocks` (Agent.hs:1708). No other agent API function called synchronously from the agentSubscriber acquires connLock.
|
||||
|
||||
Verified (FACT): `ackMessageAsync` → `enqueueCommand` only (no connLock). `toggleConnectionNtfs` → no lock. `deleteConnectionAsync` → `deleteLock` not `connLock`. `joinConnectionAsync` → `withInvLock` not `connLock`.
|
||||
|
||||
Also verified (FACT): `Lock = TMVar Text` (Lock.hs:24) is **non-reentrant** — double acquisition on the same thread deadlocks.
|
||||
|
||||
### All 22 trigger paths
|
||||
|
||||
Every path goes through `deliverMessage`/`deliverMessages`/`deliverMessagesB` → `withAgent sendMessagesB` → `sendMessagesB_` → `withConnLocks`:
|
||||
|
||||
| # | Trigger | Chat function | ConnIds locked | Risk |
|
||||
|---|---------|--------------|----------------|------|
|
||||
| 1 | Group CON (Invitee) | `introduceToAll` → broadcast XGrpMemNew | **ALL member connIds** | **HIGHEST** |
|
||||
| 2 | Group MSG XGrpLinkAcpt | `introduceToRemaining` → broadcast | **ALL member connIds** | **HIGHEST** |
|
||||
| 3 | Group CON (Invitee) | `sendIntroductions` → batch intros to new member | new member connId | Medium |
|
||||
| 4 | Group CON (Invitee) | `sendHistory` → batch to new member | new member connId | Medium |
|
||||
| 5 | Group CON | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 6 | Group SENT | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 7 | Group QCONT | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 8 | Group CON (PendingReview) | `introduceToModerators` → to moderators | moderator connIds | Medium |
|
||||
| 9 | Group CON (PreMember) | `sendXGrpMemCon` → to host | host connId | Low |
|
||||
| 10 | Group CON (PreMember) | `probeMatchingMemberContact` → probes + hashes | member + N matching connIds | Medium |
|
||||
| 11 | Direct CON | `probeMatchingMembers` → probes + hashes | contact + N matching connIds | Medium |
|
||||
| 12 | Direct JOINED | `sendAutoReply` | contact connId | Low |
|
||||
| 13 | Group JOINED | `sendGroupAutoReply` | member connId | Low |
|
||||
| 14 | Group INV | `sendXGrpMemInv` → to host | host connId | Low |
|
||||
| 15 | Group INV (legacy) | `sendGrpInvitation` → to contact | contact connId | Low |
|
||||
| 16 | Group MSG XGrpMemInv | `xGrpMemInv` → `sendGroupMemberMessage` | re-member connId | Low |
|
||||
| 17 | Group MSG XGrpMemDel | `forwardToMember` | deleted member connId | Low |
|
||||
| 18 | Group MSG XGrpLinkMem | `probeMatchingMemberContact` | member + N matching connIds | Medium |
|
||||
| 19 | Group MSG (dup relay) | `saveGroupRcvMsg` error → `sendDirectMemberMessage` | forwarder connId | Low |
|
||||
| 20 | SFDONE | `sendFileDescriptions` → to recipients | recipient connIds | Medium |
|
||||
| 21 | Group MSG XGrpLinkAcpt | `sendHistory` → to accepted member | accepted member connId | Medium |
|
||||
| 22 | Direct MSG (autoAccept) | `autoAcceptFile` → inline accept reply | contact connId | Low (test-only config) |
|
||||
|
||||
### Key observations
|
||||
|
||||
1. **Single bottleneck**: All 22 paths converge on `sendMessagesB_` → `withConnLocks` (Agent.hs:1708). The deadlock is between this lock acquisition and any worker thread holding `connLock` + blocking on `writeTBQueue subQ`.
|
||||
|
||||
2. **Highest-risk paths** (#1, #2): Broadcasting to ALL group members in `introduceToAll` / `introduceToRemaining` acquires `withConnLocks` on ALL member connIds in a single batch. For large groups, this holds the agentSubscriber thread for a long time, during which subQ fills, which causes worker threads holding connLock on any of those connIds to deadlock.
|
||||
|
||||
3. **Medium-risk paths** (#5-7): `sendPendingGroupMessages` fires on every CON/SENT/QCONT. These are frequent and lock the member's connId, which is the SAME connId that a delivery worker or ACK worker may hold while writing to subQ.
|
||||
|
||||
---
|
||||
|
||||
## Analysis: `withConnLocks` in `sendMessagesB_`
|
||||
|
||||
### FACT: the lock protects ratchet encryption state
|
||||
|
||||
`sendMessagesB_` (Agent.hs:1708-1713) acquires `withConnLocks` and executes:
|
||||
|
||||
1. **`getConn_`** — reads connection metadata, send queues from DB
|
||||
2. **`setConnPQSupport`** — updates PQ encryption flag per connection
|
||||
3. **`enqueueMessagesB`** → `enqueueMessageB` → `storeSentMsg_` which calls:
|
||||
- **`updateSndIds`** (AgentStore.hs:899) — increments `internalSndId` (sequential send counter)
|
||||
- **`agentRatchetEncryptHeader`** (Agent.hs:3698) — reads current ratchet via `getRatchetForUpdate`, encrypts message header via `rcEncryptHeader`, writes advanced ratchet state via `updateRatchet`
|
||||
- **`createSndMsg`** + **`createSndMsgDelivery`** — inserts message and delivery records
|
||||
|
||||
All operations run within `unsafeWithStore` → `withTransaction` (single DB transaction per batch).
|
||||
|
||||
### FACT: the lock CANNOT be removed
|
||||
|
||||
Without `withConnLocks`, concurrent `sendMessagesB_` calls targeting the same connection would:
|
||||
- Read the same ratchet state, both encrypt, one overwrite the other → **ratchet desync** (unrecoverable)
|
||||
- Get duplicate `internalSndId` values → **message ID collision**
|
||||
- Race on `setConnPQSupport` → **PQ state inconsistency**
|
||||
|
||||
The lock serializes ALL operations on the connection's encryption state. Removing it would introduce data corruption.
|
||||
|
||||
Note: `sendMessage` (singular, line 530) uses the same `sendMessagesB_` function — there is no lock-free send path.
|
||||
|
||||
### Eliminated strategies
|
||||
|
||||
- **Strategy C (remove lock)**: The lock protects ratchet encryption. Removing it causes unrecoverable ratchet desync. Eliminated.
|
||||
- **Strategy A (async dispatch)**: All 22 chat-layer callers use `deliverMessagesB` return values (delivery IDs, PQ state) synchronously. `forkIO` loses results. Eliminated.
|
||||
- **Strategy W (isFullTBQueue + pending TVar)**: The existing pattern (lines 1937, 3216) buffers events in a local TVar and flushes after lock release. Between lock release and flush, another thread can acquire the same connLock and write events to subQ — reordering events within the same connection. This trades a visible deadlock for invisible ordering bugs. Eliminated.
|
||||
- **Strategy O (per-connection overflow queues)**: Bounded overflow queues with "drop when full" were analyzed. Drop consequences are unacceptable at 5 of 6 write sites — CONF, INFO, CON cause permanent connection failure after ACK; INV loses connection invitations; SENT/MERR leave messages stuck forever. Unbounded overflow defeats backpressure. Eliminated.
|
||||
|
||||
---
|
||||
|
||||
## Solution: move subQ writes outside connLock
|
||||
|
||||
### Root cause
|
||||
|
||||
The `writeTBQueue subQ` calls at the 3 deadlock sites are inside `connLock` by accident of code structure, not necessity. `connLock` protects ratchet encryption state and DB consistency. The `notify` calls write informational events to `subQ` — they do not modify any state that `connLock` protects.
|
||||
|
||||
Moving the writes outside the lock scope eliminates the deadlock: blocking `writeTBQueue subQ` without holding `connLock` is safe — agentSubscriber is free to acquire the lock, process events, and drain `subQ`.
|
||||
|
||||
### Why reordering doesn't matter at these sites
|
||||
|
||||
The chat layer handlers for the 3 deadlock site events do NOT advance the ratchet:
|
||||
|
||||
| Event | Chat handler | Calls sendMessagesB_? |
|
||||
|-------|-------------|----------------------|
|
||||
| SWITCH SPCompleted | Creates internal chat item, updates UI | **No** |
|
||||
| ERR INTERNAL | Logs error to view | **No** |
|
||||
| MSGNTF | `toView CEvtNtfMessage` → empty output | **No** |
|
||||
|
||||
Events that DO trigger ratchet advances (CON, SENT, QCONT → `sendPendingGroupMessages` → `sendMessagesB_`) are all already written OUTSIDE `connLock` in the current code.
|
||||
|
||||
Ratchet state lives in the DB, not in subQ events. agentSubscriber processes events sequentially regardless of arrival order. The SENT-before-SWITCH race already exists in the current code (new queue worker writes SENT outside connLock while old queue worker writes SWITCH inside connLock).
|
||||
|
||||
### Fix: Site 1 — `runSmpQueueMsgDelivery` AM_QTEST_ (line 2187)
|
||||
|
||||
Restructure `withConnLock` to return the event, write outside.
|
||||
|
||||
**Current code** (Agent.hs:2187-2214):
|
||||
```haskell
|
||||
AM_QTEST_ -> withConnLock c connId "runSmpQueueMsgDelivery AM_QTEST_" $ do
|
||||
withStore' c $ \db -> setSndQueueStatus db sq Active
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection cData' rqs sqs -> do
|
||||
let addr = qAddress sq
|
||||
case findQ addr sqs of
|
||||
Just SndQueue {dbReplaceQueueId = Just replacedId, primary} ->
|
||||
case removeQP (\sq' -> dbQId sq' == replacedId && not (sameQueue addr sq')) sqs of
|
||||
Nothing -> internalErr msgId "sent QTEST: queue not found in connection"
|
||||
Just (sq', sq'' : sqs') -> do
|
||||
checkSQSwchStatus sq' SSSendingQTEST
|
||||
atomically $ TM.delete (qAddress sq') $ smpDeliveryWorkers c
|
||||
withStore' c $ \db -> do
|
||||
when primary $ setSndQueuePrimary db connId sq
|
||||
deletePendingMsgs db connId sq'
|
||||
deleteConnSndQueue db connId sq'
|
||||
let sqs'' = sq'' :| sqs'
|
||||
conn' = DuplexConnection cData' rqs sqs''
|
||||
cStats <- connectionStats c conn'
|
||||
notify $ SWITCH QDSnd SPCompleted cStats -- DEADLOCK
|
||||
_ -> internalErr msgId "sent QTEST: ..." -- DEADLOCK (via notifyDel → notify)
|
||||
_ -> internalErr msgId "sent QTEST: ..." -- DEADLOCK
|
||||
_ -> internalErr msgId "QTEST sent not in duplex ..." -- DEADLOCK
|
||||
```
|
||||
|
||||
**New code:**
|
||||
```haskell
|
||||
AM_QTEST_ -> do
|
||||
evt_ <- withConnLock c connId "runSmpQueueMsgDelivery AM_QTEST_" $ do
|
||||
withStore' c $ \db -> setSndQueueStatus db sq Active
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection cData' rqs sqs -> do
|
||||
let addr = qAddress sq
|
||||
case findQ addr sqs of
|
||||
Just SndQueue {dbReplaceQueueId = Just replacedId, primary} ->
|
||||
case removeQP (\sq' -> dbQId sq' == replacedId && not (sameQueue addr sq')) sqs of
|
||||
Nothing -> pure $ Left "sent QTEST: queue not found in connection"
|
||||
Just (sq', sq'' : sqs') -> do
|
||||
checkSQSwchStatus sq' SSSendingQTEST
|
||||
atomically $ TM.delete (qAddress sq') $ smpDeliveryWorkers c
|
||||
withStore' c $ \db -> do
|
||||
when primary $ setSndQueuePrimary db connId sq
|
||||
deletePendingMsgs db connId sq'
|
||||
deleteConnSndQueue db connId sq'
|
||||
let sqs'' = sq'' :| sqs'
|
||||
conn' = DuplexConnection cData' rqs sqs''
|
||||
cStats <- connectionStats c conn'
|
||||
pure $ Right $ SWITCH QDSnd SPCompleted cStats
|
||||
_ -> pure $ Left "sent QTEST: there is only one queue in connection"
|
||||
_ -> pure $ Left "sent QTEST: queue not in connection or not replacing another queue"
|
||||
_ -> pure $ Left "QTEST sent not in duplex connection"
|
||||
-- subQ write is now OUTSIDE connLock — blocking writeTBQueue is safe
|
||||
case evt_ of
|
||||
Right evt -> notify evt
|
||||
Left err -> internalErr msgId err
|
||||
```
|
||||
|
||||
All DB operations remain inside the lock. Only `notify`/`internalErr` (which write to subQ) move outside. `internalErr` calls `notifyDel` = `notify >> delMsg` — both `notify` (subQ write) and `delMsg` (`deleteSndMsgDelivery`, keyed on unique msgId) are safe outside the lock. The existing double-delete pattern (`delMsg` inside `internalErr` + `delMsgKeep` at line 2216) is preserved.
|
||||
|
||||
### Fix: Sites 2 & 3 — `ackQueueMessage::sendMsgNtf` (line 2386)
|
||||
|
||||
Change `ackQueueMessage` to return the MSGNTF event instead of writing it to subQ. Callers write to subQ after releasing connLock.
|
||||
|
||||
**Current code** (Agent.hs:2371-2386):
|
||||
```haskell
|
||||
ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM ()
|
||||
ackQueueMessage c rq@RcvQueue {userId, connId, server} srvMsgId = do
|
||||
atomically $ incSMPServerStat c userId server ackAttempts
|
||||
tryAllErrors (sendAck c rq srvMsgId) >>= \case
|
||||
Right _ -> sendMsgNtf ackMsgs
|
||||
Left (SMP _ SMP.NO_MSG) -> sendMsgNtf ackNoMsgErrs
|
||||
Left e -> ...
|
||||
where
|
||||
sendMsgNtf stat = do
|
||||
atomically $ incSMPServerStat c userId server stat
|
||||
whenM (liftIO $ hasGetLock c rq) $ do
|
||||
atomically $ releaseGetLock c rq
|
||||
brokerTs_ <- eitherToMaybe <$> tryAllErrors (withStore c $ \db -> getRcvMsgBrokerTs db connId srvMsgId)
|
||||
atomically $ writeTBQueue (subQ c) ("", connId, AEvt SAEConn $ MSGNTF srvMsgId brokerTs_)
|
||||
```
|
||||
|
||||
**New code** — return `Maybe ATransmission` instead of writing:
|
||||
```haskell
|
||||
ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM (Maybe ATransmission)
|
||||
ackQueueMessage c rq@RcvQueue {userId, connId, server} srvMsgId = do
|
||||
atomically $ incSMPServerStat c userId server ackAttempts
|
||||
tryAllErrors (sendAck c rq srvMsgId) >>= \case
|
||||
Right _ -> sendMsgNtf ackMsgs
|
||||
Left (SMP _ SMP.NO_MSG) -> sendMsgNtf ackNoMsgErrs
|
||||
Left e -> ... >> pure Nothing
|
||||
where
|
||||
sendMsgNtf stat = do
|
||||
atomically $ incSMPServerStat c userId server stat
|
||||
ifM (liftIO $ hasGetLock c rq)
|
||||
(do atomically $ releaseGetLock c rq
|
||||
brokerTs_ <- eitherToMaybe <$> tryAllErrors (withStore c $ \db -> getRcvMsgBrokerTs db connId srvMsgId)
|
||||
pure $ Just ("", connId, AEvt SAEConn $ MSGNTF srvMsgId brokerTs_))
|
||||
(pure Nothing)
|
||||
```
|
||||
|
||||
**Caller 1: `ackMessage'`** (Agent.hs:2253-2267) — return event from `withConnLock`, write after:
|
||||
```haskell
|
||||
ackMessage' c connId msgId rcptInfo_ = do
|
||||
t_ <- withConnLock c connId "ackMessage" $ do
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection {} -> do
|
||||
t_ <- ack
|
||||
sendRcpt conn
|
||||
del
|
||||
pure t_
|
||||
RcvConnection {} -> do
|
||||
t_ <- ack
|
||||
del
|
||||
pure t_
|
||||
SndConnection {} -> throwE $ CONN SIMPLEX "ackMessage"
|
||||
ContactConnection {} -> throwE $ CMD PROHIBITED "ackMessage: ContactConnection"
|
||||
NewConnection _ -> throwE $ CMD PROHIBITED "ackMessage: NewConnection"
|
||||
-- subQ write is OUTSIDE connLock
|
||||
case t_ of
|
||||
Just t -> atomically $ writeTBQueue (subQ c) t
|
||||
Nothing -> pure ()
|
||||
```
|
||||
|
||||
**Caller 2: `ICAck` / `ICAckDel`** (Agent.hs:1823-1824) — inline `tryWithLock` as `tryCommand` + `withConnLock`, write subQ between the two scopes:
|
||||
|
||||
`tryWithLock name = tryCommand . withConnLock c connId name` — by inlining, the subQ write can be placed outside `withConnLock` but inside `tryCommand` (retaining retry/error handling).
|
||||
|
||||
```haskell
|
||||
ICAck rId srvMsgId -> withServer $ \srv ->
|
||||
tryCommand $ do
|
||||
t_ <- withConnLock c connId "ICAck" $ ack srv rId srvMsgId
|
||||
-- subQ write is OUTSIDE connLock — cannot deadlock with agentSubscriber
|
||||
forM_ t_ $ atomically . writeTBQueue subQ
|
||||
|
||||
ICAckDel rId srvMsgId msgId -> withServer $ \srv ->
|
||||
tryCommand $ do
|
||||
t_ <- withConnLock c connId "ICAckDel" $ do
|
||||
t_ <- ack srv rId srvMsgId
|
||||
withStore' c (\db -> deleteMsg db connId msgId)
|
||||
pure t_
|
||||
-- subQ write is OUTSIDE connLock — cannot deadlock with agentSubscriber
|
||||
forM_ t_ $ atomically . writeTBQueue subQ
|
||||
```
|
||||
|
||||
Where `ack` now returns `AM (Maybe ATransmission)`:
|
||||
```haskell
|
||||
ack srv rId srvMsgId = do
|
||||
rq <- withStore c $ \db -> getRcvQueue db connId srv rId
|
||||
ackQueueMessage c rq srvMsgId
|
||||
```
|
||||
|
||||
All subQ writes for MSGNTF are now outside connLock. FIFO ordering is preserved — no `nonBlockingWriteTBQueue`, no forked threads. The same thread that held the lock writes to subQ sequentially after releasing it.
|
||||
|
||||
### Race analysis
|
||||
|
||||
Window between connLock release and subQ write at Site 1:
|
||||
|
||||
| Thread | Can acquire connLock(X)? | Writes subQ? | Consequence |
|
||||
|--------|-------------------------|-------------|-------------|
|
||||
| agentSubscriber via sendMessagesB_ | Yes | **No** (encrypts only) | No race |
|
||||
| processSMP for connId X | Yes | Yes (pending flush) | MSG before SWITCH — cosmetic |
|
||||
| runCommandProcessing for connId X | Yes | Yes (pending flush) | Command response before SWITCH — cosmetic |
|
||||
| New queue delivery worker | No (SENT outside lock) | Yes | SENT before SWITCH — cosmetic, **already exists in current code** |
|
||||
|
||||
All races are cosmetic UI ordering. None affect ratchet state, protocol correctness, or message delivery.
|
||||
|
||||
### Summary of changes
|
||||
|
||||
| File | Change | Lines affected |
|
||||
|------|--------|---------------|
|
||||
| Agent.hs | Restructure AM_QTEST_ to return event from `withConnLock`, write outside | ~2187-2214 |
|
||||
| Agent.hs | Change `ackQueueMessage` return type to `AM (Maybe ATransmission)`, return event instead of writing | ~2371-2386 |
|
||||
| Agent.hs | `ackMessage'`: return event from `withConnLock`, write outside | ~2253-2267 |
|
||||
| Agent.hs | `ICAck`/`ICAckDel`: inline `tryCommand` + `withConnLock`, write subQ between scopes | ~1823-1824 |
|
||||
| Agent.hs | `ack` helper: propagate new return type | ~1899-1901 |
|
||||
|
||||
No new data structures. No new modules. No changes to other write sites (1937, 3216 — already safe). ~25 lines changed total.
|
||||
@@ -0,0 +1,299 @@
|
||||
# SMP Agent for Browser — Web Widget Infrastructure
|
||||
|
||||
## 1. Problem & Goal
|
||||
|
||||
The SimpleX web widget needs to create duplex connections, send and receive encrypted messages, and handle the full SMP agent lifecycle — all running in the browser. This requires a TypeScript implementation of the SMP protocol stack: encoding, transport, client, and agent layers, mirroring the Haskell implementation in simplexmq.
|
||||
|
||||
This document covers the protocol infrastructure that lives in the simplexmq repository (`smp-web/`). The widget UI and chat-layer semantics (contact addresses, business addresses, group links) live in simplex-chat.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
Four layers, mirroring the Haskell codebase:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Agent Layer │
|
||||
│ Duplex connections, X3DH key agreement, double ratchet, │
|
||||
│ message delivery, queue rotation, connection lifecycle │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Client Layer │
|
||||
│ Connection pool (per server), command/response correlation, │
|
||||
│ reconnection, backoff │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Transport Layer │
|
||||
│ WebSocket, SMP handshake, block framing (16384 bytes), │
|
||||
│ block encryption (X25519 DH + SbChainKeys) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Protocol Layer │
|
||||
│ SMP commands (NEW, KEY, SUB, SEND, ACK, etc.), │
|
||||
│ binary encoding, transmission format │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Shared (from xftp-web) │
|
||||
│ encoding.ts, secretbox.ts, padding.ts, keys.ts, digest.ts │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ WebSocket (TLS via browser)
|
||||
┌───────────────┐
|
||||
│ SMP Server │
|
||||
│ (SNI → Warp │
|
||||
│ → WS upgrade)│
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Core Principle: Mirror Haskell Structure
|
||||
|
||||
TypeScript code mirrors the Haskell module hierarchy as closely as possible. Each Haskell module has a corresponding TypeScript file, placed in the same relative path. Functions keep the same names. This enables:
|
||||
- Easy cross-reference between codebases
|
||||
- Sync as protocol evolves
|
||||
- Code review by people who know the Haskell side
|
||||
- Byte-for-byte testing of corresponding functions
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
smp-web/
|
||||
├── src/
|
||||
│ ├── protocol.ts ← SMP commands, transmission format
|
||||
│ ├── protocol/
|
||||
│ │ └── types.ts ← protocol types
|
||||
│ ├── version.ts ← version range negotiation
|
||||
│ ├── transport.ts ← handshake, block framing, THandle
|
||||
│ ├── transport/
|
||||
│ │ └── websockets.ts ← WebSocket connection
|
||||
│ ├── client.ts ← connection pool, correlation, reconnect
|
||||
│ ├── crypto/
|
||||
│ │ ├── ratchet.ts ← double ratchet
|
||||
│ │ └── shortLink.ts ← HKDF, link data decrypt
|
||||
│ └── agent/
|
||||
│ ├── protocol.ts ← connection types, link data parsing
|
||||
│ └── client.ts ← connection lifecycle, message delivery
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Encoding and crypto primitives are imported directly from xftp-web (npm dependency). New files are only created where SMP-specific logic is needed.
|
||||
|
||||
### Haskell Module → TypeScript File Mapping
|
||||
|
||||
| Haskell Module | TypeScript File | Source |
|
||||
|---|---|---|
|
||||
| `Simplex.Messaging.Encoding` | xftp-web `protocol/encoding.ts` | import directly |
|
||||
| `Simplex.Messaging.Crypto` | xftp-web `crypto/*` | import directly |
|
||||
| `Simplex.Messaging.Protocol` | `protocol.ts` | new |
|
||||
| `Simplex.Messaging.Protocol.Types` | `protocol/types.ts` | new |
|
||||
| `Simplex.Messaging.Version` | `version.ts` | new |
|
||||
| `Simplex.Messaging.Transport` | `transport.ts` | new |
|
||||
| `Simplex.Messaging.Transport.WebSockets` | `transport/websockets.ts` | new |
|
||||
| `Simplex.Messaging.Client` | `client.ts` | new |
|
||||
| `Simplex.Messaging.Crypto.Ratchet` | `crypto/ratchet.ts` | new |
|
||||
| `Simplex.Messaging.Crypto.ShortLink` | `crypto/shortLink.ts` | new |
|
||||
| `Simplex.Messaging.Agent.Protocol` | `agent/protocol.ts` | new |
|
||||
| `Simplex.Messaging.Agent.Client` | `agent/client.ts` | new |
|
||||
|
||||
Function names in TypeScript match Haskell names (camelCase preserved). When a Haskell function is `smpClientHandshake`, TypeScript has `smpClientHandshake`. When Haskell has `contactShortLinkKdf`, TypeScript has `contactShortLinkKdf`.
|
||||
|
||||
## 3. Relationship to xftp-web
|
||||
|
||||
xftp-web (`simplexmq-2/xftp-web/`) is a production TypeScript XFTP client. smp-web reuses its foundations:
|
||||
|
||||
**Reused directly (npm dependency)**:
|
||||
- `protocol/encoding.ts` — Decoder class, Word16/Word32/Int64, ByteString, Large, Bool, Maybe, List encoding
|
||||
- `crypto/secretbox.ts` — XSalsa20-Poly1305 (cbEncrypt/cbDecrypt, streaming)
|
||||
- `crypto/padding.ts` — Block padding (2-byte length prefix + `#` fill)
|
||||
- `crypto/keys.ts` — Ed25519, X25519 key generation, signing, DH, DER encoding
|
||||
- `crypto/digest.ts` — SHA-256, SHA-512
|
||||
- `crypto/identity.ts` — X.509 certificate chain parsing, signature verification
|
||||
|
||||
**New in smp-web**:
|
||||
- SMP protocol commands and transmission format
|
||||
- SMP handshake (different from XFTP handshake)
|
||||
- WebSocket transport (XFTP uses HTTP/2 fetch)
|
||||
- SMP client with queue-based correlation
|
||||
- Agent layer (connections, ratchet, message processing)
|
||||
- Short link operations (HKDF-SHA512, link data parsing)
|
||||
|
||||
**Same build pattern**:
|
||||
- TypeScript strict, ES2022 modules
|
||||
- `tsc` → `dist/`
|
||||
- Haskell tests via `callNode` (same function from XFTPWebTests)
|
||||
- Each TypeScript function verified byte-for-byte against Haskell
|
||||
|
||||
## 4. Server Changes
|
||||
|
||||
### Done
|
||||
- `attachStaticAndWS` — unified HTTP + WebSocket handler via `wai-websockets`
|
||||
- SNI-based routing: browser (SNI) → Warp → WebSocket upgrade → SMP over WS; native (no SNI) → SMP over TLS
|
||||
- `acceptWSConnection` — constructs `WS 'TServer` from TLS connection + Warp PendingConnection, preserves peer cert chain
|
||||
- `AttachHTTP` takes `TLS 'TServer` (not raw Context), enabling proper cert chain forwarding
|
||||
- Test: `testWebSocketAndTLS` verifies native TLS and WebSocket clients on same port
|
||||
|
||||
### Remaining
|
||||
- CORS headers for cross-origin widget embedding (pattern available in XFTP server)
|
||||
- Server CLI configuration for enabling/disabling WebSocket support per port
|
||||
|
||||
## 5. Build Approach
|
||||
|
||||
Bottom-up, function-by-function. Each TypeScript function tested against its Haskell counterpart before building the next.
|
||||
|
||||
**Test infrastructure**: `SMPWebTests.hs` reuses `callNode`, `jsOut`, `jsUint8` from `XFTPWebTests.hs` (generalized, not copied).
|
||||
|
||||
**Pattern**: for each function:
|
||||
1. Implement in TypeScript
|
||||
2. Write Haskell test that calls it via `callNode`
|
||||
3. Compare output byte-for-byte with Haskell reference
|
||||
4. Also test cross-language: Haskell encodes → TypeScript decodes, and vice versa
|
||||
|
||||
## 6. Implementation Phases
|
||||
|
||||
### Phase 1: Protocol Encoding + Handshake
|
||||
|
||||
Foundation layer. SMP-specific binary encoding and handshake.
|
||||
|
||||
**Functions**:
|
||||
- SMP transmission format: `[auth ByteString][corrId ByteString][entityId ByteString][command]`
|
||||
- `encodeTransmission` / `parseTransmission`
|
||||
- `parseSMPServerHandshake` — versionRange, sessionId, authPubKey (CertChainPubKey)
|
||||
- `encodeSMPClientHandshake` — version, keyHash, authPubKey, proxyServer, clientService
|
||||
- Server certificate chain verification (reuse xftp-web identity.ts)
|
||||
- Version negotiation
|
||||
|
||||
**Key encoding details**:
|
||||
- `authPubKey` uses `encodeAuthEncryptCmds`: Nothing → empty (0 bytes), Just → raw smpEncode (NOT Maybe 0/1 prefix)
|
||||
- `proxyServer`: Bool 'T'/'F' (v14+)
|
||||
- `clientService`: Maybe '0'/'1' (v16+)
|
||||
|
||||
### Phase 2: SMP Commands
|
||||
|
||||
All commands needed for messaging.
|
||||
|
||||
**Sender**: SKEY, SEND
|
||||
**Receiver**: NEW, KEY, SUB, ACK, OFF, DEL
|
||||
**Link**: LGET
|
||||
**Common**: PING
|
||||
|
||||
**For each command**: encode function + decode function for its response, tested against Haskell.
|
||||
|
||||
### Phase 3: Transport
|
||||
|
||||
WebSocket connection with SMP block framing.
|
||||
|
||||
**Functions**:
|
||||
- WebSocket connect (`wss://` URL)
|
||||
- Block send/receive (16384-byte binary frames)
|
||||
- SMP handshake over WebSocket
|
||||
- Block encryption: X25519 DH → HKDF-SHA512 → SbChainKeys → per-block XSalsa20-Poly1305
|
||||
|
||||
**Block encryption flow**:
|
||||
1. Client generates ephemeral X25519 keypair, sends public key in handshake
|
||||
2. Server sends its signed DH key in handshake
|
||||
3. Both sides compute DH shared secret
|
||||
4. `sbcInit(sessionId, dhSecret)` → two 32-byte chain keys (HKDF-SHA512)
|
||||
5. Each block: `sbcHkdf(chainKey)` → ephemeral key + nonce, advance chain
|
||||
6. Encrypt/decrypt with XSalsa20-Poly1305, blockSize-16 padding target
|
||||
|
||||
### Phase 4: Client
|
||||
|
||||
Connection management layer.
|
||||
|
||||
**Functions**:
|
||||
- Connection pool: one WebSocket per SMP server
|
||||
- Command/response correlation via corrId
|
||||
- Send queue + receive queue (ABQueue pattern from simplexmq-js)
|
||||
- Automatic reconnection with exponential backoff
|
||||
- Timeout handling
|
||||
|
||||
### Phase 5: Agent — Connection Establishment
|
||||
|
||||
Duplex SMP connections with X3DH key agreement.
|
||||
|
||||
**Functions**:
|
||||
- Create receive queue (NEW)
|
||||
- Join connection via invitation URI
|
||||
- X3DH key agreement
|
||||
- Send confirmation (SKEY + SEND)
|
||||
- Complete handshake (HELLO exchange)
|
||||
- Connection state machine
|
||||
|
||||
### Phase 6: Agent — Double Ratchet
|
||||
|
||||
Message encryption/decryption.
|
||||
|
||||
**Functions**:
|
||||
- Signal double ratchet implementation
|
||||
- Header encryption
|
||||
- Ratchet state management
|
||||
- Key derivation (HKDF)
|
||||
- Message sequence + hash chain verification
|
||||
|
||||
### Phase 7: Agent — Message Delivery
|
||||
|
||||
Send and receive messages through established connections.
|
||||
|
||||
**Functions**:
|
||||
- Send path: encrypt → encode agent envelope → SEND → handle OK/delivery receipt
|
||||
- Receive path: SUB → receive MSG → decrypt → verify → ACK
|
||||
- Delivery receipts
|
||||
- Message acknowledgment
|
||||
|
||||
### Phase 8: Short Links
|
||||
|
||||
Entry point for the widget — parse short link, fetch profile.
|
||||
|
||||
**Functions**:
|
||||
- Parse short link URI (contact, group, business address types)
|
||||
- HKDF key derivation (SHA-512): `contactShortLinkKdf`
|
||||
- LGET command → LNK response
|
||||
- Decrypt link data (XSalsa20-Poly1305)
|
||||
- Parse FixedLinkData, ConnLinkData, UserLinkData
|
||||
- Extract profile JSON
|
||||
|
||||
## 7. Persistence
|
||||
|
||||
Agent state (keys, ratchet, connections, messages) must persist across page reloads.
|
||||
|
||||
**Open question**: storage backend.
|
||||
|
||||
Options:
|
||||
- **IndexedDB directly** — universal browser support, async API, no additional dependencies. Downside: key-value semantics, no SQL queries, manual indexing.
|
||||
- **SQLite in browser** — sql.js (WASM-compiled SQLite) or wa-sqlite (with OPFS backend for persistence). Upside: matches Haskell agent's SQLite storage, schema can mirror `Simplex.Messaging.Agent.Store.SQLite`. Downside: additional dependency, WASM bundle size.
|
||||
- **OPFS + SQLite** — Origin Private File System for durable storage, SQLite for structured access. Best durability, but limited browser support (no Safari private browsing).
|
||||
|
||||
**Decision criteria**: how closely we want to mirror the Haskell agent's storage schema, bundle size budget, browser compatibility requirements.
|
||||
|
||||
## 8. Testing Strategy
|
||||
|
||||
### Unit Tests (per function)
|
||||
|
||||
Haskell tests in `SMPWebTests.hs` using `callNode` pattern:
|
||||
- TypeScript function called via Node.js subprocess
|
||||
- Output compared byte-for-byte with Haskell reference
|
||||
- Cross-language tests: encode in one language, decode in the other
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Against live SMP server (spawned by test setup, same pattern as xftp-web globalSetup.ts):
|
||||
- WebSocket connect + handshake
|
||||
- Command round-trips (NEW, KEY, SUB, SEND, ACK)
|
||||
- Message delivery through server
|
||||
- Reconnection after disconnect
|
||||
|
||||
### Browser Tests
|
||||
|
||||
Vitest + Playwright (same as xftp-web):
|
||||
- Full connection lifecycle in browser environment
|
||||
- WebSocket transport in real browser
|
||||
- Persistence round-trips
|
||||
|
||||
## 9. Security Model
|
||||
|
||||
Same principles as xftp-web:
|
||||
- **TLS via browser** — browser handles certificate validation for WSS connections
|
||||
- **SNI routing** — browser connections use SNI, routed to Warp + WebSocket handler
|
||||
- **Server identity** — verified via certificate chain in SMP handshake (keyHash from short link or known servers)
|
||||
- **Block encryption** — X25519 DH + SbChainKeys provides forward secrecy per block, on top of TLS
|
||||
- **End-to-end encryption** — double ratchet between agent peers, server sees only encrypted blobs
|
||||
- **No server-side secrets** — all keys derived and stored client-side
|
||||
- **CORS** — required for cross-origin widget embedding, safe because SMP requires auth on every command
|
||||
- **CSP** — strict content security policy for widget page
|
||||
|
||||
**Threat model**: same as xftp-web. Primary risk is page substitution (malicious JS). Mitigated by HTTPS, CSP, SRI, and optionally IPFS hosting with published fingerprints.
|
||||
@@ -0,0 +1,359 @@
|
||||
# SMP Agent Web: Spike Plan
|
||||
|
||||
Revision 4, 2026-03-20
|
||||
|
||||
Parent RFC: [2026-03-20-smp-agent-web.md](../2026-03-20-smp-agent-web.md)
|
||||
|
||||
## Revision History
|
||||
|
||||
- **Rev 4**: Aligned with RFC. Restructured as bottom-up build plan with per-function Haskell tests. Router WebSocket support done. File structure mirrors Haskell modules.
|
||||
- **Rev 3**: Fixed multiple encoding errors discovered during audit (see encoding details below).
|
||||
|
||||
## Objective
|
||||
|
||||
Fetch and display business/contact profile from a SimpleX short link URI, via WebSocket to SMP router. This is the first milestone of the SMP agent web implementation — it proves the protocol encoding, transport, crypto, and data parsing layers work end-to-end.
|
||||
|
||||
The spike is not throwaway code. It is the beginning of the `smp-web/` TypeScript library, built bottom-up with each function tested against its Haskell counterpart.
|
||||
|
||||
## What This Proves
|
||||
|
||||
- WebSocket transport to SMP router works from browser
|
||||
- SMP protocol encoding is correct (binary format, not ASCII)
|
||||
- SMP handshake works (version negotiation, server certificate parsing)
|
||||
- Crypto is compatible (HKDF-SHA512, XSalsa20-Poly1305)
|
||||
- Short link data parsing matches Haskell (FixedLinkData, ConnLinkData, profile)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Haskell test creates a short link, TypeScript fetches and decodes it via WebSocket, profile data matches.
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
```
|
||||
1. Parse short link URI
|
||||
https://simplex.chat/c#<linkKey>?h=hosts&p=port&c=keyHash
|
||||
→ server, linkKey
|
||||
|
||||
2. Derive keys (HKDF-SHA512)
|
||||
linkKey → (linkId, sbKey)
|
||||
|
||||
3. WebSocket connect
|
||||
wss://server:443 (TLS handled by browser)
|
||||
|
||||
4. SMP handshake
|
||||
← SMPServerHandshake {sessionId, smpVersionRange, authPubKey}
|
||||
→ SMPClientHandshake {smpVersion, keyHash, authPubKey=Nothing, proxyServer=False, clientService=Nothing}
|
||||
|
||||
5. Send LGET
|
||||
→ [empty auth][corrId][linkId]["LGET"]
|
||||
|
||||
6. Receive LNK
|
||||
← [auth][corrId][linkId]["LNK" space senderId encFixedData encUserData]
|
||||
|
||||
7. Decrypt
|
||||
XSalsa20-Poly1305 with sbKey
|
||||
→ FixedLinkData, ConnLinkData (with profile JSON)
|
||||
|
||||
8. Display profile
|
||||
```
|
||||
|
||||
Note: spike sends `authPubKey=Nothing` so block encryption is not used (blocks are padded only). Block encryption is added in steps 12-13.
|
||||
|
||||
|
||||
## Build Approach
|
||||
|
||||
Bottom-up, function-by-function. Each TypeScript function tested against its Haskell counterpart via `callNode` — the same pattern used in xftp-web (see `XFTPWebTests.hs`).
|
||||
|
||||
**Project location**: `simplexmq-2/smp-web/`
|
||||
**Tests**: `simplexmq-2/tests/SMPWebTests.hs` — reuses `callNode`/`jsOut`/`jsUint8` from XFTPWebTests (generalized, not copied)
|
||||
**xftp-web**: npm dependency via `file:../xftp-web` (encoding, crypto, padding imported directly). Note: libsodium-wrappers-sumo is xftp-web's dependency; tests must init the same sodium instance that xftp-web's secretbox uses. If xftp-web is ever published to npm, libsodium should become a peerDependency.
|
||||
**File structure**: mirrors Haskell module hierarchy (see RFC section 2)
|
||||
|
||||
**Pattern for each function**:
|
||||
1. Check if xftp-web already implements it (or something close). If so, import and reuse — export from xftp-web if not yet exported. Only write new code when no existing implementation covers the need.
|
||||
2. Implement in TypeScript, in the file corresponding to its Haskell module
|
||||
3. Write Haskell test that calls it via `callNode`
|
||||
4. Compare output byte-for-byte with Haskell reference
|
||||
5. Cross-language: Haskell encodes → TypeScript decodes, and vice versa
|
||||
|
||||
### Parsing Approach
|
||||
|
||||
All binary parsing uses xftp-web's `Decoder` class — the same class, not a copy. `Decoder` tracks position over a `Uint8Array`, throws on malformed input, returns subarray views (zero-copy).
|
||||
|
||||
SMP command parsing follows the same pattern as xftp-web's `decodeResponse` in `commands.ts`: `readTag` reads bytes until space or end, switch dispatches on the tag string, fields are parsed sequentially with `Decoder` methods (`decodeBytes`, `decodeLarge`, `decodeBool`, etc.).
|
||||
|
||||
**Prerequisite xftp-web change**: `readTag` and `readSpace` in xftp-web's `commands.ts` need to be exported so smp-web can import them.
|
||||
|
||||
### WebSocket Transport Approach
|
||||
|
||||
WebSocket transport follows the simplexmq-js `WSTransport` pattern:
|
||||
|
||||
- `WebSocket` connects to `wss://` URL with `binaryType = 'arraybuffer'`
|
||||
- `onmessage` enqueues received frames into an `ABQueue` (async bounded queue with backpressure)
|
||||
- `onclose` closes the queue (sentinel-based)
|
||||
- `readBlock()` dequeues one frame, validates it is exactly 16384 bytes
|
||||
- `sendBlock(data)` sends one 16384-byte binary frame
|
||||
|
||||
The `ABQueue` class from simplexmq-js provides backpressure via semaphores and clean async iteration. It can be included in smp-web or extracted as a shared utility.
|
||||
|
||||
The SMP transport layer wraps WebSocket transport:
|
||||
- Receives raw blocks → unpad → parse transmission
|
||||
- Encodes transmission → pad → send as block
|
||||
- After handshake, if block encryption is active: decrypt before unpad, encrypt after pad
|
||||
|
||||
|
||||
## Encoding Reference
|
||||
|
||||
Binary encoding rules (from `Simplex.Messaging.Encoding`):
|
||||
|
||||
| Type | Format |
|
||||
|------|--------|
|
||||
| `Word16` | 2 bytes big-endian |
|
||||
| `Word32` | 4 bytes big-endian |
|
||||
| `ByteString` | 1-byte length + bytes (max 255) |
|
||||
| `Large` | 2-byte length (BE) + bytes (max 65535) |
|
||||
| `Bool` | 'T' (0x54) or 'F' (0x46) |
|
||||
| `Maybe a` | '0' (0x30) for Nothing, '1' (0x31) + value for Just |
|
||||
| `smpEncodeList` | 1-byte count + items |
|
||||
| `UserLinkData` | ByteString if ≤254 bytes, else 0xFF + Large |
|
||||
|
||||
**Critical**: `encodeAuthEncryptCmds Nothing` = empty (0 bytes), NOT 'F' or '0'.
|
||||
|
||||
**Transmission format** (binary, NOT ASCII with spaces):
|
||||
```
|
||||
[auth ByteString][corrId ByteString][entityId ByteString][command bytes]
|
||||
```
|
||||
|
||||
For v7+ (`implySessId = True`): sessionId is NOT sent on wire, but is prepended to the `authorized` data for signature verification. For unauthenticated commands (LGET), this doesn't apply.
|
||||
|
||||
**Block framing**: `pad(transmission, 16384)` = `[2-byte BE length][message][padding with '#' (0x23)]`
|
||||
|
||||
|
||||
## Server Changes — DONE
|
||||
|
||||
WebSocket support on the same port as native TLS is implemented and tested.
|
||||
|
||||
- `attachStaticAndWS` — unified HTTP + WebSocket handler via `wai-websockets`
|
||||
- SNI routing: browser (SNI) → Warp → WebSocket upgrade → SMP over WS
|
||||
- `acceptWSConnection` — constructs `WS 'TServer` from `TLS 'TServer` + PendingConnection
|
||||
- Test: `testWebSocketAndTLS` in `ServerTests.hs`
|
||||
|
||||
**Remaining**: CORS headers for cross-origin widget embedding.
|
||||
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
Each step produces working, tested code. Steps 1-11 work without block encryption. Steps 12-13 add it.
|
||||
|
||||
### Step 1: Project Setup + xftp-web Changes
|
||||
|
||||
**smp-web setup**:
|
||||
- Create `smp-web/` with `package.json` (xftp-web + `@noble/hashes` as dependencies), `tsconfig.json` (ES2022, strict, same as xftp-web)
|
||||
- Build: `tsc` → `dist/`
|
||||
|
||||
**xftp-web change**:
|
||||
- Export `readTag` and `readSpace` from `commands.ts` (currently unexported) so smp-web can import them
|
||||
|
||||
**Test infrastructure**:
|
||||
- Create `SMPWebTests.hs`, reusing `callNode`/`jsOut`/`jsUint8` from XFTPWebTests (generalize shared utilities into a common test module, not copy)
|
||||
- First test: import `decodeBytes` from xftp-web, encode a ByteString, verify output matches Haskell `smpEncode`
|
||||
|
||||
### Step 2: SMP Transmission Encode/Decode
|
||||
|
||||
**File**: `protocol.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Protocol` — `encodeTransmission_`, `transmissionP`
|
||||
|
||||
**Implementation**:
|
||||
- `encodeTransmission(corrId, entityId, command)`: `concatBytes(encodeBytes(emptyAuth), encodeBytes(corrId), encodeBytes(entityId), command)` — unsigned, empty auth byte (0x00)
|
||||
- `decodeTransmission(data)`: sequential Decoder — `decodeBytes` for auth, corrId, entityId, then `takeAll` for command bytes
|
||||
- Pad/unpad: reuse xftp-web `blockPad`/`blockUnpad` (same 2-byte length prefix + '#' padding, same 16384 block size)
|
||||
|
||||
**Tests**: encode in TypeScript → decode in Haskell (`transmissionP`), encode in Haskell (`encodeTransmission_`) → decode in TypeScript. Byte-for-byte match.
|
||||
|
||||
### Step 3: SMP Handshake Parse/Encode
|
||||
|
||||
**File**: `transport.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Transport` — `SMPServerHandshake`, `SMPClientHandshake`
|
||||
|
||||
**Implementation**:
|
||||
- `parseSMPServerHandshake(d: Decoder)`: `decodeWord16` × 2 for versionRange, `decodeBytes` for sessionId. For authPubKey: if `maxVersion >= 7` and bytes remaining, parse `CertChainPubKey` (reuse xftp-web `identity.ts` for X.509 cert chain parsing and signature extraction). If no bytes remain, authPubKey is absent (encodeAuthEncryptCmds encoded Nothing as empty).
|
||||
- `encodeSMPClientHandshake(...)`: `concatBytes(encodeWord16(version), encodeBytes(keyHash), authPubKeyBytes, encodeBool(proxyServer), encodeMaybe(encodeService, clientService))`. Where authPubKey: empty bytes for Nothing, `encodeBytes(pubkey)` for Just. proxyServer only for v14+, clientService only for v16+.
|
||||
|
||||
**Tests**: Haskell encodes `SMPServerHandshake` → TypeScript parses, all fields match. TypeScript encodes `SMPClientHandshake` → Haskell parses via `smpP`.
|
||||
|
||||
### Step 4: LGET Command Encode
|
||||
|
||||
**File**: `protocol.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Protocol` — `LGET` command encoding
|
||||
|
||||
**Implementation**:
|
||||
- `encodeLGET()`: returns `ascii("LGET")` — 4 bytes, no parameters. The LinkId is carried as entityId in the transmission (step 2), not in the command body.
|
||||
- Full LGET block: `blockPad(encodeTransmission(corrId, linkId, encodeLGET()), 16384)`
|
||||
|
||||
**Tests**: encode full LGET block in TypeScript, Haskell unpad + `transmissionP` + `parseProtocol` decodes as `LGET` with correct corrId and linkId.
|
||||
|
||||
### Step 5: LNK Response Parse
|
||||
|
||||
**File**: `protocol.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Protocol` — `LNK` response encoding (line 1834)
|
||||
|
||||
**Implementation**:
|
||||
- `decodeResponse(d: Decoder)`: `readTag(d)` → switch dispatch (same pattern as xftp-web `decodeResponse`)
|
||||
- For `"LNK"`: `readSpace(d)`, `decodeBytes(d)` for senderId, `decodeLarge(d)` for encFixedData, `decodeLarge(d)` for encUserData
|
||||
- Also handle `"ERR"` responses for error reporting
|
||||
|
||||
**Tests**: Haskell encodes `LNK senderId (encFixed, encUser)` → TypeScript `decodeResponse` parses. All fields match byte-for-byte.
|
||||
|
||||
### Step 6: Short Link URI Parse
|
||||
|
||||
**File**: `agent/protocol.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Agent.Protocol` — `ConnShortLink` StrEncoding instance (lines 1599-1612)
|
||||
|
||||
**Implementation**:
|
||||
- `parseShortLink(uri)`: regex to extract scheme (https/simplex), type char (c/g/a), linkKey (base64url, 43 chars → 32 bytes), query params (h=hosts, p=port, c=keyHash)
|
||||
- `base64UrlDecode(s)`: pad to multiple of 4, replace `-`→`+`, `_`→`/`, decode
|
||||
- Returns `{scheme, connType, server: {hosts, port, keyHash}, linkKey}`
|
||||
|
||||
**Tests**: Haskell `strEncode` a `ConnShortLink` → TypeScript `connShortLinkStrP` parses. All fields match. Test multiple formats: with/without query params, different type chars.
|
||||
|
||||
**Done**. Function: `connShortLinkStrP` in `agent/protocol.ts`. Uses `base64urlDecode` from xftp-web `description.ts`.
|
||||
|
||||
**Future**:
|
||||
- Add long link parsing (`ConnectionRequestUri`) and an either-parser that handles both short and long links.
|
||||
- Add `restoreShortLink`: preset servers are shortened to host-only (`SMPServerOnlyHost` - no port, no keyHash). After parsing, `restoreShortLink` looks up the full server by hostname from a preset servers list. Without this, connections to preset servers will fail. See `Agent/Protocol.hs:1692`.
|
||||
|
||||
### Step 7: HKDF Key Derivation
|
||||
|
||||
**File**: `crypto/shortLink.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Crypto.ShortLink` — `contactShortLinkKdf` (line 48)
|
||||
|
||||
**Implementation**:
|
||||
- `contactShortLinkKdf(linkKey)`: `hkdf(sha512, linkKey, new Uint8Array(0), "SimpleXContactLink", 56)` using `@noble/hashes/hkdf` + `@noble/hashes/sha512`. Split result: first 24 bytes = linkId, remaining 32 bytes = sbKey.
|
||||
|
||||
**Note**: Haskell `C.hkdf` uses SHA-512, not SHA3-256.
|
||||
|
||||
**Tests**: given known linkKey bytes, TypeScript and Haskell produce identical linkId and sbKey.
|
||||
|
||||
### Step 8: Link Data Decrypt
|
||||
|
||||
**File**: `crypto/shortLink.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Crypto.ShortLink` — `decryptLinkData` (lines 100-120)
|
||||
|
||||
**Implementation**:
|
||||
- `decryptLinkData(sbKey, encFixedData, encUserData)`:
|
||||
1. For each EncDataBytes: `Decoder` → `decodeBytes(d)` for nonce (24 bytes), `decodeTail(d)` for ciphertext (includes Poly1305 tag)
|
||||
2. `cbDecrypt(sbKey, nonce, ciphertext)` via xftp-web `secretbox.ts`
|
||||
3. From decrypted plaintext: `decodeBytes(d)` for signature (1-byte len 0x40 + 64 bytes), `decodeTail(d)` for actual data
|
||||
4. Return both plaintext data blobs (signature verification skipped for spike)
|
||||
|
||||
**Tests**: Haskell `encodeSignLinkData` + `sbEncrypt` with known key/nonce → TypeScript decrypts → plaintext matches.
|
||||
|
||||
### Step 9: ConnLinkData Parse
|
||||
|
||||
**File**: `agent/protocol.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Agent.Protocol` — `ConnLinkData`, `UserContactData`, `OwnerAuth`, `ConnShortLink`, `ProtocolServer` Encoding instances
|
||||
|
||||
**Implementation** (proper decoding, not skipping):
|
||||
- `decodeConnLinkData(d)`: `anyByte` for connectionMode ('C'=Contact), `decodeWord16` × 2 for agentVRange, then `decodeUserContactData`
|
||||
- `decodeUserContactData(d)`: `decodeBool` for direct, `smpListP(decodeOwnerAuth, d)` for owners, `smpListP(decodeConnShortLink, d)` for relays, `decodeUserLinkData(d)` for userData
|
||||
- `decodeOwnerAuth(d)`: `decodeBytes` for outer wrapper, then parse inner: `(ownerId, ownerKey, authOwnerSig)` all as ByteStrings
|
||||
- `decodeConnShortLink(d)`: `anyByte` for mode, then Contact: `(ctTypeChar, srv, linkKey)` or Invitation: `(srv, linkId, linkKey)`
|
||||
- `decodeProtocolServer(d)`: `decodeBytes` for scheme+keyHash, `decodeBytes` for host, `decodeBytes` for port — need to verify exact encoding
|
||||
- `decodeUserLinkData(d)`: first byte 0xFF → `decodeLarge`; otherwise it's the 1-byte length of a ByteString
|
||||
- `parseProfile(userData)`: check first byte for 'X' (0x58, zstd compressed) — if so, decompress; otherwise `JSON.parse` directly
|
||||
|
||||
**Tests**: Haskell encodes `ContactLinkData` with known values → TypeScript decodes → all fields match.
|
||||
|
||||
**FixedLinkData**: deferred to step 15. `linkConnReq` (ConnectionRequestUri) is NOT length-prefixed in the tuple encoding — it requires full parsing. FixedLinkData is also needed to validate mutable data signature using rootKey.
|
||||
|
||||
### Step 15: FixedLinkData Parse
|
||||
|
||||
**File**: `agent/protocol.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Agent.Protocol` — `FixedLinkData`, `ConnectionRequestUri`, `ConnReqUriData` Encoding instances
|
||||
|
||||
**Implementation**:
|
||||
- `decodeFixedLinkData(d)`: `decodeWord16` × 2 for agentVRange, `decodeBytes` for rootKey (32 bytes Ed25519), then parse `ConnectionRequestUri` (mode byte + `ConnReqUriData`), optional `decodeBytes` for linkEntityId
|
||||
- `decodeConnectionRequestUri(d)`: full parsing of `ConnReqUriData` including SMP queue URIs
|
||||
- Needed for: connecting to the contact, and validating mutable data signature with rootKey
|
||||
|
||||
**Tests**: Haskell encodes full `FixedLinkData` → TypeScript decodes → rootKey and linkConnReq fields match.
|
||||
|
||||
### Step 10: WebSocket Transport
|
||||
|
||||
**File**: `transport/websockets.ts`
|
||||
**Pattern reference**: simplexmq-js `WSTransport` + `ABQueue`
|
||||
|
||||
**Implementation**:
|
||||
- `ABQueue<T>` class: semaphore-based async bounded queue (from simplexmq-js `queue.ts` — reimplement or include as utility). `enqueue`/`dequeue`/`close`, sentinel-based close, async iterator.
|
||||
- `connectWS(url)`: `new WebSocket(url)`, `binaryType = 'arraybuffer'`, `onmessage` enqueues `Uint8Array` frames into ABQueue, `onclose` closes queue, `onerror` closes socket. Returns transport handle on `onopen`.
|
||||
- `readBlock(transport)`: dequeue one frame, verify `byteLength === 16384`, return `Uint8Array`
|
||||
- `sendBlock(transport, data)`: `ws.send(data)`, verify `data.length === 16384`
|
||||
- `smpHandshake(transport, keyHash)`: `readBlock` → `blockUnpad` → `parseSMPServerHandshake` → negotiate version → `encodeSMPClientHandshake` → `blockPad` → `sendBlock`. Returns `{sessionId, version}`.
|
||||
|
||||
**Integration test**: spawn test SMP server with web credentials (reuse `cfgWebOn` from SMPClient.hs), connect via WebSocket from Node.js, complete handshake, verify sessionId received.
|
||||
|
||||
### Step 11: End-to-End Integration
|
||||
|
||||
Wire steps 6-10 together: `parseShortLink` → `contactShortLinkKdf` → `connectWS` → `smpHandshake` → encode LGET block → `sendBlock` → `readBlock` → `blockUnpad` → `decodeTransmission` → `decodeResponse` → `decryptLinkData` → `decodeFixedLinkData` + `decodeConnLinkData` → `parseProfile`.
|
||||
|
||||
**Test**: Haskell creates a contact address with short link (using agent), TypeScript fetches and decodes it via WebSocket. Profile displayName matches. This is the full spike proof: browser can fetch a SimpleX contact profile via SMP protocol.
|
||||
|
||||
### Step 12: Server Certificate Verification
|
||||
|
||||
**File**: `transport.ts`
|
||||
**Approach**: client sends a random challenge in an HTTP header on the WebSocket upgrade request. Server includes the signed challenge in the handshake response. Client verifies the signature using the server's certificate chain.
|
||||
|
||||
**Implementation**:
|
||||
- Generate 32-byte random challenge, send as HTTP header (e.g. `smp-web-challenge`) on WebSocket upgrade
|
||||
- Parse `CertChainPubKey` from server handshake (already parsed in step 3 as `authPubKey`)
|
||||
- Verify certificate chain fingerprint matches `keyHash` (reuse xftp-web `caFingerprint`)
|
||||
- Verify challenge signature (reuse xftp-web `identity.ts` — `extractCertPublicKeyInfo`, signature verification)
|
||||
- Requires server-side change: detect the challenge header on WebSocket connections, sign `challenge || sessionId` with server key, include proof in handshake
|
||||
|
||||
**Tests**: connect to test server, verify challenge-response succeeds. Connect with wrong keyHash, verify rejection.
|
||||
|
||||
### Step 13: Block Encryption (DH + SbChainKeys)
|
||||
|
||||
**File**: `transport.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Crypto` — `sbcInit`, `sbcHkdf`; `Simplex.Messaging.Transport` — `tPutBlock`, `tGetBlock`
|
||||
|
||||
**Implementation**:
|
||||
- `generateX25519KeyPair()`, `dh(peerPub, ownPriv)` — reuse from xftp-web `keys.ts`
|
||||
- `sbcInit(sessionId, dhSecret)`: `hkdf(sha512, dhSecret, sessionId, "SimpleXSbChainInit", 64)` → split at 32: `(sndChainKey, rcvChainKey)`. Note client swaps send/receive keys vs server (line 858 Transport.hs).
|
||||
- `sbcHkdf(chainKey)`: `hkdf(sha512, chainKey, "", "SimpleXSbChain", 88)` → split: 32 bytes new chainKey, 32 bytes sbKey, 24 bytes nonce. Returns `{sbKey, nonce, nextChainKey}`.
|
||||
- `encryptBlock(state, block)`: `sbcHkdf` → `cryptoBox(sbKey, nonce, pad(block, blockSize - 16))` → 16-byte tag + ciphertext
|
||||
- `decryptBlock(state, block)`: `sbcHkdf` → split tag (first 16 bytes) + ciphertext → `cryptoBoxOpen` → `unpad`
|
||||
|
||||
**Tests**: Haskell and TypeScript DH with same keys → identical chain keys. Haskell encrypts block → TypeScript decrypts (and vice versa). Chain key advances identically after each block.
|
||||
|
||||
### Step 14: Full Handshake with Auth
|
||||
|
||||
**File**: `transport.ts`
|
||||
**Haskell reference**: `Simplex.Messaging.Transport` — `smpClientHandshake` (lines 792-842)
|
||||
|
||||
**Implementation**:
|
||||
- Update `smpHandshake` to generate ephemeral X25519 keypair and include public key in `encodeSMPClientHandshake` as authPubKey
|
||||
- Compute DH: `dh(serverDhPub, clientPrivKey)` → shared secret
|
||||
- `sbcInit(sessionId, dhSecret)` → chain keys (with client-side swap)
|
||||
- All subsequent `readBlock`/`sendBlock` go through `decryptBlock`/`encryptBlock`
|
||||
|
||||
**Tests**: full handshake with real server, block encryption active, exchange encrypted commands. Haskell sends encrypted response → TypeScript decrypts correctly.
|
||||
|
||||
|
||||
## Haskell Code References
|
||||
|
||||
### Handshake
|
||||
- `Simplex.Messaging.Transport` — `smpClientHandshake`, `smpServerHandshake`, `SMPServerHandshake`, `SMPClientHandshake`
|
||||
- `encodeAuthEncryptCmds` — Nothing → empty, Just → raw smpEncode
|
||||
|
||||
### Protocol
|
||||
- `Simplex.Messaging.Protocol` — `LGET`, `LNK`, `encodeTransmission_`, `transmissionP`
|
||||
- Block: `pad`/`unPad` in `Simplex.Messaging.Crypto`
|
||||
|
||||
### Short Links
|
||||
- `Simplex.Messaging.Crypto.ShortLink` — `contactShortLinkKdf`, `decryptLinkData`
|
||||
- `Simplex.Messaging.Agent.Protocol` — `ConnShortLink`, `FixedLinkData`, `ConnLinkData`, `UserLinkData`
|
||||
|
||||
### Block Encryption
|
||||
- `Simplex.Messaging.Crypto` — `sbcInit`, `sbcHkdf`, `sbEncrypt`, `sbDecrypt`, `dh'`
|
||||
- `Simplex.Messaging.Transport` — `blockEncryption`, `TSbChainKeys`, `tPutBlock`, `tGetBlock`
|
||||
@@ -0,0 +1,355 @@
|
||||
# SMP Client for Browser
|
||||
|
||||
**Parent**: [SMP Agent Web Spike](./2026-03-20-smp-agent-web-spike.md)
|
||||
**Depends on**: Spike 1 (merged) — transport, ratchet, encoding, per-queue E2E
|
||||
|
||||
## Context
|
||||
|
||||
The encoding spike proved all four encryption layers work cross-language. The next implementable and testable piece is the SMP client — the layer that sends commands, correlates responses by CorrId, authenticates with entity keys, and exposes typed async functions.
|
||||
|
||||
Faithful transpilation of `Simplex.Messaging.Client` (Client.hs). Transport is WebSocket (already working), protocol logic is identical to Haskell.
|
||||
|
||||
## Encoding path (per command)
|
||||
|
||||
Traced from `sendSMPMessage` through every function call:
|
||||
|
||||
```
|
||||
1. encodeTransmission_(v, (corrId, entityId, command))
|
||||
→ smpEncode(corrId, entityId) <> encodeProtocol(v, cmd)
|
||||
Already have as encodeTransmission() in protocol.ts — update in place
|
||||
|
||||
2. encodeTransmissionForAuth(thParams, transmission)
|
||||
→ tForAuth = smpEncode(sessionId) <> encodeTransmission_(...)
|
||||
→ tToSend = encodeTransmission_(...) [when implySessId=true, which is always true for v>=7]
|
||||
Note: implySessId means tToSend omits sessionId, but tForAuth includes it (for signing)
|
||||
|
||||
3. authTransmission(thAuth, serviceAuth=false, maybePrivKey, nonce, tForAuth)
|
||||
→ thAuth contains serverPubKey (X25519) from handshake
|
||||
→ maybePrivKey is Nothing for unauthenticated commands (LGET, SEND without key)
|
||||
→ Nothing privKey: no auth, encode empty ByteString
|
||||
→ Just X25519 privKey: TAAuthenticator(cbAuthenticate(serverPubKey, privKey, nonce, tForAuth))
|
||||
→ Just Ed25519 privKey: TASignature(sign(privKey, tForAuth))
|
||||
Note: nonce IS the CorrId (same 24 bytes used for both)
|
||||
Note: serviceAuth is always false for browser client (no service certificates)
|
||||
|
||||
4. tEncodeAuth(serviceAuth=false, maybeAuth)
|
||||
→ Nothing: smpEncode("") [1-byte 0x00]
|
||||
→ Just (TAAuthenticator s, _): smpEncode(s) [1-byte len + 80 bytes]
|
||||
→ Just (TASignature sig, _): smpEncode(signatureBytes sig) [1-byte len + 64 bytes]
|
||||
Note: TAuthorizations = (TransmissionAuth, Maybe serviceSig) — serviceSig always Nothing for us
|
||||
|
||||
5. tEncode(serviceAuth, (auth, tToSend))
|
||||
→ tEncodeAuth(auth) <> tToSend
|
||||
|
||||
6. tEncodeBatch1(serviceAuth, sentRawTransmission)
|
||||
→ lenEncode(1) + smpEncode(Large(tEncode(...)))
|
||||
Single-command batch. Always used when batch=true (v7+).
|
||||
|
||||
7. batchTransmissions_(blockSize, transmissions)
|
||||
→ Pack multiple Large-wrapped transmissions into ≤blockSize blocks
|
||||
→ Count byte prefix, up to 255 per block
|
||||
→ blockSize' = blockSize - 19 (2 pad + 1 count + 16 auth tag)
|
||||
```
|
||||
|
||||
## Parsing path (per received block)
|
||||
|
||||
```
|
||||
1. tParse(thParams, blockBytes)
|
||||
→ batch=true: parse count byte, then N Large-wrapped transmissions
|
||||
→ Each: transmissionP(thParams) parses:
|
||||
- authenticator (ByteString, 1-byte len + data) — ignored by client
|
||||
- rest = authorized bytes
|
||||
- re-parse authorized: corrId (ByteString) + entityId (ByteString) + command (rest)
|
||||
- if implySessId=true: sessionId not in wire format, prepended from thParams for verification
|
||||
→ Returns RawTransmission{authenticator, corrId, entityId, command}
|
||||
|
||||
2. tDecodeClient(thParams, rawTransmission)
|
||||
→ Verify sessId matches (skipped when implySessId=true)
|
||||
→ parseProtocol(v, command) → Either ErrorType BrokerMsg
|
||||
→ Return (corrId, entityId, Right msg | Left err)
|
||||
|
||||
3. clientResp classification (Client.hs:708-712):
|
||||
→ Left err (parse error) → PCEResponseError
|
||||
→ Right msg, protocolError msg = Just err → PCEProtocolError (ERR response)
|
||||
→ Right msg, protocolError msg = Nothing → Right msg (success)
|
||||
|
||||
4. Process: lookup corrId in pendingCommands
|
||||
→ Found: resolve Promise with clientResp
|
||||
→ Not found (empty corrId = server push): deliver to event callback
|
||||
```
|
||||
|
||||
## Functions to implement
|
||||
|
||||
### Crypto (`src/crypto.ts` — extend)
|
||||
|
||||
| Function | Haskell | Implementation |
|
||||
|---|---|---|
|
||||
| `sha512Hash(msg)` | `Crypto.hs:1016` | `sha512(msg)` from `@noble/hashes/sha512` |
|
||||
| `cbAuthenticator(serverPubKey, entityPrivKey, nonce, msg)` | `Crypto.hs:1367` | `cryptoBox(dh(serverPubKey, privKey), nonce, sha512Hash(msg))` → 80 bytes (16 tag + 64 hash) |
|
||||
`cryptoBox` and `dh` already available from xftp-web. `sha512` from `@noble/hashes`.
|
||||
|
||||
Not needed in spike: `cbDecryptNoPad` (only used by `cbVerify` and proxy commands).
|
||||
|
||||
Ed25519 signing: `crypto_sign_detached` from libsodium (already loaded and initialized via xftp-web for secretbox — no second implementation needed).
|
||||
|
||||
Not needed: `cbVerify` (server-side only).
|
||||
|
||||
### Transport update (`src/transport/websockets.ts` — update)
|
||||
|
||||
**Gap: `connectSMP` must return `serverPubKey`** (raw X25519 public key bytes from the handshake). Currently it computes the DH secret and derives block keys, but discards the server's raw public key. The client needs it for `cbAuthenticate` on every command.
|
||||
|
||||
Update `SMPConnection` to include:
|
||||
```typescript
|
||||
interface SMPConnection {
|
||||
ws: WebSocket
|
||||
sessionId: Uint8Array
|
||||
smpVersion: number
|
||||
sndKey: Uint8Array | null
|
||||
rcvKey: Uint8Array | null
|
||||
serverPubKey: Uint8Array | null // raw X25519 public key — needed for command auth
|
||||
}
|
||||
```
|
||||
|
||||
### Protocol encoding (`src/protocol.ts` — update existing)
|
||||
|
||||
Update `encodeTransmission`, `encodeBatch`, `decodeTransmission` in place — these were spike throwaway. Replace with auth-aware versions and update existing tests accordingly.
|
||||
|
||||
| Function | Haskell ref | Notes |
|
||||
|---|---|---|
|
||||
| `encodeTransmission_(v, corrId, entityId, command)` | `Protocol.hs:2194` | Update existing `encodeTransmission`. Also fix `encodeNEW`: QueueReqData should be `Just (QRMessaging Nothing)` not `Nothing`, and rename `sndAuthKey` param to `basicAuth` (it's server auth, not a crypto key) |
|
||||
| `encodeTransmissionForAuth(sessionId, corrId, entityId, command)` | `Protocol.hs:2186` | Returns `{tForAuth, tToSend}`. `implySessId` always true for v>=7 |
|
||||
| `authTransmission(serverPubKey, maybePrivKey, nonce, tForAuth)` | `Client.hs:1372` | `maybePrivKey` is `{type: "x25519"|"ed25519", key} | null`. Null for unauthenticated commands. X25519 → cbAuthenticator. Ed25519 → sign. |
|
||||
| `tEncodeAuth(auth)` | `Protocol.hs:507` | Handles null, authenticator (80 bytes), signature (64 bytes) |
|
||||
| `tEncode(auth, tToSend)` | `Protocol.hs:2171` | `tEncodeAuth(auth) + tToSend` |
|
||||
| `tEncodeBatch1(auth, tToSend)` | `Protocol.hs:2179` | `[count=1] + Large(tEncode(...))` |
|
||||
| `tEncodeForBatch(auth, tToSend)` | `Protocol.hs:2175` | `Large(tEncode(...))` |
|
||||
| `batchTransmissions(blockSize, transmissions)` | `Protocol.hs:2151` | Pack into ≤(blockSize-19)-byte blocks, count prefix |
|
||||
| `transmissionP(sessionId, block)` | `Protocol.hs:1629` | Skip auth bytes (1-byte len + data), parse corrId + entityId + command from rest. `implySessId`=true (sessionId not in wire, no need to verify on client side), `serviceAuth`=false (no serviceSig to skip) |
|
||||
| `tParse(sessionId, block)` | `Protocol.hs:2211` | Parse count, N×Large, each through `transmissionP` |
|
||||
| `tDecodeClient(sessionId, version, rawTransmission)` | `Protocol.hs:2256` | Parse command bytes → typed BrokerMsg |
|
||||
| `encodePING()` | | PING command for keepalive |
|
||||
|
||||
Update `decodeResponse`:
|
||||
- Add `SOK` (subscribe response with optional serviceId, returned by SUB in v19)
|
||||
- Add `INFO` (queue info response, for `getSMPQueueInfo`)
|
||||
- Improve `ERR` parsing: currently reads just the tag string. Need to parse structured `ErrorType` (at minimum AUTH, QUOTA, NO_MSG, INTERNAL) for proper error handling in the client
|
||||
|
||||
### Client (`src/client.ts` — new)
|
||||
|
||||
```typescript
|
||||
interface SMPClient {
|
||||
sessionId: Uint8Array
|
||||
smpVersion: number
|
||||
serverPubKey: Uint8Array // for cbAuthenticate
|
||||
|
||||
// Core: send pre-encoded command, correlate response
|
||||
// Lower-level than Haskell's sendProtocolCommand — takes pre-encoded command bytes
|
||||
// privKey: {type: "x25519", key} | {type: "ed25519", key} | null
|
||||
// Rejects with PCEProtocolError (ERR response), PCEResponseError (parse fail), PCEResponseTimeout
|
||||
sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<BrokerMsg>
|
||||
|
||||
// High-level commands (keys are DER-encoded unless noted)
|
||||
// authKeyPair: {publicKey, privateKey, type: "x25519"} — public goes in NEW encoding, private for auth
|
||||
createQueue(authKeyPair, dhKey, subMode): Promise<QueueIdsKeys>
|
||||
subscribeQueue(privKey, rcvId): Promise<void> // SUB can return MSG (queued message) → pushed to onMessage
|
||||
sendMessage(privKey, sndId, flags, msg): Promise<void> // privKey can be null (before queue secured)
|
||||
ackMessage(privKey, rcvId, msgId): Promise<void> // ACK can return MSG → pushed to onMessage
|
||||
secureQueue(privKey, rcvId, senderKey): Promise<void>
|
||||
secureSndQueue(privKey, sndId): Promise<void>
|
||||
getQueueLink(linkId): Promise<{senderId, linkData}>
|
||||
getQueueInfo(privKey, queueId): Promise<QueueInfo>
|
||||
deleteQueue(privKey, rcvId): Promise<void>
|
||||
suspendQueue(privKey, rcvId): Promise<void>
|
||||
|
||||
close(): void
|
||||
}
|
||||
|
||||
function createSMPClient(
|
||||
url: string,
|
||||
keyHash: Uint8Array,
|
||||
onMessage: (entityId: Uint8Array, msg: BrokerMsg) => void,
|
||||
onDisconnected: () => void,
|
||||
wsOptions?: object,
|
||||
): Promise<SMPClient>
|
||||
```
|
||||
|
||||
Internally:
|
||||
- `connectSMP` for WebSocket + handshake (existing, updated to return serverPubKey)
|
||||
- `Map<string, {resolve, reject}>` for hex(corrId) → Promise correlation
|
||||
- WebSocket `onmessage`: `receiveEncryptedBlock` → `tParse` → for each transmission: `tDecodeClient` → classify via `protocolError` → correlate by corrId or push to `onMessage`
|
||||
- `sendCommand`: generate random 24-byte corrId/nonce → `encodeTransmissionForAuth` → `authTransmission` → `tEncodeBatch1` → `sendEncryptedBlock` → return Promise resolved by correlator
|
||||
- `setInterval` ping: send PING, count timeouts, close after N consecutive
|
||||
- Timeout per command: `setTimeout` on pending Promise, reject with PCEResponseTimeout
|
||||
|
||||
**Message delivery model:**
|
||||
|
||||
All MSGs reach `onMessage` regardless of how they arrive. Three sources:
|
||||
|
||||
1. **Server push** (empty corrId): receive handler calls `onMessage` directly
|
||||
2. **SUB response**: `sendCommand` resolves with MSG → `subscribeQueue` pushes to `onMessage`, returns success to caller
|
||||
3. **ACK response**: same — `ackMessage` pushes to `onMessage`, returns success
|
||||
|
||||
High-level functions never expose MSG to their callers. This mirrors Haskell's `processSUBResponse_` (Client.hs:858-862) and `ackSMPMessage` (Client.hs:1042-1044) which both call `writeSMPMessage` to forward MSGs to msgQ and return OK-equivalent.
|
||||
|
||||
### Client REPL (`smp-web/tests/client-repl.ts` — new, separate from ratchet-repl.ts)
|
||||
|
||||
Separate REPL process holding a WebSocket connection + SMP client state. Same stdin/stdout line protocol approach, different state and commands.
|
||||
|
||||
**Message queue:** The REPL maintains an internal `Message[]` queue. The SMPClient's `onMessage` callback pushes to this queue. MSGs arrive here from three sources: server pushes (no corrId), SUB responses, and ACK responses — all handled identically by the client internals. The `RECV` command dequeues from this queue (or waits with timeout).
|
||||
|
||||
**Concurrency:** Unlike the ratchet REPL (pure, no network), the client REPL receives messages concurrently with stdin. This works because Node's event loop handles WebSocket `onmessage` events between readline callbacks — no explicit threading needed.
|
||||
|
||||
```
|
||||
CONNECT <url> <keyHashHex> [wsOptions]
|
||||
→ Creates SMPClient, returns "ok"
|
||||
|
||||
NEW <rcvAuthKeyHex> <rcvDhKeyHex> [subMode]
|
||||
→ createQueue (defaults: no basic auth, SMSubscribe, QRMessaging, no ntf creds)
|
||||
→ returns "ok: <rcvIdHex> <sndIdHex> <srvDhKeyHex>"
|
||||
|
||||
SUB <rcvIdHex> <rcvPrivKeyHex>
|
||||
→ subscribeQueue, returns "ok"
|
||||
|
||||
SEND <sndIdHex> <sndPrivKeyHex> <bodyHex>
|
||||
→ sendMessage, returns "ok"
|
||||
|
||||
ACK <rcvIdHex> <rcvPrivKeyHex> <msgIdHex>
|
||||
→ ackMessage, returns "ok"
|
||||
|
||||
KEY <rcvIdHex> <rcvPrivKeyHex> <senderKeyHex>
|
||||
→ secureQueue, returns "ok"
|
||||
|
||||
SKEY <sndIdHex> <sndPrivKeyHex>
|
||||
→ secureSndQueue, returns "ok"
|
||||
|
||||
LGET <linkIdHex>
|
||||
→ getQueueLink, returns "ok: <senderIdHex> <linkDataHex>"
|
||||
|
||||
RECV [timeoutMs]
|
||||
→ Dequeue next server-pushed MSG, returns "ok: <entityIdHex> <msgIdHex> <bodyHex>"
|
||||
→ Times out with "error: timeout" if no message arrives
|
||||
```
|
||||
|
||||
### Polymorphic testing
|
||||
|
||||
Same pattern as ratchet tests: `TestPeer` sum type with `TestPeerHS` / `TestPeerJS` dispatch. For SMP client tests:
|
||||
|
||||
```haskell
|
||||
data TestSMPClient
|
||||
= TestClientHS SMPClient
|
||||
| TestClientJS Handle Handle ProcessHandle -- stdin, stdout, process
|
||||
|
||||
-- Dispatch functions
|
||||
tcCreateQueue :: TestSMPClient -> ... -> IO QueueIdsKeys
|
||||
tcSubscribe :: TestSMPClient -> ... -> IO ()
|
||||
tcSendMessage :: TestSMPClient -> ... -> IO ()
|
||||
tcReceiveMessage :: TestSMPClient -> IO (EntityId, MsgId, ByteString)
|
||||
tcSecureQueue :: TestSMPClient -> ... -> IO ()
|
||||
tcAckMessage :: TestSMPClient -> ... -> IO ()
|
||||
```
|
||||
|
||||
Then the same test function runs against HS↔HS, HS↔JS, JS↔HS, JS↔JS peer combinations. The test creates two clients (one receiver, one sender) on the same SMP server, creates a queue, exchanges keys, sends messages — proving protocol compatibility.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit tests (callNode, no server)
|
||||
|
||||
1. `sha512Hash` — same input → same output as Haskell
|
||||
2. `cbAuthenticator` — same serverPubKey + entityPrivKey + nonce + message → same 80 bytes as Haskell
|
||||
3. `encodeTransmissionForAuth` — same sessionId + corrId + entityId + command (encoded at v19) → same `{tForAuth, tToSend}` as Haskell
|
||||
4. `authTransmission` with X25519 key — same keys + nonce + tForAuth → same authenticated bytes as Haskell
|
||||
5. `authTransmission` with Ed25519 key — same key + tForAuth → same signature bytes as Haskell
|
||||
6. `authTransmission` with no key (Nothing) — produces empty auth, matches Haskell
|
||||
7. `tEncodeBatch1` — same auth + transmission → same block bytes as Haskell
|
||||
8. `tParse` + `tDecodeClient` — TS parses Haskell-encoded response block, extracts corrId + entityId + typed response
|
||||
9. `batchTransmissions` — given N transmissions, produces same batch boundaries and block bytes as Haskell
|
||||
|
||||
### Integration tests (with SMP server, using REPL)
|
||||
|
||||
10. JS client connects, sends PING, receives PONG
|
||||
11. JS client creates queue (NEW → IDS)
|
||||
12. JS receiver creates queue + subscribes, JS sender sends message, receiver gets MSG
|
||||
13. Full handshake: create queue → secure (KEY) → subscribe → send → receive MSG → ack
|
||||
|
||||
### Polymorphic integration tests
|
||||
|
||||
14. Same test function, peer combinations:
|
||||
- HS sender, JS receiver
|
||||
- JS sender, HS receiver
|
||||
- JS sender, JS receiver
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Transport update — `connectSMP` returns `serverPubKey`
|
||||
2. Crypto additions — `sha512Hash`, `cbAuthenticator`, Ed25519 `sign`
|
||||
3. Protocol encoding updates — `encodeTransmissionForAuth`, `authTransmission`, `tEncode`, `tEncodeBatch1`, `batchTransmissions`, `encodePING`
|
||||
4. Protocol parsing updates — `transmissionP`, `tParse`, `tDecodeClient`, update `decodeResponse` (add `SOK`, `INFO`, structured `ERR`)
|
||||
5. Unit tests for steps 1-4
|
||||
6. Client core — `createSMPClient`, `sendCommand`, corrId correlation, receive dispatch, ping
|
||||
7. High-level command functions
|
||||
8. Client REPL
|
||||
9. Integration tests with server
|
||||
10. Polymorphic test wiring
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `smp-web/src/transport/websockets.ts` | Update `connectSMP` to return `serverPubKey` |
|
||||
| `smp-web/src/crypto.ts` | Add `sha512Hash`, `cbAuthenticator`, Ed25519 `sign` |
|
||||
| `smp-web/src/protocol.ts` | Update transmission encoding/parsing, add auth, batching |
|
||||
| `smp-web/src/client.ts` | New — SMP client |
|
||||
| `smp-web/tests/client-repl.ts` | New — SMP client REPL for integration tests |
|
||||
| `tests/SMPWebTests.hs` | Unit + integration tests |
|
||||
|
||||
## Scope
|
||||
|
||||
### Client spike (this plan)
|
||||
|
||||
Core client: connect, auth, send/receive, correlate, ping. High-level commands: NEW, SUB, KEY, SKEY, SEND, ACK, OFF, DEL, LGET, GET, QUE (getSMPQueueInfo). Single-command path. Tests against real server.
|
||||
|
||||
### Client MVP (next, after spike)
|
||||
|
||||
- Proxy commands (PRXY, PFWD, PRES) — essential for privacy, users must not connect directly to untrusted servers
|
||||
- Batch subscribe (subscribeSMPQueues) — needed for groups
|
||||
- `reverseNonce` — needed for proxy
|
||||
- Batch delete (deleteSMPQueues)
|
||||
|
||||
### Post-MVP
|
||||
|
||||
| What | Why |
|
||||
|---|---|
|
||||
| Notification commands (NKEY, NDEL, NSUB) | Value only with webpush support |
|
||||
| Service certificates (serviceAuth, serviceSig) | Browser doesn't use |
|
||||
| Stream commands (streamSubscribeSMPQueues) | Not used in Haskell client either |
|
||||
| NetworkConfig, SOCKS, host mode, transport selection | Browser connects via WebSocket directly |
|
||||
| Queue link management (LSET, LDEL, LKEY) | Only needed to create links, not join them |
|
||||
| `cbVerify` | Server-side only |
|
||||
|
||||
## Haskell references
|
||||
|
||||
- `Client.hs:179-200` — ProtocolClient, PClient types
|
||||
- `Client.hs:248` — `type SMPClient = ProtocolClient SMPVersion ErrorType BrokerMsg`
|
||||
- `Client.hs:506-512` — Request type
|
||||
- `Client.hs:628-642` — client connection, handshake, raceAny_ [send, process, receive, monitor]
|
||||
- `Client.hs:644-658` — send loop, receive loop
|
||||
- `Client.hs:660-678` — monitor/ping loop
|
||||
- `Client.hs:680-719` — process loop, processMsg (corrId correlation, clientResp classification)
|
||||
- `Client.hs:810-828` — createSMPQueue
|
||||
- `Client.hs:833-836` — subscribeSMPQueue
|
||||
- `Client.hs:938-939` — secureSMPQueue
|
||||
- `Client.hs:1027-1031` — sendSMPMessage
|
||||
- `Client.hs:1040-1045` — ackSMPMessage (note: ACK can return MSG)
|
||||
- `Client.hs:1239-1243` — okSMPCommand pattern
|
||||
- `Client.hs:1300-1326` — sendProtocolCommand_, sendRecv, size check, tEncodeBatch1
|
||||
- `Client.hs:1333-1344` — getResponse, timeout handling
|
||||
- `Client.hs:1349-1370` — mkTransmission_, CorrId=nonce, encodeTransmissionForAuth, authTransmission
|
||||
- `Client.hs:1372-1391` — authTransmission, authenticate (X25519 vs Ed25519), service sig
|
||||
- `Protocol.hs:488-525` — RawTransmission, TransmissionAuth, TAuthorizations, tEncodeAuth
|
||||
- `Protocol.hs:1629-1643` — transmissionP
|
||||
- `Protocol.hs:2129-2198` — batching, tEncode, tEncodeBatch1, batchTransmissions_
|
||||
- `Protocol.hs:2207-2267` — tGetClient, tParse, tDecodeClient
|
||||
- `Crypto.hs:1016` — sha512Hash
|
||||
- `Crypto.hs:1296-1298` — cbEncryptNoPad (= cryptoBox without padding)
|
||||
- `Crypto.hs:1330-1331` — cbDecryptNoPad
|
||||
- `Crypto.hs:1366-1371` — cbAuthenticate, cbVerify
|
||||
@@ -0,0 +1,238 @@
|
||||
# SMP Client MVP: Proxy + Batching — Transpilation Plan
|
||||
|
||||
**Parent**: [SMP Client Spike](./2026-05-17-smp-client.md)
|
||||
|
||||
## Rule
|
||||
|
||||
Every TypeScript function is a faithful transpilation of a specific Haskell function at specific lines. Same name, same steps, same call chain. No inferences, no approximations. Each entry below gives the exact source to transpile from.
|
||||
|
||||
## Crypto functions
|
||||
|
||||
### `reverseNonce` → transpile `Crypto.hs:1409-1410`
|
||||
```haskell
|
||||
reverseNonce (CryptoBoxNonce s) = CryptoBoxNonce (B.reverse s)
|
||||
```
|
||||
TS: `function reverseNonce(nonce: Uint8Array): Uint8Array` — reverse the 24 bytes.
|
||||
|
||||
### `cbDecryptNoPad` → transpile `Crypto.hs:1330-1331`
|
||||
```haskell
|
||||
cbDecryptNoPad (DhSecretX25519 secret) = sbDecryptNoPad_ secret
|
||||
```
|
||||
Which is `sbDecryptNoPad_` from secretbox. xftp-web's `cbDecrypt` does decrypt+unpad. Need decrypt without unpad — extract tag(16) + cipher, decrypt, verify tag, return raw (no unpad). Use xftp-web's `sbInit`/`sbDecryptChunk`/`sbAuth` directly.
|
||||
|
||||
## Protocol encoding functions
|
||||
|
||||
### `encodeProtocolServer` → transpile `Protocol.hs:1264-1266`
|
||||
```haskell
|
||||
smpEncode ProtocolServer {host, port, keyHash} = smpEncode (host, port, keyHash)
|
||||
```
|
||||
Where:
|
||||
- `host :: NonEmpty TransportHost` → `smpEncodeList` (1-byte count + items)
|
||||
- Each `TransportHost` → `smpEncode (strEncode host)` → `encodeBytes(ascii(hostname))` (`Transport/Client.hs:77-78`)
|
||||
- `port :: ServiceName` = ByteString → `encodeBytes(port)`
|
||||
- `keyHash :: KeyHash` = ByteString → `encodeBytes(keyHash)`
|
||||
|
||||
File: `src/protocol.ts`
|
||||
|
||||
### `encodePRXY` → transpile `Protocol.hs:1710`
|
||||
```haskell
|
||||
PRXY host auth_ -> e (PRXY_, ' ', host, auth_)
|
||||
```
|
||||
= `"PRXY " + smpEncode(server) + smpEncode(Maybe BasicAuth)`
|
||||
|
||||
Where `Maybe BasicAuth` = `encodeMaybe(encodeBytes, auth)`.
|
||||
|
||||
### `encodePFWD` → transpile `Protocol.hs:1711`
|
||||
```haskell
|
||||
PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s)
|
||||
```
|
||||
= `"PFWD " + encodeWord16(version) + encodeBytes(pubKeyDer) + encTransmission` (Tail = no length prefix)
|
||||
|
||||
### `decodePKEY` → transpile `Protocol.hs:1894`
|
||||
```haskell
|
||||
PKEY_ -> PKEY <$> _smpP <*> smpP <*> smpP
|
||||
```
|
||||
= space + `decodeBytes(d)` (sessionId) + `decodeVersionRange(d)` + `decodeCertChainPubKey(d)`
|
||||
|
||||
`VersionRange` encoding (`Version.hs`): `smpEncode (minVersion, maxVersion)` = two Word16.
|
||||
|
||||
`CertChainPubKey` encoding (`Transport.hs:663-667`): `smpEncode (encodeCertChain chain, SignedObject signedPubKey)` — `encodeCertChain` is `Large`-encoded DER bytes, `SignedObject` is `Large`-encoded DER bytes.
|
||||
|
||||
### `decodePRES` → transpile `Protocol.hs:1896`
|
||||
```haskell
|
||||
PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP)
|
||||
```
|
||||
= space + rest of bytes (Tail) → `EncResponse`
|
||||
|
||||
### Add to `decodeResponse`: `PKEY` and `PRES` cases.
|
||||
|
||||
## Client functions
|
||||
|
||||
### `sendProtocolCommands` → transpile `Client.hs:1262-1278`
|
||||
|
||||
Call chain:
|
||||
1. `mapM (mkTransmission c) cs` — for each command: generate corrId, encode, auth, register pending request
|
||||
2. `batchTransmissions' thParams` — pack into blocks
|
||||
3. `mapM (sendBatch c nm) bs` — send each block, collect responses
|
||||
4. `validate` — verify response count matches command count
|
||||
|
||||
In TS: `mkTransmission` = the existing `sendCommand` logic (corrId generation, `encodeTransmissionForAuth`, `authTransmission`) but separated into encode+register vs send+await. Need to refactor `sendCommand` to split these.
|
||||
|
||||
### `batchTransmissions'` → transpile `Protocol.hs:2135-2148`
|
||||
|
||||
Already have `batchTransmissions` in protocol.ts that does `batchTransmissions_`. Need `batchTransmissions'` which wraps with `tEncodeForBatch` before batching. Currently the TS `batchTransmissions` takes pre-encoded Large-wrapped bytes. Need to match the Haskell call chain exactly:
|
||||
|
||||
```haskell
|
||||
batchTransmissions' params ts
|
||||
| batch = batchTransmissions_ bSize $ L.map (first $ fmap $ tEncodeForBatch serviceAuth) ts
|
||||
```
|
||||
|
||||
### `sendBatch` → transpile `Client.hs:1285-1298`
|
||||
|
||||
Three cases:
|
||||
- `TBError`: return error response
|
||||
- `TBTransmissions s n rs`: send block `s`, await all `n` responses concurrently
|
||||
- `TBTransmission s r`: send block `s`, await one response
|
||||
|
||||
In browser: "concurrently" = all promises pending simultaneously, resolved by `onBlock` handler as responses arrive.
|
||||
|
||||
### `subscribeSMPQueues` → transpile `Client.hs:840-845`
|
||||
```haskell
|
||||
subscribeSMPQueues c qs = do
|
||||
liftIO $ enablePings c
|
||||
sendProtocolCommands c NRMBackground cs >>= mapM (processSUBResponse c)
|
||||
where
|
||||
cs = L.map (\(rId, rpKey) -> (rId, Just rpKey, Cmd SRecipient SUB)) qs
|
||||
```
|
||||
|
||||
### `processSUBResponse` → transpile `Client.hs:854-862`
|
||||
```haskell
|
||||
processSUBResponse c (Response rId r) = pure r $>>= processSUBResponse_ c rId
|
||||
processSUBResponse_ c rId = \case
|
||||
OK -> pure $ Right Nothing
|
||||
SOK serviceId_ -> pure $ Right serviceId_
|
||||
cmd@MSG {} -> writeSMPMessage c rId cmd $> Right Nothing
|
||||
r' -> pure . Left $ unexpectedResponse r'
|
||||
```
|
||||
MSG → push to `onMessage`, return success. Same pattern as single subscribe.
|
||||
|
||||
### `deleteSMPQueues` → transpile `Client.hs:1062-1065`
|
||||
```haskell
|
||||
deleteSMPQueues = okSMPCommands DEL
|
||||
```
|
||||
Uses `okSMPCommands` (`Client.hs:1245-1253`) which calls `sendProtocolCommands` and checks each response is OK.
|
||||
|
||||
### `connectSMPProxiedRelay` → transpile `Client.hs:1069-1093`
|
||||
|
||||
Call chain:
|
||||
1. Send `PRXY relayServ proxyAuth` to proxy (via `sendProtocolCommand_`, entityId = NoEntity)
|
||||
2. Receive `PKEY sessionId versionRange certChainPubKey`
|
||||
3. Check version compatibility
|
||||
4. `validateRelay chain key` — validate cert chain against relay's keyHash, extract X25519 key
|
||||
5. Return `ProxiedRelay {sessionId, version, auth, relayKey}`
|
||||
|
||||
`validateRelay` (`Client.hs:1085-1093`):
|
||||
1. `chainIdCaCerts chain` → extract leaf, id, ca certs
|
||||
2. Check `Fingerprint kh == getFingerprint idCert SHA256`
|
||||
3. `x509validate caCert (hostName, port) chain`
|
||||
4. Extract server key from leaf cert
|
||||
5. Verify signed key against server key
|
||||
|
||||
In browser: we already have `verifyIdentityProof` and `extractSignedKey` from xftp-web. Need to adapt for relay validation where we receive the cert chain in the PKEY response (DER-encoded, not from TLS handshake).
|
||||
|
||||
### `proxySMPCommand` → transpile `Client.hs:1157-1206`
|
||||
|
||||
Call chain:
|
||||
1. Construct `serverThParams` = `smpTHParamsSetVersion v proxyThParams {sessionId, thAuth = serverThAuth}`
|
||||
- `serverThAuth = thAuth proxyThParams with peerServerPubKey = relayKey`
|
||||
2. Generate ephemeral X25519 keypair: `(cmdPubKey, cmdPrivKey)`
|
||||
3. `cmdSecret = dh(relayKey, cmdPrivKey)`
|
||||
4. Generate random nonce (also used as corrId)
|
||||
5. `encodeTransmissionForAuth serverThParams (CorrId corrId, sId, Cmd sParty command)` — encode as if sending to relay
|
||||
6. `authTransmission serverThAuth False spKey nonce tForAuth` — authenticate with entity key against relay
|
||||
7. `batchTransmissions serverThParams [Right (auth, tToSend)]` — batch into single block
|
||||
8. `cbEncrypt cmdSecret nonce batchBlock paddedProxiedTLength` → `EncTransmission`
|
||||
9. Send `PFWD version cmdPubKey encTransmission` to proxy (entityId = sessionId)
|
||||
10. Receive `PRES (EncResponse encResponse)`
|
||||
11. `cbDecrypt cmdSecret (reverseNonce nonce) encResponse` — decrypt relay's response
|
||||
12. `tParse serverThParams decrypted` — parse as relay's response
|
||||
13. `tDecodeClient serverThParams parsed` — decode command
|
||||
14. Classify: `Right (ERR e)` → throw PCEProtocolError, `Right r` → return Right r, `Left e` → throw PCEResponseError
|
||||
|
||||
Error wrapping (`Client.hs:1200-1206`): proxy-level errors (from PFWD response itself) → `ProxyClientError` returned as `Left`. Relay-level errors (inside PRES) → `PCEProtocolError` thrown.
|
||||
|
||||
### `paddedProxiedTLength` → `Protocol.hs:306-307` = 16226
|
||||
|
||||
## Constants
|
||||
|
||||
```
|
||||
paddedProxiedTLength = 16226 -- Protocol.hs:306
|
||||
serviceCertsSMPVersion = 16 -- Transport.hs:213
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (callNode, no server)
|
||||
Each encoding function tested byte-for-byte against Haskell:
|
||||
1. `reverseNonce` — reverse known bytes, compare
|
||||
2. `encodeProtocolServer` — encode known server, compare with `smpEncode @SMPServer`
|
||||
3. `encodePRXY` — encode PRXY command, compare with `encodeProtocol v (Cmd SProxiedClient (PRXY srv auth))`
|
||||
4. `encodePFWD` — encode PFWD command, compare with `encodeProtocol v (Cmd SProxiedClient (PFWD v pk et))`
|
||||
5. `batchTransmissions` with multiple commands — same batch boundaries as `batchTransmissions_` in Haskell
|
||||
|
||||
### Integration tests (with two SMP servers, from SMPProxyTests.hs pattern)
|
||||
6. `connectProxiedRelay` — JS connects to proxy, sends PRXY for relay, gets PKEY, validates cert, extracts key
|
||||
7. `proxySMPMessage` — JS sends SEND via proxy to relay, HS receiver gets MSG
|
||||
8. Full proxy roundtrip — JS creates queue on relay via proxy, sends message via proxy, HS receives
|
||||
|
||||
### Batch tests
|
||||
9. `subscribeSMPQueues` — JS batch-subscribes to N queues, verifies all subscribed
|
||||
10. `deleteSMPQueues` — JS batch-deletes N queues
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `reverseNonce`, `cbDecryptNoPad`
|
||||
2. `encodeProtocolServer`, `encodePRXY`, `encodePFWD`
|
||||
3. `decodePKEY`, `decodePRES`, update `decodeResponse`
|
||||
4. Unit tests for steps 1-3
|
||||
5. Refactor `sendCommand` → split into `mkTransmission` (encode+register) and send
|
||||
6. `sendProtocolCommands`, `sendBatch`
|
||||
7. `subscribeSMPQueues`, `deleteSMPQueues`
|
||||
8. Batch integration tests
|
||||
9. `connectSMPProxiedRelay` (cert validation, PRXY/PKEY)
|
||||
10. `proxySMPCommand`, `proxySMPMessage`
|
||||
11. Proxy integration tests
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `smp-web/src/crypto.ts` | `reverseNonce`, `cbDecryptNoPad` |
|
||||
| `smp-web/src/protocol.ts` | `encodeProtocolServer`, `encodePRXY`, `encodePFWD`, `decodePKEY`, `decodePRES` |
|
||||
| `smp-web/src/client.ts` | `sendProtocolCommands`, `sendBatch`, `subscribeSMPQueues`, `deleteSMPQueues`, `connectSMPProxiedRelay`, `proxySMPCommand` |
|
||||
| `smp-web/tests/client-repl.ts` | Proxy + batch REPL commands |
|
||||
| `tests/SMPWebTests.hs` | Tests |
|
||||
|
||||
## Haskell source — exact lines to transpile
|
||||
|
||||
| TS function | Haskell function | File:lines |
|
||||
|---|---|---|
|
||||
| `reverseNonce` | `reverseNonce` | `Crypto.hs:1409-1410` |
|
||||
| `cbDecryptNoPad` | `cbDecryptNoPad` / `sbDecryptNoPad_` | `Crypto.hs:1330-1331` |
|
||||
| `encodeProtocolServer` | `instance Encoding (ProtocolServer p)` | `Protocol.hs:1264-1266` |
|
||||
| `encodePRXY` | `encodeProtocol v (PRXY ...)` | `Protocol.hs:1710` |
|
||||
| `encodePFWD` | `encodeProtocol v (PFWD ...)` | `Protocol.hs:1711` |
|
||||
| `decodePKEY` | `protocolP v PKEY_` | `Protocol.hs:1894` |
|
||||
| `decodePRES` | `protocolP v PRES_` | `Protocol.hs:1896` |
|
||||
| `sendProtocolCommands` | `sendProtocolCommands` | `Client.hs:1262-1278` |
|
||||
| `sendBatch` | `sendBatch` | `Client.hs:1285-1298` |
|
||||
| `subscribeSMPQueues` | `subscribeSMPQueues` | `Client.hs:840-845` |
|
||||
| `processSUBResponse` | `processSUBResponse` + `processSUBResponse_` | `Client.hs:854-862` |
|
||||
| `deleteSMPQueues` | `deleteSMPQueues` via `okSMPCommands` | `Client.hs:1062-1065, 1245-1253` |
|
||||
| `connectSMPProxiedRelay` | `connectSMPProxiedRelay` | `Client.hs:1069-1093` |
|
||||
| `validateRelay` | `validateRelay` (inside `connectSMPProxiedRelay`) | `Client.hs:1085-1093` |
|
||||
| `proxySMPCommand` | `proxySMPCommand` | `Client.hs:1157-1206` |
|
||||
| `proxyOKSMPCommand` | `proxyOKSMPCommand` | `Client.hs:1150-1155` |
|
||||
| `smpTHParamsSetVersion` | `smpTHParamsSetVersion` | `Transport.hs:921-926` |
|
||||
| `batchTransmissions'` | `batchTransmissions'` | `Protocol.hs:2135-2148` |
|
||||
| `batchTransmissions_` | `batchTransmissions_` | `Protocol.hs:2150-2169` |
|
||||
@@ -0,0 +1,195 @@
|
||||
# Agent for Browser: Transpilation Breakdown
|
||||
|
||||
**Parent**: [SMP Client MVP](./2026-05-20-client-mvp.md)
|
||||
**Depends on**: SMP Client (complete, 96 tests), encoding/encryption spike (complete)
|
||||
|
||||
## Rule
|
||||
|
||||
Every TypeScript function is a faithful transpilation of a specific Haskell function. Same name, same steps, same call chain. No inferences.
|
||||
|
||||
## Scope
|
||||
|
||||
The web widget JOINS connections (never creates addresses). It sends and receives messages. It handles the connection handshake. It does NOT create invitations, manage notifications, transfer files, or do remote control.
|
||||
|
||||
## Architecture difference from Haskell
|
||||
|
||||
Haskell agent uses SQLite + multiple background threads (subscriber, delivery workers, cleanup manager, NTF supervisor). Browser agent uses IndexedDB + event-driven architecture (WebSocket onmessage, Promises, no threads).
|
||||
|
||||
The protocol logic is identical. The concurrency model differs. The store interface differs. The transpilation focuses on the protocol logic.
|
||||
|
||||
## Breakdown into testable pieces
|
||||
|
||||
### Piece 1: Agent protocol types (Agent/Protocol.hs)
|
||||
|
||||
Already partially done (AgentMsgEnvelope, AgentMessage, APrivHeader, AMessage). What's missing for the handshake:
|
||||
|
||||
| Type | Haskell location | What's needed |
|
||||
|------|-----------------|---------------|
|
||||
| `SMPQueueInfo` | `Agent/Protocol.hs:1310-1327` | Binary encode/decode — version-dependent, complex |
|
||||
| `SMPQueueUri` | `Agent/Protocol.hs:1344-1431` | Binary encode/decode + string encode/decode |
|
||||
| `SMPQueueAddress` | `Agent/Protocol.hs:1350-1356` | `{smpServer, senderId, dhPublicKey, queueMode}` |
|
||||
| `ConnectionRequestUri` | `Agent/Protocol.hs:1436-1441` | Binary encode/decode: `CRInvitationUri` + `CRContactUri` |
|
||||
| `ConnReqUriData` | `Agent/Protocol.hs:1728-1734` | Binary encode/decode: `{crAgentVRange, crSmpQueues, crClientData}` |
|
||||
| `SMPConfirmation` | `Agent/Protocol.hs:798-810` | Not wire-encoded — internal data structure for confirmation handling |
|
||||
| `E2ERatchetParams` | `Crypto/Ratchet.hs:223-241` | Already have encode/decode in ratchet.ts — need to verify completeness |
|
||||
|
||||
**Test**: each encode/decode function tested byte-for-byte against Haskell via callNode.
|
||||
|
||||
### Piece 2: Connection handshake — joinConnection (Agent.hs)
|
||||
|
||||
The join flow, transpiled step by step:
|
||||
|
||||
```
|
||||
joinConnection (Agent.hs:~1200-1300)
|
||||
1. Parse ConnectionRequestUri (already have URI parsing)
|
||||
2. Create RcvQueue on a selected server (newRcvQueue — Agent/Client.hs:1373)
|
||||
- Generate X25519 DH keypair for queue
|
||||
- Generate X25519/Ed25519 auth keypair
|
||||
- Call createSMPQueue on SMP client
|
||||
- Get back rcvId, sndId, srvDhKey
|
||||
3. Store connection + queue in database
|
||||
4. Generate X448 E2E ratchet params (generateRcvE2EParams — already have)
|
||||
5. Build ConnInfo (profile data)
|
||||
6. Encrypt ConnInfo with ratchet → encConnInfo
|
||||
7. Build AgentConfirmation envelope
|
||||
8. Wrap in ClientMessage + per-queue E2E encrypt → ClientMsgEnvelope
|
||||
9. Send via SMP SEND to the contact address queue
|
||||
10. Subscribe to own receive queue (SUB)
|
||||
11. Return connection ID
|
||||
```
|
||||
|
||||
Each step is independently testable. The full flow is an integration test.
|
||||
|
||||
**Key Haskell functions to transpile:**
|
||||
|
||||
| Function | File:lines | What it does |
|
||||
|----------|-----------|--------------|
|
||||
| `joinConnection` | `Agent.hs:~1200` | Top-level join |
|
||||
| `joinConn` | `Agent.hs:~1230` | Internal join logic |
|
||||
| `newRcvQueue` | `Agent/Client.hs:1373-1420` | Create queue on server |
|
||||
| `sendConfirmation` | `Agent/Client.hs:1788-1794` | Encrypt+send confirmation |
|
||||
| `sendInvitation` | `Agent/Client.hs:1796-1806` | Encrypt+send invitation |
|
||||
| `mkAgentConfirmation` | `Agent.hs:~3700` | Build confirmation envelope |
|
||||
| `agentCbEncrypt` | `Agent/Client.hs:2074-2082` | Per-queue E2E encrypt (already have) |
|
||||
|
||||
### Piece 3: Message processing — subscriber (Agent.hs)
|
||||
|
||||
Incoming message handling:
|
||||
|
||||
```
|
||||
subscriber (Agent.hs:2912-2919)
|
||||
→ reads from msgQ (populated by SMP client's onMessage callback)
|
||||
→ processSMPTransmissions (Agent.hs:2997-3297)
|
||||
→ for each transmission:
|
||||
→ STEvent (server push MSG):
|
||||
→ decryptClientMessage (per-queue E2E decrypt)
|
||||
→ parse AgentMsgEnvelope
|
||||
→ for AgentMsgEnvelope 'M':
|
||||
→ agentRatchetDecrypt (double ratchet decrypt)
|
||||
→ parse AgentMessage
|
||||
→ dispatch on AMessage type:
|
||||
→ HELLO: complete handshake
|
||||
→ A_MSG body: deliver to user
|
||||
→ A_RCVD: delivery receipt
|
||||
→ QADD/QKEY/QUSE/QTEST: queue switching (skip for MVP)
|
||||
→ EREADY: ratchet sync (skip for MVP)
|
||||
```
|
||||
|
||||
**Key Haskell functions to transpile:**
|
||||
|
||||
| Function | File:lines | What it does |
|
||||
|----------|-----------|--------------|
|
||||
| `processSMPTransmissions` | `Agent.hs:2997-3297` | Top-level message dispatcher |
|
||||
| `decryptClientMessage` | `Agent.hs:3282-3293` | Per-queue E2E decrypt + parse envelope |
|
||||
| `agentRatchetDecrypt` | `Agent.hs:3757-3767` | Ratchet decrypt + store update |
|
||||
| `helloMsg` | `Agent.hs:~3400` | Process HELLO (complete handshake) |
|
||||
| `smpConfirmation` | `Agent.hs:~3500` | Process received confirmation |
|
||||
| `smpInvitation` | `Agent.hs:~3600` | Process received invitation |
|
||||
|
||||
### Piece 4: Message sending — sendMessage (Agent.hs)
|
||||
|
||||
```
|
||||
sendMessage (Agent.hs:~1500)
|
||||
→ enqueueMessageB
|
||||
→ agentRatchetEncryptHeader (get ratchet encrypt key)
|
||||
→ rcEncryptMsg (encrypt message body)
|
||||
→ store encrypted message in DB
|
||||
→ createSndMsgDelivery (link to queue)
|
||||
→ delivery worker sends via SMP SEND
|
||||
```
|
||||
|
||||
**Key Haskell functions to transpile:**
|
||||
|
||||
| Function | File:lines | What it does |
|
||||
|----------|-----------|--------------|
|
||||
| `sendMessage` | `Agent.hs:~1500` | Top-level send |
|
||||
| `enqueueMessageB` | `Agent.hs:~2020-2060` | Encode + encrypt + store |
|
||||
| `agentRatchetEncrypt` | `Agent.hs:3742-3746` | Ratchet encrypt |
|
||||
| `agentRatchetEncryptHeader` | `Agent.hs:3748-3754` | Ratchet encrypt header |
|
||||
| `encodeAgentMsgStr` | `Agent.hs:2050-2054` | Encode AgentMessage to bytes |
|
||||
| `runSmpQueueMsgDelivery` | `Agent.hs:2092-2220` | Delivery worker — read from DB, send via SMP |
|
||||
|
||||
### Piece 5: Message acknowledgment (Agent.hs)
|
||||
|
||||
```
|
||||
ackMessage (Agent.hs:~1550)
|
||||
→ withStore: mark message as acknowledged
|
||||
→ send ACK to SMP server
|
||||
→ optionally send delivery receipt (A_RCVD)
|
||||
```
|
||||
|
||||
### Piece 6: Store interface (IndexedDB)
|
||||
|
||||
The agent uses ~50 distinct store operations. For the web widget MVP, we need:
|
||||
|
||||
| Store operation | What it does | Used by |
|
||||
|----------------|--------------|---------|
|
||||
| `createConnection` | Create connection record | joinConnection |
|
||||
| `getConn` | Get connection by ID | all operations |
|
||||
| `updateRcvIds` | Increment receive IDs | message processing |
|
||||
| `createRcvMsg` | Store received message | message processing |
|
||||
| `getRatchetForUpdate` | Get ratchet state for modify | encrypt/decrypt |
|
||||
| `updateRatchet` | Store updated ratchet | encrypt/decrypt |
|
||||
| `getSkippedMsgKeys` | Get skipped message keys | ratchet decrypt |
|
||||
| `createSndMsg` | Store sent message | sendMessage |
|
||||
| `createSndMsgDelivery` | Link message to queue | sendMessage |
|
||||
| `getPendingQueueMsg` | Get next message to send | delivery worker |
|
||||
| `updateSndMsgStatus` | Update delivery status | delivery worker |
|
||||
| `deleteMsg` | Delete after ACK | ackMessage |
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Piece 1**: Agent protocol types — `SMPQueueInfo`, `SMPQueueUri`, `ConnectionRequestUri`, `ConnReqUriData` encode/decode. Test each against Haskell.
|
||||
2. **Piece 6**: Store interface — define TypeScript interface matching the needed operations. Implement with in-memory Map first (for testing), IndexedDB later.
|
||||
3. **Piece 4**: Message sending — `sendMessage` + `agentRatchetEncrypt` + delivery. Test: encrypt in TS, decrypt in HS.
|
||||
4. **Piece 3**: Message processing — `processSMPTransmissions` + `agentRatchetDecrypt`. Test: encrypt in HS, decrypt in TS.
|
||||
5. **Piece 2**: Connection handshake — `joinConnection`. Test: TS joins, HS accepts, messages flow.
|
||||
6. **Piece 5**: Message acknowledgment — `ackMessage`. Test: full roundtrip with ack.
|
||||
|
||||
Each piece is independently testable. Piece 1 uses callNode (no server). Pieces 3-5 use the SMP client REPL + real server. Piece 2 is the integration test that ties everything together.
|
||||
|
||||
## What to skip
|
||||
|
||||
| Feature | Why skip | Haskell functions |
|
||||
|---------|----------|-------------------|
|
||||
| Queue switching | Additive, not needed for MVP | `switchConnection`, QADD/QKEY/QUSE/QTEST handling |
|
||||
| Ratchet sync | MVP shows error, suggests reconnecting | `synchronizeRatchet`, EREADY handling |
|
||||
| Notifications | No webpush yet | All NTF functions |
|
||||
| File transfer | Separate protocol | All XFTP functions |
|
||||
| Remote control | Not in scope | All RC functions |
|
||||
| Client notices | Server admin feature | `processClientNotices` |
|
||||
| Cleanup manager | Can do manual cleanup | `cleanupManager` |
|
||||
| Server management | Configured at init | `setProtocolServers`, `testProtocolServer` |
|
||||
| Connection creation | Widget only joins | `createConnection`, short links |
|
||||
| Delivery receipts | Can add later | A_RCVD handling, receipt sending |
|
||||
| Multiple receive queues | Single queue per connection for MVP | Queue replacement logic |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `smp-web/src/agent/protocol.ts` | Extend with SMPQueueInfo, ConnectionRequestUri, etc. |
|
||||
| `smp-web/src/agent/store.ts` | New — store interface + in-memory implementation |
|
||||
| `smp-web/src/agent/agent.ts` | New — agent logic: join, send, receive, ack |
|
||||
| `smp-web/tests/agent-repl.ts` | New — agent REPL for testing (or extend client-repl) |
|
||||
| `tests/SMPWebTests.hs` | Agent tests |
|
||||
@@ -0,0 +1,260 @@
|
||||
# Agent API Inventory for Web Widget
|
||||
|
||||
Every exported function from `Agent.hs`, classified as MVP / post-MVP / skip, with reasoning.
|
||||
|
||||
## Context
|
||||
|
||||
The web widget:
|
||||
- Joins existing connections via addresses hardcoded in the widget or sent as simplex links
|
||||
- Sends and receives messages
|
||||
- Does NOT create addresses, invitation links, or short links
|
||||
- Does NOT transfer files (post-MVP)
|
||||
- Does NOT manage notifications (post-MVP)
|
||||
- Does NOT rotate queues
|
||||
- Must accept ratchet re-sync initiated by the other side (but does not initiate)
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `getSMPAgentClient` | YES | Initialize agent — required |
|
||||
| `getSMPAgentClient_` | NO | Variant with extra params, not needed |
|
||||
| `disconnectAgentClient` | YES | Clean shutdown — required |
|
||||
| `disposeAgentClient` | NO | Hard shutdown — disconnectAgentClient is sufficient |
|
||||
| `resumeAgentClient` | NO | Widget doesn't suspend/resume — runs while page is open |
|
||||
| `foregroundAgent` | NO | Mobile-only concept |
|
||||
| `suspendAgent` | NO | Mobile-only concept |
|
||||
|
||||
## User management
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `createUser` | YES | Widget needs at least one user to own connections |
|
||||
| `deleteUser` | NO | Widget doesn't delete users — page reload is cleanup |
|
||||
|
||||
## Connection creation (widget never creates)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `createConnection` | NO | Widget joins, never creates |
|
||||
| `createConnectionAsync` | NO | Same |
|
||||
| `prepareConnectionLink` | NO | For creating links |
|
||||
| `createConnectionForLink` | NO | For creating links |
|
||||
| `setConnShortLink` | NO | For creating short links |
|
||||
| `setConnShortLinkAsync` | NO | Same |
|
||||
| `deleteConnShortLink` | NO | For managing short links |
|
||||
| `getConnShortLink` | NO | For reading short links — widget uses hardcoded address |
|
||||
| `getConnShortLinkAsync` | NO | Same |
|
||||
| `getConnLinkPrivKey` | NO | For link management |
|
||||
| `deleteLocalInvShortLink` | NO | For link cleanup |
|
||||
| `changeConnectionUser` | NO | Widget has one user |
|
||||
|
||||
## Joining connections
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `prepareConnectionToJoin` | YES | Create connection record before join — prevents race with incoming confirmation |
|
||||
| `joinConnection` | YES | Core function — join via address URI |
|
||||
| `joinConnectionAsync` | NO | Async variant — widget can use sync joinConnection with await |
|
||||
| `connRequestPQSupport` | YES | Determine PQ support from connection request — needed for join |
|
||||
|
||||
## Handshake (accepting incoming)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `allowConnection` | YES | Allow connection after receiving CONF — part of handshake |
|
||||
| `allowConnectionAsync` | NO | Async variant |
|
||||
| `acceptContact` | YES | Accept contact request (for group join flow) |
|
||||
| `acceptContactAsync` | NO | Async variant |
|
||||
| `prepareConnectionToAccept` | YES | Prepare for accept — same race prevention as prepareConnectionToJoin |
|
||||
| `rejectContact` | NO | Widget doesn't reject — it always accepts |
|
||||
|
||||
## Subscription
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `subscribeConnection` | YES | Subscribe to receive messages on one connection |
|
||||
| `subscribeConnections` | YES | Batch subscribe — needed after page reload |
|
||||
| `subscribeAllConnections` | YES | Subscribe everything for a user — simplest for widget |
|
||||
| `resubscribeConnection` | YES | Resubscribe after network recovery |
|
||||
| `resubscribeConnections` | YES | Batch resubscribe |
|
||||
| `getConnectionMessages` | NO | Fetch stored messages — widget processes messages as they arrive |
|
||||
| `getNotificationConns` | NO | Push notification management |
|
||||
| `subscribeClientService` | NO | Service certificate management |
|
||||
|
||||
## Messaging
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `sendMessage` | YES | Send a message — core function |
|
||||
| `sendMessages` | NO | Batch send — sendMessage is sufficient for MVP |
|
||||
| `sendMessagesB` | NO | Batch send with error handling — optimization |
|
||||
| `ackMessage` | YES | Acknowledge received message — required for protocol correctness |
|
||||
| `ackMessageAsync` | NO | Async variant |
|
||||
|
||||
## Queue management
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `switchConnection` | NO | Queue rotation — not needed |
|
||||
| `switchConnectionAsync` | NO | Same |
|
||||
| `abortConnectionSwitch` | NO | Cancel rotation |
|
||||
| `getConnectionQueueInfo` | NO | Debug info |
|
||||
| `suspendConnection` | NO | Widget deletes or ignores, doesn't suspend |
|
||||
|
||||
## Ratchet
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `synchronizeRatchet` | NO | Widget doesn't initiate ratchet sync. But MUST handle incoming EREADY — that's in processSMPTransmissions, not a separate API call. |
|
||||
| `getConnectionRatchetAdHash` | NO | Verification UI not in widget |
|
||||
|
||||
## Connection cleanup
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `deleteConnection` | YES | User may want to delete a conversation |
|
||||
| `deleteConnectionAsync` | NO | Async variant |
|
||||
| `deleteConnections` | NO | Batch delete — single delete sufficient |
|
||||
| `deleteConnectionsAsync` | NO | Same |
|
||||
| `getConnectionServers` | NO | Info only |
|
||||
| `compareConnections` | NO | Database sync tool |
|
||||
| `syncConnections` | NO | Database sync tool |
|
||||
|
||||
## Server configuration
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `setProtocolServers` | NO | Widget initialized with servers, doesn't change them |
|
||||
| `checkUserServers` | NO | Admin function |
|
||||
| `testProtocolServer` | NO | Admin function |
|
||||
| `setNtfServers` | NO | No notifications in MVP |
|
||||
| `setNetworkConfig` | NO | Widget uses default network config |
|
||||
| `setUserNetworkInfo` | NO | Widget doesn't track network state changes |
|
||||
| `reconnectAllServers` | NO | Widget handles reconnection via SMP client |
|
||||
| `reconnectSMPServer` | NO | Same |
|
||||
|
||||
## Notifications (all post-MVP)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `registerNtfToken` | NO | No webpush yet |
|
||||
| `verifyNtfToken` | NO | Same |
|
||||
| `checkNtfToken` | NO | Same |
|
||||
| `deleteNtfToken` | NO | Same |
|
||||
| `getNtfToken` | NO | Same |
|
||||
| `getNtfTokenData` | NO | Same |
|
||||
| `toggleConnectionNtfs` | NO | Same |
|
||||
|
||||
## File transfer (all post-MVP)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `xftpStartWorkers` | NO | Post-MVP |
|
||||
| `xftpStartSndWorkers` | NO | Same |
|
||||
| `xftpReceiveFile` | NO | Same |
|
||||
| `xftpDeleteRcvFile` | NO | Same |
|
||||
| `xftpDeleteRcvFiles` | NO | Same |
|
||||
| `xftpSendFile` | NO | Same |
|
||||
| `xftpSendDescription` | NO | Same |
|
||||
| `xftpDeleteSndFileInternal` | NO | Same |
|
||||
| `xftpDeleteSndFilesInternal` | NO | Same |
|
||||
| `xftpDeleteSndFileRemote` | NO | Same |
|
||||
| `xftpDeleteSndFilesRemote` | NO | Same |
|
||||
|
||||
## Remote control (all skip)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `rcNewHostPairing` | NO | Not in scope |
|
||||
| `rcConnectHost` | NO | Same |
|
||||
| `rcConnectCtrl` | NO | Same |
|
||||
| `rcDiscoverCtrl` | NO | Same |
|
||||
|
||||
## Debug/stats (all skip)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `getAgentSubsTotal` | NO | Debug |
|
||||
| `getAgentServersSummary` | NO | Debug |
|
||||
| `resetAgentServersStats` | NO | Debug |
|
||||
| `execAgentStoreSQL` | NO | Debug |
|
||||
| `getAgentMigrations` | NO | Debug |
|
||||
| `debugAgentLocks` | NO | Debug |
|
||||
| `getAgentSubscriptions` | NO | Debug |
|
||||
| `logConnection` | NO | Debug |
|
||||
| `withAgentEnv` | NO | Test utility |
|
||||
|
||||
## Summary
|
||||
|
||||
### Corrections after reviewing simplex-chat-2 Subscriber.hs + Commands.hs
|
||||
|
||||
The widget handles business chats (groups). Group flows trigger agent calls the widget doesn't initiate but must support:
|
||||
|
||||
- Member introductions create connections asynchronously → `createConnectionAsync`
|
||||
- Members join via introductions → `joinConnectionAsync`, `prepareConnectionToJoin`
|
||||
- Members leave/deleted → `deleteConnectionAsync`, `deleteConnectionsAsync`
|
||||
- Group messages go to all members → `sendMessages`
|
||||
- All message acks use async variant → `ackMessageAsync`
|
||||
- Accepting group invitations → `allowConnectionAsync`
|
||||
- `acceptContact` — used in contact request acceptance flow (APIAcceptContactRequest in Commands.hs)
|
||||
- `toggleConnectionNtfs` — used when CON received for group member (Subscriber.hs:850)
|
||||
|
||||
### MVP functions (27 of 90):
|
||||
|
||||
**Lifecycle:** `getSMPAgentClient`, `disconnectAgentClient`
|
||||
|
||||
**User:** `createUser`
|
||||
|
||||
**Join:** `prepareConnectionToJoin`, `joinConnection`, `joinConnectionAsync`, `connRequestPQSupport`
|
||||
|
||||
**Handshake:** `allowConnection`, `allowConnectionAsync`, `acceptContact`, `acceptContactAsync`, `prepareConnectionToAccept`
|
||||
|
||||
**Connection creation (for group member flows):** `createConnectionAsync`, `deleteConnectionAsync`, `deleteConnectionsAsync`
|
||||
|
||||
**Subscribe:** `subscribeConnection`, `subscribeConnections`, `subscribeAllConnections`, `resubscribeConnection`, `resubscribeConnections`
|
||||
|
||||
**Message:** `sendMessage`, `sendMessages`, `ackMessage`, `ackMessageAsync`
|
||||
|
||||
**Cleanup:** `deleteConnection`
|
||||
|
||||
**Notification toggle:** `toggleConnectionNtfs`
|
||||
|
||||
**Internal (not exported but required):** `subscriber`/`processSMPTransmissions` (message processing), `runSmpQueueMsgDelivery` (delivery worker), all encryption/decryption functions, store operations for the above.
|
||||
|
||||
### Message types to handle in processSMPTransmissions:
|
||||
|
||||
| AMessage | Handle? | Reason |
|
||||
|----------|---------|--------|
|
||||
| `HELLO` | YES | Complete handshake |
|
||||
| `A_MSG body` | YES | Deliver message to user |
|
||||
| `A_RCVD receipts` | YES | Process delivery receipts (show checkmarks) |
|
||||
| `A_QCONT addr` | NO | Queue continuation after quota — skip |
|
||||
| `QADD qs` | NO | Queue rotation — skip |
|
||||
| `QKEY qs` | NO | Queue rotation — skip |
|
||||
| `QUSE qs` | NO | Queue rotation — skip |
|
||||
| `QTEST qs` | NO | Queue rotation — skip |
|
||||
| `EREADY msgId` | ACCEPT | Must handle incoming (don't initiate) — reset ratchet sync state |
|
||||
|
||||
### AgentMsgEnvelope types to handle:
|
||||
|
||||
| Variant | Handle? | Reason |
|
||||
|---------|---------|--------|
|
||||
| `AgentConfirmation` | YES | Handshake — received when peer confirms |
|
||||
| `AgentMsgEnvelope` | YES | Normal encrypted messages |
|
||||
| `AgentInvitation` | YES | Received when joining contact address |
|
||||
| `AgentRatchetKey` | ACCEPT | Must handle incoming ratchet key — don't initiate |
|
||||
|
||||
### Store operations needed:
|
||||
|
||||
Based on the 18 MVP functions, the store needs (rough count):
|
||||
|
||||
- Connection CRUD: ~8 operations
|
||||
- Queue CRUD: ~6 operations
|
||||
- Ratchet state: ~4 operations (get, update, skipped keys)
|
||||
- Message storage: ~6 operations (create rcv/snd msg, update status, delete)
|
||||
- Message delivery: ~4 operations (create delivery, get pending, update status)
|
||||
- User: ~2 operations (create, get)
|
||||
- Confirmation: ~3 operations (create, get, delete)
|
||||
|
||||
**Estimated: ~33 store operations.** This is what determines whether SQLite WASM or IndexedDB direct is more practical.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Agent Store: IndexedDB Design
|
||||
|
||||
**Parent**: [Agent Plan](./2026-05-22-agent.md)
|
||||
|
||||
## Schema
|
||||
|
||||
IndexedDB object stores, mapped from SQLite tables. Each store mirrors the Haskell schema from `agent_schema.sql`.
|
||||
|
||||
### Object stores needed (16)
|
||||
|
||||
```
|
||||
users
|
||||
key: userId (autoincrement)
|
||||
fields: deleted
|
||||
|
||||
connections
|
||||
key: connId (Uint8Array)
|
||||
fields: connMode, lastInternalMsgId, lastInternalRcvMsgId, lastInternalSndMsgId,
|
||||
lastExternalSndMsgId, lastRcvMsgHash, lastSndMsgHash, smpAgentVersion,
|
||||
duplexHandshake, enableNtfs, deleted, userId, ratchetSyncState, pqSupport
|
||||
|
||||
rcv_queues
|
||||
key: [host, port, rcvId] (compound)
|
||||
index: [connId], [host, port, sndId]
|
||||
fields: connId, rcvPrivateKey, rcvDhSecret, e2ePrivKey, e2eDhSecret, sndId, sndKey,
|
||||
status, smpClientVersion, rcvQueueId, rcvPrimary, replaceRcvQueueId, queueMode,
|
||||
serverKeyHash, lastBrokerTs
|
||||
|
||||
snd_queues
|
||||
key: [host, port, sndId] (compound)
|
||||
index: [connId]
|
||||
fields: connId, sndPrivateKey, e2eDhSecret, status, smpClientVersion,
|
||||
sndPublicKey, e2ePubKey, sndQueueId, sndPrimary, queueMode, serverKeyHash
|
||||
|
||||
messages
|
||||
key: [connId, internalId] (compound)
|
||||
index: [connId]
|
||||
fields: internalTs, internalRcvId, internalSndId, msgType, msgBody, msgFlags, pqEncryption
|
||||
|
||||
rcv_messages
|
||||
key: [connId, internalRcvId] (compound)
|
||||
index: [connId, internalId]
|
||||
fields: internalId, externalSndId, brokerId, brokerTs, internalHash,
|
||||
externalPrevSndHash, integrity, userAck, rcvQueueId, receiveAttempts
|
||||
|
||||
snd_messages
|
||||
key: [connId, internalSndId] (compound)
|
||||
index: [connId, internalId]
|
||||
fields: internalId, internalHash, previousMsgHash, retryIntSlow, retryIntFast,
|
||||
rcptInternalId, rcptStatus, msgEncryptKey, paddedMsgLen, sndMessageBodyId
|
||||
|
||||
snd_message_deliveries
|
||||
key: sndMessageDeliveryId (autoincrement)
|
||||
index: [connId, sndQueueId]
|
||||
fields: connId, sndQueueId, internalId, failed
|
||||
|
||||
snd_message_bodies
|
||||
key: sndMessageBodyId (autoincrement)
|
||||
fields: agentMsg
|
||||
|
||||
conn_confirmations
|
||||
key: confirmationId (Uint8Array)
|
||||
index: [connId]
|
||||
fields: connId, e2eSndPubKey, senderKey, ratchetState, senderConnInfo,
|
||||
accepted, ownConnInfo, smpReplyQueues, smpClientVersion
|
||||
|
||||
conn_invitations
|
||||
key: invitationId (Uint8Array)
|
||||
index: [contactConnId]
|
||||
fields: contactConnId, crInvitation, recipientConnInfo, accepted, ownConnInfo
|
||||
|
||||
ratchets
|
||||
key: connId (Uint8Array)
|
||||
fields: x3dhPrivKey1, x3dhPrivKey2, ratchetState, e2eVersion,
|
||||
x3dhPubKey1, x3dhPubKey2, pqPrivKem, pqPubKem
|
||||
|
||||
skipped_messages
|
||||
key: skippedMessageId (autoincrement)
|
||||
index: [connId]
|
||||
fields: connId, headerKey, msgN, msgKey
|
||||
|
||||
servers
|
||||
key: [host, port] (compound)
|
||||
fields: keyHash
|
||||
|
||||
commands
|
||||
key: commandId (autoincrement)
|
||||
index: [connId], [host, port]
|
||||
fields: connId, host, port, corrId, commandTag, command, agentVersion, serverKeyHash, failed
|
||||
|
||||
encrypted_rcv_message_hashes
|
||||
key: id (autoincrement)
|
||||
index: [connId, hash]
|
||||
fields: connId, hash, createdAt
|
||||
```
|
||||
|
||||
## Interface
|
||||
|
||||
TypeScript interface matching the ~60 store operations. Each method maps to a specific Haskell function in `AgentStore.hs`.
|
||||
|
||||
The interface will be defined in `src/agent/store.ts`. Implementation in `src/agent/store-idb.ts` (IndexedDB).
|
||||
|
||||
## Implementation approach
|
||||
|
||||
1. Define the TypeScript interface first — every method name matches the Haskell function name
|
||||
2. Implement with IndexedDB transactions
|
||||
3. Test each operation in isolation before wiring to agent
|
||||
|
||||
IndexedDB transactions map to SQLite transactions — both are ACID within a single store/table. Cross-store atomicity in IndexedDB requires putting multiple stores in one transaction, which is supported.
|
||||
|
||||
## Key differences from SQLite
|
||||
|
||||
1. **No SQL joins** — denormalize where needed, or do application-level joins
|
||||
2. **No AUTO INCREMENT guaranteed ordering** — use explicit counters
|
||||
3. **Blob keys** — IndexedDB supports ArrayBuffer keys natively
|
||||
4. **Compound keys** — IndexedDB supports array keys: `[host, port, rcvId]`
|
||||
5. **Indexes** — must be declared upfront in `onupgradeneeded`
|
||||
|
||||
## Testing
|
||||
|
||||
Each store operation tested by: write data, read it back, verify it matches. No server needed — pure store tests using `fake-indexeddb` in Node.js.
|
||||
@@ -0,0 +1,666 @@
|
||||
# Agent Client Middle Layer: Transpilation Plan
|
||||
|
||||
**Parent**: [Agent Plan](./2026-05-22-agent.md)
|
||||
**Depends on**: Store (complete, 98 tests), SMP Client (complete, 99 tests), Ratchet (complete), Agent Protocol Types (complete)
|
||||
|
||||
## Rule
|
||||
|
||||
Every TypeScript function is a faithful transpilation of a specific Haskell function. Same name, same steps, same call chain. No inferences, no simplifications, no "browser-friendly" shortcuts. The concurrency primitives differ (Promises vs STM, event callbacks vs TBQueue), but the logic, state transitions, and decision paths must be identical.
|
||||
|
||||
## Architecture mapping
|
||||
|
||||
| Haskell | TypeScript | Notes |
|
||||
|---------|-----------|-------|
|
||||
| `TVar a` | mutable variable (object property) | Single-threaded, no atomicity needed |
|
||||
| `TMap k v` | `Map<K, V>` | No STM, direct mutation |
|
||||
| `TBQueue a` | `ABQueue<T>` | `subQ` for user events, `msgQ` for server messages |
|
||||
| `TMVar a` | `Promise` + resolver, or flag | For worker doWork signaling |
|
||||
| `STM` transaction | synchronous code block | Single-threaded JS, no races |
|
||||
| `forkIO` / `async` | `setTimeout(0)` / microtask | Event loop scheduling |
|
||||
| `Worker` thread | delivery loop function | Triggered by `submitPendingMsg`, runs via microtask |
|
||||
| `ReaderT Env IO` (AM') | closure over agent state | Config + store + DRG captured in closure |
|
||||
| `ExceptT AgentErrorType` (AM) | thrown errors / Result type | TBD: throw vs return Either |
|
||||
|
||||
## Files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `smp-web/src/agent/queue.ts` | Sem, ABQueue (copied from simplex-chat) |
|
||||
| `smp-web/src/agent/tmvar.ts` | TMVar — single-cell blocking variable |
|
||||
| `smp-web/src/agent/session.ts` | SessionVar, getSessVar (Promise-based) |
|
||||
| `smp-web/src/agent/retry.ts` | RetryInterval, RetryInterval2, withRetryLock2 |
|
||||
| `smp-web/src/agent/subscriptions.ts` | TSessionSubs transpilation |
|
||||
| `smp-web/src/agent/client.ts` | AgentClient state, session management, worker infrastructure, queue operations |
|
||||
| `smp-web/src/agent/agent.ts` | Top-level agent API (joinConnection, sendMessage, etc.) |
|
||||
| `smp-web/tests/agent-repl.ts` | Agent-level REPL for cross-language testing |
|
||||
|
||||
## Implementation order
|
||||
|
||||
Each step produces a testable artifact.
|
||||
|
||||
### Step 1: Pure infrastructure (TS-only tests)
|
||||
- `queue.ts` — Sem, ABQueue (copied verbatim from simplex-chat)
|
||||
- `tmvar.ts` — TMVar (new)
|
||||
- `session.ts` — SessionVar, getSessVar, removeSessVar, tryReadSessVar
|
||||
- `retry.ts` — RetryInterval types + nextRetryDelay + withRetryInterval + withRetryLock2
|
||||
- `subscriptions.ts` — TSessionSubs (all 22 functions)
|
||||
- **Test**: TS unit tests for each module — no server needed
|
||||
|
||||
### Step 2: AgentClient state + worker infrastructure (TS-only tests)
|
||||
- AgentClient record, newAgentClient
|
||||
- Worker, newWorker, getAgentWorker, runWorkerAsync, waitForWork, hasWorkToDo, withWork
|
||||
- AgentOpState, operation bracket, suspend/resume
|
||||
- Locking (withConnLock, withInvLock)
|
||||
- Server selection (userServers, pickServer, getNextServer, withNextSrv)
|
||||
- Store wrappers (withStore, withStore', storeError)
|
||||
- **Test**: TS tests — create agent client, test worker lifecycle, test server selection
|
||||
|
||||
### Step 3a: SMP session management + direct queue operations
|
||||
- getSMPServerClient, smpConnectClient, waitForSMPClient
|
||||
- agentCbEncrypt, agentCbEncryptOnce, agentCbDecrypt (using existing ClientMsgEnvelope from protocol.ts)
|
||||
- sendAgentMessage, sendConfirmation, sendInvitation
|
||||
- secureQueue, secureSndQueue, sendAck
|
||||
- decryptSMPMessage
|
||||
- newRcvQueue
|
||||
- subscribeQueues, subscribeServerQueues
|
||||
- addNewQueueSubscription
|
||||
- **Test**: TS-only round-trip test for agentCbEncrypt (encode → decode → decrypt), then agent-repl cross-language tests (TS creates queue → Haskell sends → TS receives, TS sends → Haskell receives)
|
||||
|
||||
### Step 3b: Disconnect handling + resubscription
|
||||
- smpClientDisconnected (full: remove proxied relays, notify DOWN, trigger resubscribe)
|
||||
- resubscribeSMPSession, resubscribeSessQueues
|
||||
- processSubResults (partition into failed/subscribed)
|
||||
- subscribeSessQueues_ (batch SUB + process results)
|
||||
- **Test**: agent-repl: establish connection → kill WebSocket → verify subs move to pending → reconnect → verify resubscribed
|
||||
|
||||
### Step 3c: Proxy operations
|
||||
- getSMPProxyClient (get/create proxied relay session)
|
||||
- withProxySession (bracket for proxied operations)
|
||||
- sendOrProxySMPMessage (decide direct vs proxy, delegate)
|
||||
- sendOrProxySMPCommand (decide direct vs proxy for SKEY etc)
|
||||
- ipAddressProtected, shouldUseProxy logic
|
||||
- withClient_, withClient, withSMPClient, withLogClient_
|
||||
- **Test**: agent-repl: TS sends via proxy → Haskell receives
|
||||
|
||||
### Step 4: Agent message flow (cross-language end-to-end)
|
||||
- agentRatchetEncrypt, agentRatchetEncryptHeader, agentRatchetDecrypt
|
||||
- encodeAgentMsgStr
|
||||
- enqueueMessageB, storeConfirmation, enqueueConfirmation
|
||||
- submitPendingMsg, getDeliveryWorker, runSmpQueueMsgDelivery
|
||||
- enqueueCommand, runCommandProcessing
|
||||
- **Test via agent-repl**: TS encrypts agent message → Haskell decrypts, Haskell encrypts → TS decrypts
|
||||
|
||||
### Step 5: Connection handshake + full agent API (cross-language end-to-end)
|
||||
- newConnToJoin, joinConn, joinConnSrv, startJoinInvitation
|
||||
- compatibleInvitationUri, compatibleContactUri
|
||||
- secureConfirmQueue(Async), agentSecureSndQueue
|
||||
- mkAgentConfirmation, createReplyQueue, newRcvConnSrv, createRcvQueue
|
||||
- newSndQueue, connectReplyQueues
|
||||
- allowConnection'
|
||||
- processSMPTransmissions, subscriber
|
||||
- decryptClientMessage, agentClientMsg
|
||||
- smpConfirmation, helloMsg, smpInvitation
|
||||
- sendMessage', sendMessagesB_
|
||||
- ackMessage', ackQueueMessage
|
||||
- subscribeConnection(s)
|
||||
- **Test**: TS joins invitation URI created by Haskell agent → handshake completes → messages flow both ways → ack
|
||||
|
||||
---
|
||||
|
||||
## Piece -1: Concurrency primitives
|
||||
|
||||
### `Sem` and `ABQueue` — copy from simplex-chat
|
||||
|
||||
Copy verbatim from `/code/simplex-chat/packages/simplex-chat-client/typescript/src/queue.ts` into `smp-web/src/agent/queue.ts`.
|
||||
|
||||
`Sem` — counting semaphore. `wait()` blocks if permits=0. `signal()` increments and wakes a waiter.
|
||||
`ABQueue` — async bounded queue. Two semaphores (enq for items, deq for slots). Backpressure on full. Close via sentinel. Implements AsyncIterator.
|
||||
|
||||
Used for:
|
||||
- `subQ` — agent events to user. Agent writes, user reads via `dequeue()` loop or async iterator.
|
||||
- `msgQ` — WebSocket onmessage enqueues, subscriber loop dequeues and calls `processSMPTransmissions`.
|
||||
- Queues prevent deadlock: without them, processing a received message that triggers a send, which triggers another event, could cause unbounded reentrancy in single-threaded JS.
|
||||
|
||||
### `TMVar<T>` — new, in `smp-web/src/agent/tmvar.ts`
|
||||
|
||||
Single-cell mutable variable, empty or full. Blocking take/put/read.
|
||||
|
||||
```typescript
|
||||
class TMVar<T> {
|
||||
private val: T | undefined
|
||||
private full: boolean
|
||||
private takeQ: Array<(v: T) => void> = [] // waiters for value to appear
|
||||
private putQ: Array<(v: T) => void> = [] // waiters for cell to empty
|
||||
|
||||
static empty<T>(): TMVar<T> // create empty
|
||||
static new<T>(v: T): TMVar<T> // create full
|
||||
|
||||
take(): Promise<T> // block until full, take value, leave empty
|
||||
put(v: T): Promise<void> // block until empty, put value
|
||||
read(): Promise<T> // block until full, return value without taking
|
||||
tryTake(): T | undefined // non-blocking take
|
||||
tryPut(v: T): boolean // non-blocking put, returns false if full
|
||||
tryRead(): T | undefined // non-blocking read
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
Used for:
|
||||
- `doWork :: TMVar ()` — worker signal. `waitForWork` = `read()`. `noWorkToDo` = `tryTake()`. `hasWorkToDo` = `tryPut(undefined)`.
|
||||
- `action :: TMVar (Maybe ThreadId)` — worker running state. `runWorkerAsync` takes, checks, starts async loop.
|
||||
- Retry lock in `withRetryLock2`.
|
||||
|
||||
The doWork race condition: worker clears FIRST (`tryTake`), THEN checks store. If work found, re-sets (`tryPut`). Any signal arriving during the store check (via `await` yielding to onmessage → `hasWorkToDo`) stays set because it happened after the clear. If we did read-then-clear-if-empty, the clear could swallow a signal set between the store check and the clear.
|
||||
|
||||
### Locks — `Sem(1)` or Promise chain
|
||||
|
||||
For `withConnLock`, `withInvLock`: `Map<string, Lock>`. Each Lock is either:
|
||||
- `Sem(1)` — acquire = `wait()`, release = `signal()`, wrap in try/finally
|
||||
- Or Promise chain (each `withLock` appends to previous promise)
|
||||
|
||||
`Sem(1)` is simpler and correct. Wrap in helper:
|
||||
|
||||
```typescript
|
||||
async function withLock(locks: Map<string, Sem>, key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let sem = locks.get(key)
|
||||
if (!sem) { sem = new Sem(1); locks.set(key, sem) }
|
||||
await sem.wait()
|
||||
try { return await fn() } finally { sem.signal() }
|
||||
}
|
||||
```
|
||||
|
||||
### SessionVar — Promise with exposed resolver
|
||||
|
||||
`SessionVar` tracks pending protocol client connections. First caller creates a Promise, connects, resolves it. Subsequent callers await the same Promise.
|
||||
|
||||
```typescript
|
||||
interface SessionVar<T> {
|
||||
id: number
|
||||
ts: number
|
||||
promise: Promise<T>
|
||||
resolve: (v: T) => void
|
||||
reject: (e: Error) => void
|
||||
value: T | undefined // set after resolve, for tryRead
|
||||
}
|
||||
```
|
||||
|
||||
`getSessVar`: if key exists in Map, return Right (existing). Else create new with unresolved Promise, insert, return Left (new).
|
||||
`removeSessVar`: delete if ID matches.
|
||||
`tryReadSessVar`: return `value` if set.
|
||||
|
||||
No TMVar needed — Promise coalesces reads naturally.
|
||||
|
||||
---
|
||||
|
||||
## Piece 0: SessionVar (`session.ts`)
|
||||
|
||||
Transpile from `Simplex/Messaging/Session.hs` (43 lines).
|
||||
|
||||
| Function | Haskell lines | Purpose |
|
||||
|----------|--------------|---------|
|
||||
| `SessionVar` type | 18-22 | `{sessionVar: TMVar a, sessionVarId: number, sessionVarTs: Date}` |
|
||||
| `getSessVar` | 24-33 | Get existing or create new empty session var for key |
|
||||
| `removeSessVar` | 35-39 | Remove if ID matches (guards against removing replaced session) |
|
||||
| `tryReadSessVar` | 41-42 | Non-blocking read of session var value |
|
||||
|
||||
Browser: `TMVar a` → `{value: T | undefined, resolve: (() => void) | null}`. `getSessVar` returns Left (new, empty) or Right (existing).
|
||||
|
||||
---
|
||||
|
||||
## Piece 1: RetryInterval (`retry.ts`)
|
||||
|
||||
Transpile from `Agent/RetryInterval.hs` (119 lines).
|
||||
|
||||
| Function | Haskell lines | Purpose |
|
||||
|----------|--------------|---------|
|
||||
| `RetryInterval` type | 27-31 | `{initialInterval, increaseAfter, maxInterval}` (microseconds) |
|
||||
| `RetryInterval2` type | 33-36 | `{riSlow, riFast}` |
|
||||
| `RI2State` type | 38-41 | `{slowInterval, fastInterval}` |
|
||||
| `RetryIntervalMode` type | 51 | `RISlow \| RIFast` |
|
||||
| `nextRetryDelay` | 114-118 | Pure: if elapsed < increaseAfter, keep delay; else min(delay*3/2, max) |
|
||||
| `updateRetryInterval2` | 44-49 | Update RI2 from saved state |
|
||||
| `withRetryInterval` | 54-55 | Wrapper around withRetryIntervalCount |
|
||||
| `withRetryIntervalCount` | 57-66 | Loop: action(n, delay, loop); loop sleeps then recurses with updated delay |
|
||||
| `withRetryLock2` | 90-112 | Two-mode retry with lock: action gets RI2State + loop function that takes mode |
|
||||
|
||||
Browser adaptation: `threadDelay'` → `setTimeout` wrapped in Promise. `TMVar` lock → Promise-based signal. Logic identical.
|
||||
|
||||
---
|
||||
|
||||
## Piece 2: TSessionSubs (`subscriptions.ts`)
|
||||
|
||||
Transpile from `Agent/TSessionSubs.hs` (202 lines). Every function, every branch.
|
||||
|
||||
Transport session key: `(UserId, SMPServer)` — serialized to string for Map key. One session per server, no per-entity multiplexing.
|
||||
|
||||
| Function | Haskell lines | Purpose |
|
||||
|----------|--------------|---------|
|
||||
| `TSessionSubs` type | 49-51 | `Map<string, SessSubs>` (string = serialized transport session) |
|
||||
| `SessSubs` type | 53-57 | `{sessId: SessionId \| null, activeSubs: Map<string, RcvQueueSub>, pendingSubs: Map<string, RcvQueueSub>}` |
|
||||
| `emptyIO` | 59-61 | Create empty TSessionSubs |
|
||||
| `clear` | 63-65 | Clear all |
|
||||
| `getSessSubs` | 71-77 | Get or create SessSubs for a transport session |
|
||||
| `hasActiveSub` | 79-81 | Check if rcvId has active subscription |
|
||||
| `hasPendingSub` | 83-85 | Check if rcvId has pending subscription |
|
||||
| `addPendingSub` | 91-92 | Add to pendingSubs |
|
||||
| `setSessionId` | 94-99 | Set session ID; if changed, move active→pending |
|
||||
| `addActiveSub` | 101-110 | If sessId matches, add to active + remove from pending; else add to pending |
|
||||
| `batchAddActiveSubs` | 112-121 | Batch version of addActiveSub |
|
||||
| `batchAddPendingSubs` | 123-126 | Batch add to pending |
|
||||
| `deletePendingSub` | 128-129 | Delete from pending |
|
||||
| `batchDeletePendingSubs` | 131-134 | Batch delete from pending |
|
||||
| `deleteSub` | 136-137 | Delete from both active and pending |
|
||||
| `batchDeleteSubs` | 139-143 | Batch delete from both |
|
||||
| `hasPendingSubs` | 145-146 | Check if any pending exist for session |
|
||||
| `getPendingSubs` | 148-150 | Get all pending for session |
|
||||
| `getActiveSubs` | 152-154 | Get all active for session |
|
||||
| `setSubsPending` | 159-177 | Move active→pending on disconnect; handles session mode transitions |
|
||||
| `setSubsPending_` | 179-187 | Internal: write new sessId, move active→pending |
|
||||
| `updateClientNotices` | 189-192 | Update clientNoticeId on pending subs |
|
||||
| `foldSessionSubs` | 194-195 | Fold over all sessions |
|
||||
| `mapSubs` | 197-201 | Map over active and pending |
|
||||
|
||||
Critical: `setSubsPending` has mode-dependent logic (TSMEntity vs TSMUser/TSMServer). Must be transpiled exactly.
|
||||
|
||||
---
|
||||
|
||||
## Piece 3: AgentClient state + worker infrastructure (`client.ts`)
|
||||
|
||||
### AgentClient state
|
||||
|
||||
Transpile from `AgentClient` record (Client.hs:328-378) and `newAgentClient` (Client.hs:498-584).
|
||||
|
||||
| Field | Haskell type | TS type | Purpose |
|
||||
|-------|-------------|---------|---------|
|
||||
| `active` | `TVar Bool` | `boolean` | Is agent active |
|
||||
| `subQ` | `TBQueue ATransmission` | `ABQueue` | Events to user |
|
||||
| `msgQ` | `TBQueue (ServerTransmissionBatch ...)` | `ABQueue` | WebSocket onmessage enqueues, subscriber dequeues |
|
||||
| `smpServers` | `TMap UserId (UserServers 'PSMP)` | `Map<UserId, UserServers>` | Server configs per user |
|
||||
| `smpClients` | `TMap SMPTransportSession SMPClientVar` | `Map<string, SMPClient \| Promise<SMPClient>>` | Active SMP connections |
|
||||
| `smpProxiedRelays` | `TMap SMPTransportSession SMPServerWithAuth` | `Map<string, SMPServerWithAuth>` | Proxy routing |
|
||||
| `useNetworkConfig` | `TVar (NetworkConfig, NetworkConfig)` | `{slow: NetworkConfig, fast: NetworkConfig}` | Network config |
|
||||
| `userNetworkInfo` | `TVar UserNetworkInfo` | `UserNetworkInfo` | Online/offline state |
|
||||
| `subscrConns` | `TVar (Set ConnId)` | `Set<string>` | Connections being subscribed |
|
||||
| `currentSubs` | `TSessionSubs` | `TSessionSubs` | Active/pending subscriptions |
|
||||
| `removedSubs` | `TMap ...` | `Map<string, Map<string, SMPClientError>>` | Failed subscriptions |
|
||||
| `workerSeq` | `TVar Int` | `number` | Worker ID sequence |
|
||||
| `smpDeliveryWorkers` | `TMap SndQAddr (Worker, TMVar ())` | `Map<string, DeliveryWorker>` | Per-queue delivery workers |
|
||||
| `asyncCmdWorkers` | `TMap (ConnId, Maybe SMPServer) Worker` | `Map<string, Worker>` | Async command workers |
|
||||
| `rcvNetworkOp` | `TVar AgentOpState` | `AgentOpState` | Receive operation state |
|
||||
| `msgDeliveryOp` | `TVar AgentOpState` | `AgentOpState` | Delivery operation state |
|
||||
| `sndNetworkOp` | `TVar AgentOpState` | `AgentOpState` | Send operation state |
|
||||
| `agentState` | `TVar AgentState` | `AgentState` | Foreground/suspended/suspending |
|
||||
| `connLocks` | `TMap ConnId Lock` | `Map<string, Promise<void>>` | Connection locks |
|
||||
| `invLocks` | `TMap ByteString Lock` | `Map<string, Promise<void>>` | Invitation locks |
|
||||
| `agentEnv` | `Env` | closure | Config + store + RNG |
|
||||
|
||||
Fields NOT needed for MVP: `ntfServers`, `ntfClients`, `xftpServers`, `xftpClients`, `smpSubWorkers`, `clientNotices`, `clientNoticesLock`, `getMsgLocks`, `deleteLock`, `proxySessTs`, `*Stats`, `srvStatsStartedAt`, `acThread`, `presetDomains`, `presetServers`.
|
||||
|
||||
### Worker infrastructure
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `Worker` type | Env/SQLite.hs:317-322 | `{workerId, doWork: TMVar (), action: TMVar (Maybe ThreadId), restarts}` |
|
||||
| `RestartCount` type | Env/SQLite.hs:324-327 | `{restartMinute, restartCount}` |
|
||||
| `updateRestartCount` | Env/SQLite.hs:329-332 | Reset count if minute changed, else increment |
|
||||
| `newWorker` | Client.hs:439-445 | Create worker with doWork TMVar |
|
||||
| `getAgentWorker` | Client.hs:387-389 | Get-or-create worker for key |
|
||||
| `getAgentWorker'` | Client.hs:391-437 | Full version with restart logic |
|
||||
| `runWorkerAsync` | Client.hs:447-454 | Start worker if not running |
|
||||
| `waitForWork` | Client.hs:2118-2119 | Block until doWork has value |
|
||||
| `hasWorkToDo` / `hasWorkToDo'` | Client.hs:2171-2176 | Signal work available (tryPutTMVar) |
|
||||
| `withWork` / `withWork_` | Client.hs:2122-2140 | Wait for work, get item from store, run action |
|
||||
|
||||
Browser adaptation: `TMVar ()` → boolean flag + resolver. `forkIO` → `setTimeout(0)`. Worker restart logic must be preserved exactly.
|
||||
|
||||
### Operation state management
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `AgentOpState` type | Client.hs:470 | `{opSuspended, opsInProgress}` |
|
||||
| `AgentState` type | Client.hs:472-473 | `ASForeground \| ASSuspending \| ASSuspended` |
|
||||
| `agentOperationBracket` | Client.hs:2232-2245 | Begin/end operation with suspend check |
|
||||
| `beginAgentOperation` | Client.hs:2223-2230 | Increment opsInProgress |
|
||||
| `endAgentOperation` | Client.hs:2179-2197 | Decrement opsInProgress, cascade suspend |
|
||||
| `waitUntilActive` | Client.hs:956-957 | Block until agent is active |
|
||||
| `throwWhenInactive` | Client.hs:959-962 | Throw if not active |
|
||||
| `waitWhileSuspended` | Client.hs:2248-2253 | Block while suspended |
|
||||
| `waitForUserNetwork` | Client.hs:924-928 | Block until network online |
|
||||
| `noWorkToDo` | Client.hs:2167-2168 | Clear work flag (tryTakeTMVar) |
|
||||
|
||||
### Store wrappers
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `withStore` | Client.hs:2259-2270 | Run store action, convert StoreError to AgentErrorType |
|
||||
| `withStore'` | Client.hs:2255-2257 | Simplified withStore (always Right) |
|
||||
| `storeError` | Client.hs (exported) | StoreError → AgentErrorType |
|
||||
|
||||
### Server selection
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `userServers` | Client.hs:2312-2318 | Get user's server map |
|
||||
| `pickServer` | Client.hs:2318-2325 | Pick server from NonEmpty list |
|
||||
| `getNextServer` | Client.hs:2325-2350 | Get next server avoiding used hosts |
|
||||
| `withNextSrv` | Client.hs:2375-2407 | Retry with next server on failure |
|
||||
|
||||
### Locking
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `withConnLock` | Client.hs:1003-1006 | Per-connection mutex |
|
||||
| `withConnLocks` | Client.hs:1020-1022 | Multiple connection mutex |
|
||||
| `withInvLock` | Client.hs:1012-1015 | Per-invitation mutex |
|
||||
|
||||
Browser: locks via Promise chains. Single-threaded JS means no actual contention, but the ordering semantics must be preserved for async operations.
|
||||
|
||||
---
|
||||
|
||||
## Piece 4: SMP session management
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `getSMPServerClient` | Client.hs:642-651 | Get or create SMP WebSocket client for transport session |
|
||||
| `getSMPProxyClient` | Client.hs:653-702 | Get or create proxied relay session |
|
||||
| `smpConnectClient` | Client.hs:704-718 | Actually connect SMP client via WebSocket |
|
||||
| `smpClientDisconnected` | Client.hs:720-754 | Handle disconnect: move subs to pending, notify, resubscribe |
|
||||
| `resubscribeSMPSession` | Client.hs:756-790 | Create resubscription worker |
|
||||
| `mkTransportSession` | Client.hs:1345-1348 | Build transport session key |
|
||||
| `mkSMPTransportSession` | Client.hs:1357-1360 | Build SMP transport session from queue |
|
||||
| `getSessionMode` | Client.hs:1369-1370 | Get current session mode |
|
||||
| `withClient_` | Client.hs:1037-1045 | Bracket: get client → run action → handle errors |
|
||||
| `withClient` | Client.hs:1071-1073 | withClient_ + liftClient |
|
||||
| `withSMPClient` | Client.hs:1079-1082 | withClient for SMP queues |
|
||||
| `withLogClient_` | Client.hs:1064-1069 | withClient_ with logging |
|
||||
| `withProxySession` | Client.hs:1047-1062 | Bracket for proxied operations |
|
||||
| `sendOrProxySMPMessage` | Client.hs:1084-1094 | Decide direct vs proxy for SEND |
|
||||
| `sendOrProxySMPCommand` | Client.hs:1096-1180 | Decide direct vs proxy for commands (SKEY etc) |
|
||||
| `ipAddressProtected` | Client.hs:1181-1185 | Check if server is in protected domains |
|
||||
| `liftClient` | Client.hs:1201-1203 | Convert protocol client error |
|
||||
| `protocolClientError` | Client.hs:1205-1235 | Error conversion |
|
||||
| `waitForProtocolClient` | Client.hs:847-868 | Wait for pending client connection |
|
||||
| `newProtocolClient` | Client.hs:870-896 | Create protocol client with error handling |
|
||||
| `activeClientSession` | Client.hs:1663-1666 | Check if client session is current (compares sessionId) |
|
||||
| `removeSubscription` | Client.hs:1752-1755 | Remove single subscription from currentSubs + subscrConns |
|
||||
| `removeSubscriptions` | Client.hs:1757-1763 | Remove multiple subscriptions |
|
||||
| `hasActiveSubscription` | Client.hs:1736-1740 | Check if queue has active sub |
|
||||
| `hasPendingSubscription` | Client.hs:1742-1747 | Check if queue has pending sub |
|
||||
| `getClientConfig` | Client.hs:904-908 | Get protocol client config (slow/fast network) |
|
||||
| `getNetworkConfig` | Client.hs:910-918 | Get current network config |
|
||||
| `getFastNetworkConfig` | Client.hs:920-922 | Get fast network config |
|
||||
| `slowNetworkConfig` | Client.hs:586-592 | Derive slow config from fast |
|
||||
| `batchQueues` | Client.hs:1679-1684 | Group queues by transport session |
|
||||
| `sendTSessionBatches` | Client.hs:1674-1678 | Send batched operations per session (mapConcurrently) |
|
||||
| `sendClientBatch` | Client.hs:1686-1688 | Send batch to single client session (wrapper) |
|
||||
| `sendClientBatch_` | Client.hs:1690-1722 | Send batch to single client: get client, run action, handle errors |
|
||||
| `checkQueues` | Client.hs:1590-1595 | Filter out prohibited queues (GET lock check) |
|
||||
| `subscribeSessQueues_` | Client.hs:1611-1651 | Send SUB batch via sendClientBatch_ + process results |
|
||||
|
||||
---
|
||||
|
||||
## Piece 5: Queue operations (use session management)
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `newRcvQueue` | Client.hs:1373-1377 | Generate keys, create queue on SMP server |
|
||||
| `newRcvQueue_` | Client.hs:1394-1474 | Full queue creation: auth keys, DH, createSMPQueue, build RcvQueue record |
|
||||
| `subscribeQueues` | Client.hs:1543-1556 | Batch subscribe rcv queues grouped by transport session |
|
||||
| `subscribeQueues_` | Client.hs:1556-1720 | Subscribe batch for one session |
|
||||
| `processSubResults` | Client.hs:1476-1510 | Process subscribe results: partition into failed/subscribed/notices |
|
||||
| `addNewQueueSubscription` | Client.hs:1724-1728 | Add queue to active subs after creation |
|
||||
| `sendConfirmation` | Client.hs:1788-1794 | Per-queue E2E encrypt confirmation → SEND via sendOrProxySMPMessage |
|
||||
| `sendInvitation` | Client.hs:1796-1806 | Per-queue E2E encrypt invitation → SEND via sendOrProxySMPMessage |
|
||||
| `sendAgentMessage` | Client.hs:1948-1952 | Per-queue E2E encrypt message → SEND via sendOrProxySMPMessage |
|
||||
| `agentCbEncrypt` | Client.hs:2074-2082 | Per-queue E2E encrypt with stored DH secret |
|
||||
| `agentCbEncryptOnce` | Client.hs:2085-2095 | Per-queue E2E encrypt with ephemeral DH (for invitations) |
|
||||
| `agentCbDecrypt` | Client.hs:2099-2102 | Per-queue E2E decrypt |
|
||||
| `secureQueue` | Client.hs:1830-1833 | Send KEY command |
|
||||
| `secureSndQueue` | Client.hs:1835-1841 | Send SKEY command via sendOrProxySMPCommand |
|
||||
| `sendAck` | Client.hs:1904-1907 | Send ACK command |
|
||||
| `decryptSMPMessage` | Client.hs:1824-1828 | Decrypt received SMP message body |
|
||||
| `suspendQueue` | Client.hs:1926-1929 | Send OFF command |
|
||||
| `deleteQueue` | Client.hs:1931-1934 | Send DEL command |
|
||||
| `deleteQueues` | Client.hs:1936-1945 | Batch DEL |
|
||||
| `getQueueMessage` | Client.hs:1808-1822 | Send GET + decrypt (for polling, if needed) |
|
||||
| `notifySub` / `notifySub'` | Client.hs:791-797 | Write event to subQ |
|
||||
| `cryptoError` | Client.hs:2104-2115 | CryptoError → AgentErrorType |
|
||||
|
||||
---
|
||||
|
||||
## Piece 6: Agent API (top level)
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `joinConn` | Agent.hs:1260-1263 | Top-level join: pick server, delegate |
|
||||
| `joinConnSrv` (invitation) | Agent.hs:1342-1357 | Lock, startJoinInvitation, secureConfirmQueue |
|
||||
| `joinConnSrv` (contact) | Agent.hs:1358-1388 | Lock, create rcv queue, sendInvitation |
|
||||
| `startJoinInvitation` | Agent.hs:1270-1310 | Version check, ratchet params, snd queue, createRatchet_ |
|
||||
| `compatibleInvitationUri` | Agent.hs:1321-1328 | Version range compatibility |
|
||||
| `compatibleContactUri` | Agent.hs:1330-1336 | Version range compatibility |
|
||||
| `secureConfirmQueue` | Agent.hs:3653-3671 | Secure + send confirmation synchronously |
|
||||
| `secureConfirmQueueAsync` | Agent.hs:3645-3651 | Secure + store confirmation for async delivery |
|
||||
| `agentSecureSndQueue` | Agent.hs:3673-3684 | SKEY decision logic |
|
||||
| `mkAgentConfirmation` | Agent.hs:3686-3691 | Build AgentConnInfoReply with reply queue |
|
||||
| `storeConfirmation` | Agent.hs:3698-3712 | Ratchet encrypt + store as SndMsg |
|
||||
| `enqueueConfirmation` | Agent.hs:3693-3696 | Store + submit for delivery |
|
||||
| `createReplyQueue` | Agent.hs:1415-1424 | Create rcv queue for reply |
|
||||
| `newRcvConnSrv` | Agent.hs:1155-1220 | Full create-connection-with-rcv-queue |
|
||||
| `createRcvQueue` | Agent.hs:972-983 | Wrapper: newRcvQueue_ + updateNewConnRcv + addNewQueueSubscription |
|
||||
| `newConnToJoin` | Agent.hs:1237-1253 | Create ConnData for join, store connection |
|
||||
| `newSndQueue` | Agent.hs:3769-3802 | Build SndQueue from SMPQueueInfo |
|
||||
| `getNextSMPServer` | Agent.hs (via Client.hs) | Pick server for new queue, avoiding contact's server |
|
||||
| `connReqQueue` | Agent.hs:1265-1268 | Extract first queue from ConnectionRequestUri |
|
||||
| `versionPQSupport_` | Agent.hs:1338-1340 | PQ support based on agent+e2e versions |
|
||||
| `sendMessage'` | Agent.hs:1705-1706 | Top-level send |
|
||||
| `sendMessagesB_` | Agent.hs:1725-1760 | Get conn, prepare, delegate |
|
||||
| `enqueueMessageB` | Agent.hs:1989-2048 | Core: updateSndIds, encode, ratchetEncryptHeader, createSndMsg, createSndMsgDelivery |
|
||||
| `encodeAgentMsgStr` | Agent.hs:2050-2054 | Encode AgentMessage to bytes |
|
||||
| `agentRatchetEncrypt` | Agent.hs:3742-3746 | Ratchet encrypt message body |
|
||||
| `agentRatchetEncryptHeader` | Agent.hs:3748-3754 | Get encrypt key from ratchet |
|
||||
| `agentRatchetDecrypt` | Agent.hs:3757-3767 | Ratchet decrypt with skipped keys |
|
||||
| `submitPendingMsg` | Agent.hs:2087-2090 | Signal delivery worker |
|
||||
| `getDeliveryWorker` | Agent.hs:2079-2085 | Get-or-create delivery worker for queue |
|
||||
| `runSmpQueueMsgDelivery` | Agent.hs:2092-2270 | Delivery loop: getPendingQueueMsg, dispatch on msgType, send, handle errors |
|
||||
| `ackMessage'` | Agent.hs:2285-2323 | ACK + delete + optional receipt |
|
||||
| `ackQueueMessage` | Agent.hs:2410-2430 | Send ACK to server, handle response |
|
||||
| `subscriber` | Agent.hs:2912-2919 | Read from msgQ, dispatch |
|
||||
| `processSMPTransmissions` | Agent.hs:2997-3297 | Incoming message dispatcher |
|
||||
| `decryptClientMessage` | Agent.hs:3282-3296 | Per-queue E2E decrypt + parse envelope |
|
||||
| `agentClientMsg` | Agent.hs:3207-3225 | Ratchet decrypt, parse, store RcvMsg |
|
||||
| `smpConfirmation` | Agent.hs:3298-3370 | Process received confirmation |
|
||||
| `helloMsg` | Agent.hs:3372-3393 | Process HELLO |
|
||||
| `smpInvitation` | Agent.hs:3515-3570 | Process received invitation |
|
||||
| `allowConnection'` | Agent.hs:1427-1434 | Accept confirmation, enqueue ICAllowSecure |
|
||||
| `connectReplyQueues` | Agent.hs:3630-3643 | Process reply queues from confirmation |
|
||||
| `enqueueCommand` | Agent.hs:1764-1767 | Store command + start worker |
|
||||
| `runCommandProcessing` | Agent.hs:1789-1902 | Async command worker loop |
|
||||
| `enqueueMessage` / `enqueueMessages` | Agent.hs:~2370+ | Convenience wrappers |
|
||||
| `resumeMsgDelivery` | Agent.hs:2072-2076 | Resume delivery worker for a snd queue |
|
||||
| `resumeConnCmds` | Agent.hs:1773-1776 | Resume async command workers for connections |
|
||||
| `resumeAllCommands` | Agent.hs:1778-1781 | Resume all pending async commands on startup |
|
||||
| `enqueueSavedMessage` | Agent.hs:2056-2057 | Create delivery for additional snd queues |
|
||||
| `checkMsgIntegrity` | Agent.hs:3603-3610 | Verify message sequence integrity (local fn in processSMPTransmissions) |
|
||||
| `subscribeConnection'` | Agent.hs:1472-1474 | Subscribe single connection (delegates to subscribeConnections') |
|
||||
| `subscribeConnections'` | Agent.hs:1488-1490 | Get conn subs from store, delegate to subscribeConnections_ |
|
||||
| `subscribeConnections_` | Agent.hs:1492-1527 | Core: partition conns, resume delivery, subscribe rcv queues |
|
||||
|
||||
---
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
1. **Store field naming**: No mapping. IDB returns snake_case (`row.conn_id`), agent code uses it directly.
|
||||
2. **Error handling**: Throw custom `AgentError` exception with typed error data matching Haskell `AgentErrorType`. Catches process errors by type.
|
||||
3. **subQ**: Keep ABQueue. Agent writes events, user reads. Queues prevent deadlock — without them, a callback within message processing that triggers a send could deadlock single-threaded JS.
|
||||
4. **msgQ**: Keep ABQueue. WebSocket onmessage enqueues, subscriber loop dequeues. Prevents reentrancy.
|
||||
5. **Structured commands in IDB**: Store as JS objects. New fields optional.
|
||||
6. **Transport session key**: `(userId, server)` tuple. One WebSocket per server. No TSMEntity. All TSMEntity-specific branches dropped.
|
||||
7. **Join flows**: Both invitation and contact needed. Contact for widget's primary flow (joining address). Invitation for internal group member connections.
|
||||
8. **Async join**: `joinConnSrvAsync` / `secureConfirmQueueAsync` as primary path.
|
||||
9. **Version ranges**: Match Haskell defaults.
|
||||
10. **Config defaults**: Match Haskell defaults.
|
||||
11. **`ep/conc-msgs` branch**: Ignore. Use queues.
|
||||
12. **Connection type dispatch**: Compute from `conn_mode` field + queue presence. `conn_mode = "INV"` with rcv+snd queues = duplex, with only rcv = rcv, etc.
|
||||
13. **`withAgentEnv`**: No-op in TS — env in closure.
|
||||
14. **`getConnSubs` for subscribe**: Use `getConn` (returns conn + queues) in subscribe flow.
|
||||
15. **Client notices**: Skip in `subscribeSessQueues_`.
|
||||
|
||||
---
|
||||
|
||||
## Store methods to add
|
||||
|
||||
These store methods are not yet in `store.ts` / `store-idb.ts` but are needed by the agent layer:
|
||||
|
||||
| Method | AgentStore.hs lines | Used by |
|
||||
|--------|-------------------|---------|
|
||||
| `createSndRatchet` | 1271-1287 | `startJoinInvitation` — stores ratchet + e2e pub keys for sending side |
|
||||
| `getSndRatchet` | 1289-1300 | `startJoinInvitation` — retry path, get previously created snd ratchet |
|
||||
| `updateNewConnSnd` | 424-431 | `startJoinInvitation` — add snd queue to new connection |
|
||||
| `createSndConn` | 433-440 | May be needed for contact join flow |
|
||||
| `setRcvQueueStatus` | already exists | — |
|
||||
| `setSndQueueStatus` | already exists | — |
|
||||
| `setRcvSwitchStatus` | skip (queue switching) | — |
|
||||
| `setSndSwitchStatus` | skip (queue switching) | — |
|
||||
|
||||
---
|
||||
|
||||
## What to skip for MVP
|
||||
|
||||
| Feature | Functions to skip |
|
||||
|---------|------------------|
|
||||
| Queue switching | `switchConnection`, QADD/QKEY/QUSE/QTEST handlers, `switchDuplexConnection` |
|
||||
| Ratchet sync | `synchronizeRatchet`, EREADY handler, `newRatchetKey` |
|
||||
| Notifications | All NTF functions, `newQueueNtfSubscription` |
|
||||
| File transfer | All XFTP functions |
|
||||
| Remote control | All RC functions |
|
||||
| Connection creation | `createConnection`, `newConn`, short links creation |
|
||||
| Delivery receipts sending | `sendRcpt` in ackMessage (receiving A_RCVD is kept) |
|
||||
| Multiple rcv queues | Queue replacement logic in processSMPTransmissions |
|
||||
| Cleanup manager | `cleanupManager`, `deleteRcvMsgHashesExpired`, etc. |
|
||||
| Server management | `setProtocolServers`, `testProtocolServer` |
|
||||
| Client notices | `processClientNotices`, `subscribeClientService` |
|
||||
| Statistics | All `inc*ServerStat` calls, `getAgentServersSummary` |
|
||||
| Connection comparison | `compareConnections`, `syncConnections` |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Principle: every step produces testable output
|
||||
|
||||
Cross-language tests from Haskell give the highest confidence because they verify wire compatibility. Pure TS tests verify internal logic. The goal is to have Haskell tests at every step where the TS code touches the network.
|
||||
|
||||
### Step 1 tests: pure TS (no server)
|
||||
|
||||
File: `tests/infra-test.ts` (run with `node`, like store-test.ts)
|
||||
|
||||
- **RetryInterval**: `nextRetryDelay` returns correct values for various elapsed/delay combinations. `withRetryIntervalCount` calls action with increasing delays.
|
||||
- **TSessionSubs**: Full lifecycle: add pending → set session ID → add active (moves from pending) → disconnect (moves back to pending) → reconnect. Test `setSubsPending` mode logic (entity vs user session). Test batch operations.
|
||||
- **SessionVar**: `getSessVar` returns Left for new, Right for existing. `removeSessVar` only removes matching ID.
|
||||
|
||||
### Step 2 tests: TS with store (no server)
|
||||
|
||||
File: `tests/worker-test.ts`
|
||||
|
||||
- Create AgentClient with real IndexedDB store (fake-indexeddb).
|
||||
- Test worker lifecycle: create worker → signal work → worker runs → no work → worker waits → signal again.
|
||||
- Test server selection: configure servers → `getNextServer` rotates avoiding used hosts.
|
||||
- Test locking: `withConnLock` serializes async operations on same connId.
|
||||
|
||||
### Step 3 tests: agent-repl + Haskell (real SMP server)
|
||||
|
||||
File: `tests/agent-repl.ts` — new REPL with higher-level commands.
|
||||
|
||||
The agent-repl exposes mid-level operations that Haskell can drive:
|
||||
|
||||
```
|
||||
AGENT_INIT <serverUrl> <userId>
|
||||
→ creates AgentClient, connects to server
|
||||
|
||||
CREATE_RCV_QUEUE <connIdHex>
|
||||
→ newRcvQueue on server, returns rcvId, sndId, sndQueueUri
|
||||
→ Haskell can then SEND to this queue
|
||||
|
||||
SUBSCRIBE <connIdHex>
|
||||
→ subscribeQueues for connection's rcv queue
|
||||
|
||||
SEND_AGENT_MSG <sndQueueHex> <msgBodyHex>
|
||||
→ agentCbEncrypt + sendAgentMessage
|
||||
|
||||
RECV
|
||||
→ wait for MSG from WebSocket, decryptSMPMessage, return parsed body
|
||||
|
||||
SECURE_SND <sndQueueHex>
|
||||
→ secureSndQueue (SKEY)
|
||||
|
||||
SEND_CONFIRMATION <sndQueueHex> <confirmationHex>
|
||||
→ sendConfirmation
|
||||
|
||||
ACK <rcvIdHex> <msgIdHex>
|
||||
→ sendAck
|
||||
```
|
||||
|
||||
Haskell test scenarios:
|
||||
1. **Queue creation**: TS creates rcv queue → Haskell verifies by sending to it → TS receives
|
||||
2. **Subscribe + receive**: TS subscribes → Haskell sends MSG → TS decrypts and returns
|
||||
3. **Send**: TS sends agent message → Haskell receives and decrypts
|
||||
4. **SKEY**: TS sends SKEY → Haskell verifies queue secured
|
||||
5. **Proxy send**: TS sends via proxy → Haskell receives
|
||||
|
||||
These tests verify the entire session management + queue operations layer without needing the full agent handshake.
|
||||
|
||||
### Step 4 tests: agent-repl + Haskell (ratchet operations)
|
||||
|
||||
Extend agent-repl:
|
||||
|
||||
```
|
||||
RATCHET_ENCRYPT <connIdHex> <plaintextHex>
|
||||
→ agentRatchetEncrypt, return encrypted agent envelope
|
||||
|
||||
RATCHET_DECRYPT <connIdHex> <encryptedHex>
|
||||
→ agentRatchetDecrypt, return plaintext
|
||||
|
||||
ENQUEUE_MSG <connIdHex> <msgBodyHex>
|
||||
→ enqueueMessageB (encrypt + store + create delivery)
|
||||
|
||||
DELIVER
|
||||
→ runSmpQueueMsgDelivery one iteration (getPendingQueueMsg + send)
|
||||
```
|
||||
|
||||
Haskell test scenarios:
|
||||
1. **Ratchet encrypt cross-language**: Initialize ratchet in both → TS encrypts → Haskell decrypts (and vice versa). Already have ratchet cross-language tests, extend to agent envelope level.
|
||||
2. **Enqueue + deliver**: TS enqueues message → delivery worker sends → Haskell receives and decrypts entire agent message envelope.
|
||||
3. **Receive + store**: Haskell sends agent message → TS receives, decrypts, stores RcvMsg → verify stored correctly.
|
||||
|
||||
### Step 5 tests: full handshake (end-to-end)
|
||||
|
||||
Extend agent-repl or create dedicated test:
|
||||
|
||||
```
|
||||
JOIN <connectionRequestUri> <connInfo>
|
||||
→ full joinConnection flow
|
||||
|
||||
ALLOW <confId> <connInfo>
|
||||
→ allowConnection
|
||||
|
||||
SEND <connIdHex> <msgBody>
|
||||
→ sendMessage
|
||||
|
||||
ACK_MSG <connIdHex> <msgId>
|
||||
→ ackMessage
|
||||
```
|
||||
|
||||
Haskell test scenarios:
|
||||
1. **Join invitation**: Haskell creates invitation → TS joins → handshake completes (CONF, HELLO exchange) → messages flow both ways.
|
||||
2. **Join contact**: Haskell creates contact address → TS joins → Haskell accepts → messages flow.
|
||||
3. **Multiple connections**: TS joins two different connections on different servers simultaneously.
|
||||
4. **Reconnect**: Connection established → WebSocket drops → resubscribe → messages resume.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
All cross-language tests go in `tests/SMPWebTests.hs`, extending the existing 99 tests. Each agent-repl command is a single stdin/stdout exchange (like client-repl). Haskell `callNode` drives the TS process.
|
||||
|
||||
Estimated test count per step:
|
||||
- Step 1: ~15 TS tests (retry: 5, subs: 8, session: 2)
|
||||
- Step 2: ~8 TS tests (worker: 4, server selection: 2, locking: 2)
|
||||
- Step 3: ~8 Haskell tests (queue create, subscribe, send, receive, SKEY, proxy)
|
||||
- Step 4: ~6 Haskell tests (ratchet encrypt/decrypt, enqueue+deliver, receive+store)
|
||||
- Step 5: ~6 Haskell tests (join invitation, join contact, send/receive/ack, reconnect)
|
||||
@@ -0,0 +1,150 @@
|
||||
# SimpleX Network Protocol Specifications — Governance and Evolution (draft)
|
||||
|
||||
## Why this document exists
|
||||
|
||||
SimpleX Network protocol specifications must evolve as the network grows. This document defines how specifications change, who governs those changes, and how the history of changes is preserved.
|
||||
|
||||
### Lessons from the web: why ratcheted governance matters
|
||||
|
||||
The web's governance history demonstrates both the necessity of consortium governance and the dangers of getting the transition wrong.
|
||||
|
||||
[Tim Berners-Lee invented the web in 1991](https://home.cern/science/computing/birth-web/short-history-web). [Netscape took over in 1994](https://en.wikipedia.org/wiki/Netscape_Navigator), driving rapid innovation as a single company — SSL, cookies, JavaScript, and the features that made the web commercially viable. In 1994, [W3C was founded](https://www.w3.org/about/history/) as a consortium hosted across multiple independent institutions (MIT in the US, INRIA/ERCIM in Europe, Keio University in Japan, later Beihang University in China) to govern web standards.
|
||||
|
||||
The transition from company-led innovation to consortium governance was abrupt rather than gradual. Netscape's decline (accelerated by the [browser wars](https://en.wikipedia.org/wiki/Browser_wars) and [AOL acquisition](https://cybercultural.com/p/1999-the-fall-of-netscape-and-the-rise-of-mozilla/)) transferred control to a standards body that prioritized process over progress. The result was [a lost decade of web stagnation](https://eev.ee/blog/2020/02/01/old-css-new-css/): CSS 2.0 shipped in 1998; CSS 2.1 didn't reach Candidate Recommendation until 2004 and wasn't finalized until 2011. W3C pursued XHTML and rejected proposed enhancements to HTML, until frustrated engineers from Apple, Mozilla, and Opera formed [WHATWG in 2004](https://en.wikipedia.org/wiki/WHATWG) to build HTML5 outside W3C's process. The abrupt governance transition, without a mechanism to balance community guarantees against the imperative to continue evolving the product at pace, dramatically slowed web evolution at the time it was needed most.
|
||||
|
||||
Then in 2023, [W3C restructured from a multi-host consortium into a single 501(c)(3) nonprofit entity](https://www.w3.org/press-releases/2023/w3c-le-launched/) — W3C Inc, incorporated in the US. The previous structure distributed governance across four independent university hosts in different countries, making capture by any single entity structurally difficult. The new structure concentrates governance in a single legal entity with a board of directors. While presented as modernization, this effectively ended the decentralized consortium model that had protected web standards for nearly three decades.
|
||||
|
||||
### The governance double ratchet
|
||||
|
||||
SimpleX follows the same Netscape-to-consortium evolution path, but with two ratchets designed to prevent both failure modes — stagnation from premature governance transfer, and capture from governance centralization:
|
||||
|
||||
- **Licensing ratchet**: all contributed IP is licensed under AGPLv3 (software) and Creative Commons (documentation), perpetually and irrevocably. What is licensed cannot be unlicensed. If a Party transfers Licensed IP, the licensing obligations transfer with it.
|
||||
|
||||
- **Governance ratchet**: power can be given to the SimpleX Network Consortium, but never taken back. The Consortium Agreement requires majority decision of all Governing Parties for changes to the agreement itself, IP policy, and admission or removal of parties.
|
||||
|
||||
The ratcheted transition is historically proven to be necessary. It allows the company to continue driving rapid product innovation (as Netscape did for the web) while incrementally and irreversibly transferring governance to the consortium, without the abrupt handover that stalled web evolution or the centralization that later undermined it.
|
||||
|
||||
### Specification governance via the Consortium Agreement
|
||||
|
||||
The SimpleX Network Consortium Agreement (being deployed in 2026) establishes two levels of intellectual property governance: **Licensed IP** (all contributed protocol specifications, software, and documentation, licensed perpetually and irrevocably) and **Core IP** (the subset essential to the network, requiring consortium governance to change). The distinction between these levels and how they map to the RFC process is described in [Standard vs Core specifications](#standard-vs-core-specifications) below.
|
||||
|
||||
## Specification change process: protocol specifications and RFCs
|
||||
|
||||
Protocol knowledge lives in two places:
|
||||
|
||||
### `protocol/` — Consolidated specifications
|
||||
|
||||
Each file is a complete, self-contained description of a protocol as it exists today. Like consolidated legislation in the UK legal system: the full current law in one document, not a patchwork of amendments.
|
||||
|
||||
Consolidated specifications are maintained on every code change that affects protocol behavior. With LLMs, the cost of maintaining consolidated documents collapses — reworking prose to incorporate a new RFC is now inexpensive relative to the value of a single authoritative document per protocol.
|
||||
|
||||
Implementers read `protocol/`. They should never need to reconstruct current behavior from a base spec plus a chain of RFCs.
|
||||
|
||||
### `rfcs/` — Protocol evolution commits
|
||||
|
||||
Each RFC describes a single change to a protocol specification. RFCs are the atomic unit of protocol evolution — analogous to commits in version control, or amending acts in legislation.
|
||||
|
||||
An RFC is not part of the protocol specification. It becomes part of the specification only when embedded into the consolidated `protocol/` document. The RFC itself remains as a permanent historical record of what changed, when, and why.
|
||||
|
||||
## RFC lifecycle
|
||||
|
||||
```
|
||||
┌——> done/ ——> standard/
|
||||
draft (root) ——>──┤
|
||||
└——> rejected/
|
||||
```
|
||||
|
||||
### Draft — `rfcs/*.md`
|
||||
|
||||
A proposal for a protocol change. Not yet implemented. Active proposals live in the `rfcs/` root directory.
|
||||
|
||||
Named by proposal date: `YYYY-MM-DD-topic.md`.
|
||||
|
||||
A draft may be rejected if the proposal is considered but not accepted for implementation.
|
||||
|
||||
### Done — `rfcs/done/`
|
||||
|
||||
Implemented in code. The protocol change described by this RFC exists in the codebase, but the RFC has not yet been verified against the actual implementation (code may have diverged from the proposal during implementation).
|
||||
|
||||
### Standard — `rfcs/standard/`
|
||||
|
||||
Verified against the actual implementation and synchronized with code. The RFC accurately describes what was implemented. This is a permanent historical record — standard RFCs are never modified or removed.
|
||||
|
||||
On promotion to standard, the RFC is:
|
||||
1. Renamed from proposal date to standardization date: `YYYY-MM-DD-topic.md` (new date, same topic slug)
|
||||
2. Updated with a document history header capturing the full lifecycle
|
||||
3. Embedded into the corresponding `protocol/` consolidated specification
|
||||
|
||||
The `protocol/` document references embedded RFCs by name (e.g., "Private message routing added by RFC 2023-09-12-second-relays, standardized 2026-XX-XX"), similar to UK legislation citing the amending act for each clause.
|
||||
|
||||
Protocol version numbers make it clear which RFCs are included in which protocol revision — no separate tracking is needed.
|
||||
|
||||
### Rejected — `rfcs/rejected/`
|
||||
|
||||
Draft proposals that were considered but not accepted for implementation. Only drafts move to rejected — once an RFC is implemented (done/), it proceeds to standard/ after verification. Preserved for historical record of design decisions.
|
||||
|
||||
### Document history header
|
||||
|
||||
Every RFC in `standard/` carries a history header:
|
||||
|
||||
```
|
||||
---
|
||||
Proposed: YYYY-MM-DD
|
||||
Implemented: YYYY-MM-DD
|
||||
Standardized: YYYY-MM-DD
|
||||
Protocol: simplex-messaging v9 (or whichever protocol this amends)
|
||||
---
|
||||
```
|
||||
|
||||
## Governance
|
||||
|
||||
SimpleX Network follows the Netscape-to-W3C evolution path, with ratcheted rather than abrupt transitions:
|
||||
|
||||
| Phase | Period | Governance | Development process |
|
||||
|-------|--------|-----------|-------------------|
|
||||
| Protocol invented | 2020 | Two people | Prototype developed |
|
||||
| SimpleX Chat Ltd | 2022 | One company | Product-first: code leads, specs follow |
|
||||
| SimpleX Network Consortium | 2026 | Agreement of SimpleX Chat Ltd and non-profit entities | Product-first for standard; standards-first for core |
|
||||
| Decentralized governance | Future | TBD (DAO research ongoing) | Standards-first |
|
||||
|
||||
### Current: product-first development
|
||||
|
||||
SimpleX protocols currently follow a product-first development process: requirements drive code, code drives specification. RFCs are written as design proposals before implementation, but implementation details are figured out in code. Consolidated protocol specifications in `protocol/` are then amended to match the implementation.
|
||||
|
||||
This process is governed by SimpleX Chat Ltd as the IP Holding Party under the Consortium Agreement.
|
||||
|
||||
Any Specification Author (as defined in the Consortium Agreement) may propose RFCs. Acceptance and standardization decisions are made by SimpleX Chat Ltd during the current product-first phase.
|
||||
|
||||
### Standard vs Core specifications
|
||||
|
||||
The distinction between standard and core maps directly to the two levels of IP governance in the Consortium Agreement, and reflects the difference between product-first and standards-first development:
|
||||
|
||||
**Standard** — Licensed IP, not yet under consortium governance. Governed by the company.
|
||||
|
||||
All contributed protocol specifications are Licensed IP under the Consortium Agreement. Standard specifications follow product-first development: the company can evolve them with product needs, and they must be maintained on every code change that affects protocol behavior.
|
||||
|
||||
Standard specifications live in `rfcs/standard/` and `protocol/`.
|
||||
|
||||
**Core** — Governed IP, governed by the consortium.
|
||||
|
||||
A subset of standard specifications will be designated as Core IP under the Consortium Agreement. Core specifications will follow standards-first development: specification changes must be agreed via Governing Decision before code changes.
|
||||
|
||||
This is a legally binding commitment. Once Licensed IP is included in Core IP, the company that owns the code cannot unilaterally change it — even though they own the code, the Consortium Agreement requires a Governing Decision for any change to Core IP. This protects the fundamental properties of the network (privacy, security, decentralization) from unilateral modification by any single party.
|
||||
|
||||
The designation of specific specifications as Core IP is itself a Governing Decision that requires Consortium vote. The transition will happen incrementally as protocols stabilize — the governance ratchet ensures that each designation is irreversible.
|
||||
|
||||
The exact mechanism for distinguishing core from standard within the RFC and protocol folder structure is TBD — it will be decided as the first protocols are designated as Core IP.
|
||||
|
||||
### Future: standards-first development
|
||||
|
||||
As more protocols are designated as Core IP, development naturally transitions to a standards-first process for a growing portion of the protocol suite. The governance ratchet ensures this transition is gradual and irreversible — each protocol that becomes core gains the protection of consortium governance permanently, while remaining standard protocols continue to evolve at product pace.
|
||||
|
||||
## Current state
|
||||
|
||||
| Location | Contents | Count |
|
||||
|----------|----------|-------|
|
||||
| `protocol/` | Consolidated specs (SMP v9, Agent v5, XFTP v2, XRCP v1, Push v2, PQDR v1) | 6 specs + overview |
|
||||
| `rfcs/` root | Active draft proposals | 19 |
|
||||
| `rfcs/done/` | Implemented, not yet verified | 25 |
|
||||
| `rfcs/standard/` | Verified against implementation | (to be populated) |
|
||||
| `rfcs/rejected/` | Draft proposals not accepted | 7 |
|
||||
@@ -67,14 +67,14 @@ if [ ! -f "${confd}/smp-server.ini" ]; then
|
||||
|
||||
# Fix path to certificates
|
||||
if [ -n "${WEB_MANUAL}" ]; then
|
||||
sed -i -e 's|^[^#]*https: |#&|' \
|
||||
-e 's|^[^#]*cert: |#&|' \
|
||||
-e 's|^[^#]*key: |#&|' \
|
||||
-e 's|^port:.*|port: 5223|' \
|
||||
sed -i -e 's|^[^#]*https = |#&|' \
|
||||
-e 's|^[^#]*cert = |#&|' \
|
||||
-e 's|^[^#]*key = |#&|' \
|
||||
-e 's|^port = .*|port = 5223|' \
|
||||
"${confd}/smp-server.ini"
|
||||
else
|
||||
sed -i -e "s|cert: /etc/opt/simplex/web.crt|cert: $cert_path/$ADDR.crt|" \
|
||||
-e "s|key: /etc/opt/simplex/web.key|key: $cert_path/$ADDR.key|" \
|
||||
sed -i -e "s|cert = /etc/opt/simplex/web.crt|cert = $cert_path/$ADDR.crt|" \
|
||||
-e "s|key = /etc/opt/simplex/web.key|key = $cert_path/$ADDR.key|" \
|
||||
"${confd}/smp-server.ini"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -76,7 +76,7 @@ if [ ! -f "${confd}/file-server.ini" ]; then
|
||||
|
||||
# Optionally, set password
|
||||
if [ -n "${PASS}" ]; then
|
||||
sed -i -e "/^# create_password:/a create_password: $PASS" \
|
||||
sed -i -e "/^# create_password =/a create_password = $PASS" \
|
||||
"${confd}/file-server.ini"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
cabal-version: 1.12
|
||||
|
||||
name: simplexmq
|
||||
version: 6.5.0.3
|
||||
version: 6.5.1.0
|
||||
synopsis: SimpleXMQ message broker
|
||||
description: This package includes <./docs/Simplex-Messaging-Server.html server>,
|
||||
<./docs/Simplex-Messaging-Client.html client> and
|
||||
@@ -24,33 +24,38 @@ extra-source-files:
|
||||
CHANGELOG.md
|
||||
cbits/sha512.h
|
||||
cbits/sntrup761.h
|
||||
apps/smp-server/static/index.html
|
||||
apps/smp-server/static/link.html
|
||||
apps/smp-server/static/media/apk_icon.png
|
||||
apps/smp-server/static/media/apple_store.svg
|
||||
apps/smp-server/static/media/contact.js
|
||||
apps/smp-server/static/media/contact_page_mobile.png
|
||||
apps/smp-server/static/media/f_droid.svg
|
||||
apps/smp-server/static/media/favicon.ico
|
||||
apps/smp-server/static/media/GilroyBold.woff2
|
||||
apps/smp-server/static/media/GilroyLight.woff2
|
||||
apps/smp-server/static/media/GilroyMedium.woff2
|
||||
apps/smp-server/static/media/GilroyRegular.woff2
|
||||
apps/smp-server/static/media/GilroyRegularItalic.woff2
|
||||
apps/smp-server/static/media/google_play.svg
|
||||
apps/smp-server/static/media/logo-dark.png
|
||||
apps/smp-server/static/media/logo-light.png
|
||||
apps/smp-server/static/media/logo-symbol-dark.svg
|
||||
apps/smp-server/static/media/logo-symbol-light.svg
|
||||
apps/smp-server/static/media/moon.svg
|
||||
apps/smp-server/static/media/qrcode.js
|
||||
apps/smp-server/static/media/script.js
|
||||
apps/smp-server/static/media/style.css
|
||||
apps/smp-server/static/media/sun.svg
|
||||
apps/smp-server/static/media/swiper-bundle.min.css
|
||||
apps/smp-server/static/media/swiper-bundle.min.js
|
||||
apps/smp-server/static/media/tailwind.css
|
||||
apps/smp-server/static/media/testflight.png
|
||||
apps/common/Web/static/index.html
|
||||
apps/common/Web/static/link.html
|
||||
apps/common/Web/static/media/apk_icon.png
|
||||
apps/common/Web/static/media/apple_store.svg
|
||||
apps/common/Web/static/media/contact.js
|
||||
apps/common/Web/static/media/contact_page_mobile.png
|
||||
apps/common/Web/static/media/f_droid.svg
|
||||
apps/common/Web/static/media/favicon.ico
|
||||
apps/common/Web/static/media/GilroyBold.woff2
|
||||
apps/common/Web/static/media/GilroyLight.woff2
|
||||
apps/common/Web/static/media/GilroyMedium.woff2
|
||||
apps/common/Web/static/media/GilroyRegular.woff2
|
||||
apps/common/Web/static/media/GilroyRegularItalic.woff2
|
||||
apps/common/Web/static/media/google_play.svg
|
||||
apps/common/Web/static/media/logo-dark.png
|
||||
apps/common/Web/static/media/logo-light.png
|
||||
apps/common/Web/static/media/logo-symbol-dark.svg
|
||||
apps/common/Web/static/media/logo-symbol-light.svg
|
||||
apps/common/Web/static/media/moon.svg
|
||||
apps/common/Web/static/media/qrcode.js
|
||||
apps/common/Web/static/media/script.js
|
||||
apps/common/Web/static/media/style.css
|
||||
apps/common/Web/static/media/sun.svg
|
||||
apps/common/Web/static/media/swiper-bundle.min.css
|
||||
apps/common/Web/static/media/swiper-bundle.min.js
|
||||
apps/common/Web/static/media/tailwind.css
|
||||
apps/common/Web/static/media/testflight.png
|
||||
apps/xftp-server/static/media/xftp-protocol.svg
|
||||
apps/xftp-server/static/media/xftp-protocol-dark.svg
|
||||
apps/xftp-server/static/xftp-web-bundle/crypto.worker.js
|
||||
apps/xftp-server/static/xftp-web-bundle/index.css
|
||||
apps/xftp-server/static/xftp-web-bundle/index.js
|
||||
|
||||
flag swift
|
||||
description: Enable swift JSON format
|
||||
@@ -167,6 +172,8 @@ library
|
||||
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20250702_conn_invitations_remove_cascade_delete
|
||||
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251009_queue_to_subscribe
|
||||
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251010_client_notices
|
||||
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251230_strict_tables
|
||||
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20260410_receive_attempts
|
||||
else
|
||||
exposed-modules:
|
||||
Simplex.Messaging.Agent.Store.SQLite
|
||||
@@ -216,6 +223,9 @@ library
|
||||
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20250702_conn_invitations_remove_cascade_delete
|
||||
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251009_queue_to_subscribe
|
||||
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251010_client_notices
|
||||
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251230_strict_tables
|
||||
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20260410_receive_attempts
|
||||
Simplex.Messaging.Agent.Store.SQLite.Util
|
||||
if flag(client_postgres) || flag(server_postgres)
|
||||
exposed-modules:
|
||||
Simplex.Messaging.Agent.Store.Postgres
|
||||
@@ -242,6 +252,7 @@ library
|
||||
Simplex.Messaging.Server.Main
|
||||
Simplex.Messaging.Server.Main.GitCommit
|
||||
Simplex.Messaging.Server.Main.Init
|
||||
Simplex.Messaging.Server.Web
|
||||
Simplex.Messaging.Server.MsgStore
|
||||
Simplex.Messaging.Server.MsgStore.Journal
|
||||
Simplex.Messaging.Server.MsgStore.Journal.SharedLock
|
||||
@@ -272,6 +283,9 @@ library
|
||||
Simplex.Messaging.Notifications.Server.Store.Postgres
|
||||
Simplex.Messaging.Notifications.Server.Store.Types
|
||||
Simplex.Messaging.Notifications.Server.StoreLog
|
||||
Simplex.FileTransfer.Server.Store.Postgres
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Config
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Migrations
|
||||
Simplex.Messaging.Server.MsgStore.Postgres
|
||||
Simplex.Messaging.Server.QueueStore.Postgres
|
||||
Simplex.Messaging.Server.QueueStore.Postgres.Migrations
|
||||
@@ -343,7 +357,13 @@ library
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
, temporary ==1.3.*
|
||||
, wai >=3.2 && <3.3
|
||||
, wai-app-static >=3.1 && <3.2
|
||||
, wai-websockets >=3.0.1 && <3.1
|
||||
, warp ==3.3.30
|
||||
, warp-tls ==3.4.7
|
||||
, websockets ==0.12.*
|
||||
, zlib >=0.6 && <0.8
|
||||
if flag(client_postgres) || flag(server_postgres)
|
||||
build-depends:
|
||||
postgresql-libpq >=0.10.0.0
|
||||
@@ -398,30 +418,22 @@ executable smp-server
|
||||
cpp-options: -DdbServerPostgres
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
Static
|
||||
Static.Embedded
|
||||
SMPWeb
|
||||
Web.Embedded
|
||||
Paths_simplexmq
|
||||
hs-source-dirs:
|
||||
apps/smp-server
|
||||
apps/smp-server/web
|
||||
apps/common
|
||||
default-extensions:
|
||||
StrictData
|
||||
ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts
|
||||
build-depends:
|
||||
base
|
||||
, bytestring
|
||||
, directory
|
||||
, file-embed
|
||||
, filepath
|
||||
, network
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, simple-logger
|
||||
, simplexmq
|
||||
, text
|
||||
, unliftio
|
||||
, wai
|
||||
, wai-app-static
|
||||
, warp ==3.3.30
|
||||
, warp-tls ==3.4.7
|
||||
default-language: Haskell2010
|
||||
|
||||
executable xftp
|
||||
@@ -445,14 +457,21 @@ executable xftp-server
|
||||
buildable: False
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
XFTPWeb
|
||||
Web.Embedded
|
||||
Paths_simplexmq
|
||||
hs-source-dirs:
|
||||
apps/xftp-server
|
||||
apps/common
|
||||
default-extensions:
|
||||
StrictData
|
||||
ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts
|
||||
build-depends:
|
||||
base
|
||||
, bytestring
|
||||
, directory
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, filepath
|
||||
, simple-logger
|
||||
, simplexmq
|
||||
default-language: Haskell2010
|
||||
@@ -494,8 +513,12 @@ test-suite simplexmq-test
|
||||
XFTPCLI
|
||||
XFTPClient
|
||||
XFTPServerTests
|
||||
Static
|
||||
Static.Embedded
|
||||
WebTests
|
||||
XFTPWebTests
|
||||
SMPWebTests
|
||||
SMPWeb
|
||||
XFTPWeb
|
||||
Web.Embedded
|
||||
Paths_simplexmq
|
||||
if flag(client_postgres)
|
||||
other-modules:
|
||||
@@ -507,12 +530,15 @@ test-suite simplexmq-test
|
||||
if flag(server_postgres)
|
||||
other-modules:
|
||||
AgentTests.NotificationTests
|
||||
CoreTests.XFTPStoreTests
|
||||
NtfClient
|
||||
NtfServerTests
|
||||
PostgresSchemaDump
|
||||
hs-source-dirs:
|
||||
tests
|
||||
apps/smp-server/web
|
||||
apps/smp-server
|
||||
apps/xftp-server
|
||||
apps/common
|
||||
default-extensions:
|
||||
StrictData
|
||||
-- add -fhpc to ghc-options to run tests with coverage
|
||||
@@ -523,13 +549,14 @@ test-suite simplexmq-test
|
||||
, async
|
||||
, base64-bytestring
|
||||
, bytestring
|
||||
, case-insensitive ==1.2.*
|
||||
, containers
|
||||
, crypton
|
||||
, crypton-x509
|
||||
, crypton-x509-store
|
||||
, crypton-x509-validation
|
||||
, directory
|
||||
, file-embed
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, filepath
|
||||
, generic-random ==1.5.*
|
||||
, hashable
|
||||
@@ -542,6 +569,7 @@ test-suite simplexmq-test
|
||||
, ini
|
||||
, iso8601-time
|
||||
, main-tester ==0.2.*
|
||||
, memory
|
||||
, mtl
|
||||
, network
|
||||
, QuickCheck ==2.14.*
|
||||
@@ -557,10 +585,6 @@ test-suite simplexmq-test
|
||||
, unliftio
|
||||
, unliftio-core
|
||||
, unordered-containers
|
||||
, wai
|
||||
, wai-app-static
|
||||
, warp
|
||||
, warp-tls
|
||||
, yaml
|
||||
default-language: Haskell2010
|
||||
if flag(server_postgres)
|
||||
@@ -569,8 +593,7 @@ test-suite simplexmq-test
|
||||
cpp-options: -DdbPostgres
|
||||
else
|
||||
build-depends:
|
||||
memory
|
||||
, sqlcipher-simple
|
||||
sqlcipher-simple
|
||||
if !flag(client_postgres) || flag(client_postgres) || flag(server_postgres)
|
||||
build-depends:
|
||||
deepseq ==1.4.*
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-test/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,14 @@
|
||||
addToLibrary({
|
||||
js_random_bytes: function(buf, len) {
|
||||
var bytes = new Uint8Array(len);
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
// Node.js fallback
|
||||
var nodeCrypto = require('crypto');
|
||||
var nodeBytes = nodeCrypto.randomBytes(len);
|
||||
bytes.set(nodeBytes);
|
||||
}
|
||||
HEAPU8.set(bytes, buf);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
20080913
|
||||
D. J. Bernstein
|
||||
Public domain.
|
||||
|
||||
SHA-512 implementation from SUPERCOP/NaCl.
|
||||
Source: https://bench.cr.yp.to/supercop.html
|
||||
crypto_hashblocks/sha512/ref/blocks.c
|
||||
crypto_hash/sha512/ref/hash.c
|
||||
|
||||
Combined into a single file for WASM compilation alongside sntrup761.
|
||||
*/
|
||||
|
||||
#include "sha512.h"
|
||||
|
||||
typedef unsigned long long uint64;
|
||||
|
||||
/* -- crypto_hashblocks_sha512 (blocks.c) -- */
|
||||
|
||||
static uint64 load_bigendian(const unsigned char *x)
|
||||
{
|
||||
return
|
||||
(uint64) (x[7]) \
|
||||
| (((uint64) (x[6])) << 8) \
|
||||
| (((uint64) (x[5])) << 16) \
|
||||
| (((uint64) (x[4])) << 24) \
|
||||
| (((uint64) (x[3])) << 32) \
|
||||
| (((uint64) (x[2])) << 40) \
|
||||
| (((uint64) (x[1])) << 48) \
|
||||
| (((uint64) (x[0])) << 56)
|
||||
;
|
||||
}
|
||||
|
||||
static void store_bigendian(unsigned char *x,uint64 u)
|
||||
{
|
||||
x[7] = u; u >>= 8;
|
||||
x[6] = u; u >>= 8;
|
||||
x[5] = u; u >>= 8;
|
||||
x[4] = u; u >>= 8;
|
||||
x[3] = u; u >>= 8;
|
||||
x[2] = u; u >>= 8;
|
||||
x[1] = u; u >>= 8;
|
||||
x[0] = u;
|
||||
}
|
||||
|
||||
#define SHR(x,c) ((x) >> (c))
|
||||
#define ROTR(x,c) (((x) >> (c)) | ((x) << (64 - (c))))
|
||||
|
||||
#define Ch(x,y,z) ((x & y) ^ (~x & z))
|
||||
#define Maj(x,y,z) ((x & y) ^ (x & z) ^ (y & z))
|
||||
#define Sigma0(x) (ROTR(x,28) ^ ROTR(x,34) ^ ROTR(x,39))
|
||||
#define Sigma1(x) (ROTR(x,14) ^ ROTR(x,18) ^ ROTR(x,41))
|
||||
#define sigma0(x) (ROTR(x, 1) ^ ROTR(x, 8) ^ SHR(x,7))
|
||||
#define sigma1(x) (ROTR(x,19) ^ ROTR(x,61) ^ SHR(x,6))
|
||||
|
||||
#define M(w0,w14,w9,w1) w0 = sigma1(w14) + w9 + sigma0(w1) + w0;
|
||||
|
||||
#define EXPAND \
|
||||
M(w0 ,w14,w9 ,w1 ) \
|
||||
M(w1 ,w15,w10,w2 ) \
|
||||
M(w2 ,w0 ,w11,w3 ) \
|
||||
M(w3 ,w1 ,w12,w4 ) \
|
||||
M(w4 ,w2 ,w13,w5 ) \
|
||||
M(w5 ,w3 ,w14,w6 ) \
|
||||
M(w6 ,w4 ,w15,w7 ) \
|
||||
M(w7 ,w5 ,w0 ,w8 ) \
|
||||
M(w8 ,w6 ,w1 ,w9 ) \
|
||||
M(w9 ,w7 ,w2 ,w10) \
|
||||
M(w10,w8 ,w3 ,w11) \
|
||||
M(w11,w9 ,w4 ,w12) \
|
||||
M(w12,w10,w5 ,w13) \
|
||||
M(w13,w11,w6 ,w14) \
|
||||
M(w14,w12,w7 ,w15) \
|
||||
M(w15,w13,w8 ,w0 )
|
||||
|
||||
#define F(w,k) \
|
||||
T1 = h + Sigma1(e) + Ch(e,f,g) + k + w; \
|
||||
T2 = Sigma0(a) + Maj(a,b,c); \
|
||||
h = g; \
|
||||
g = f; \
|
||||
f = e; \
|
||||
e = d + T1; \
|
||||
d = c; \
|
||||
c = b; \
|
||||
b = a; \
|
||||
a = T1 + T2;
|
||||
|
||||
static int crypto_hashblocks_sha512(unsigned char *statebytes,const unsigned char *in,unsigned long long inlen)
|
||||
{
|
||||
uint64 state[8];
|
||||
uint64 a;
|
||||
uint64 b;
|
||||
uint64 c;
|
||||
uint64 d;
|
||||
uint64 e;
|
||||
uint64 f;
|
||||
uint64 g;
|
||||
uint64 h;
|
||||
uint64 T1;
|
||||
uint64 T2;
|
||||
|
||||
a = load_bigendian(statebytes + 0); state[0] = a;
|
||||
b = load_bigendian(statebytes + 8); state[1] = b;
|
||||
c = load_bigendian(statebytes + 16); state[2] = c;
|
||||
d = load_bigendian(statebytes + 24); state[3] = d;
|
||||
e = load_bigendian(statebytes + 32); state[4] = e;
|
||||
f = load_bigendian(statebytes + 40); state[5] = f;
|
||||
g = load_bigendian(statebytes + 48); state[6] = g;
|
||||
h = load_bigendian(statebytes + 56); state[7] = h;
|
||||
|
||||
while (inlen >= 128) {
|
||||
uint64 w0 = load_bigendian(in + 0);
|
||||
uint64 w1 = load_bigendian(in + 8);
|
||||
uint64 w2 = load_bigendian(in + 16);
|
||||
uint64 w3 = load_bigendian(in + 24);
|
||||
uint64 w4 = load_bigendian(in + 32);
|
||||
uint64 w5 = load_bigendian(in + 40);
|
||||
uint64 w6 = load_bigendian(in + 48);
|
||||
uint64 w7 = load_bigendian(in + 56);
|
||||
uint64 w8 = load_bigendian(in + 64);
|
||||
uint64 w9 = load_bigendian(in + 72);
|
||||
uint64 w10 = load_bigendian(in + 80);
|
||||
uint64 w11 = load_bigendian(in + 88);
|
||||
uint64 w12 = load_bigendian(in + 96);
|
||||
uint64 w13 = load_bigendian(in + 104);
|
||||
uint64 w14 = load_bigendian(in + 112);
|
||||
uint64 w15 = load_bigendian(in + 120);
|
||||
|
||||
F(w0 ,0x428a2f98d728ae22ULL)
|
||||
F(w1 ,0x7137449123ef65cdULL)
|
||||
F(w2 ,0xb5c0fbcfec4d3b2fULL)
|
||||
F(w3 ,0xe9b5dba58189dbbcULL)
|
||||
F(w4 ,0x3956c25bf348b538ULL)
|
||||
F(w5 ,0x59f111f1b605d019ULL)
|
||||
F(w6 ,0x923f82a4af194f9bULL)
|
||||
F(w7 ,0xab1c5ed5da6d8118ULL)
|
||||
F(w8 ,0xd807aa98a3030242ULL)
|
||||
F(w9 ,0x12835b0145706fbeULL)
|
||||
F(w10,0x243185be4ee4b28cULL)
|
||||
F(w11,0x550c7dc3d5ffb4e2ULL)
|
||||
F(w12,0x72be5d74f27b896fULL)
|
||||
F(w13,0x80deb1fe3b1696b1ULL)
|
||||
F(w14,0x9bdc06a725c71235ULL)
|
||||
F(w15,0xc19bf174cf692694ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0xe49b69c19ef14ad2ULL)
|
||||
F(w1 ,0xefbe4786384f25e3ULL)
|
||||
F(w2 ,0x0fc19dc68b8cd5b5ULL)
|
||||
F(w3 ,0x240ca1cc77ac9c65ULL)
|
||||
F(w4 ,0x2de92c6f592b0275ULL)
|
||||
F(w5 ,0x4a7484aa6ea6e483ULL)
|
||||
F(w6 ,0x5cb0a9dcbd41fbd4ULL)
|
||||
F(w7 ,0x76f988da831153b5ULL)
|
||||
F(w8 ,0x983e5152ee66dfabULL)
|
||||
F(w9 ,0xa831c66d2db43210ULL)
|
||||
F(w10,0xb00327c898fb213fULL)
|
||||
F(w11,0xbf597fc7beef0ee4ULL)
|
||||
F(w12,0xc6e00bf33da88fc2ULL)
|
||||
F(w13,0xd5a79147930aa725ULL)
|
||||
F(w14,0x06ca6351e003826fULL)
|
||||
F(w15,0x142929670a0e6e70ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0x27b70a8546d22ffcULL)
|
||||
F(w1 ,0x2e1b21385c26c926ULL)
|
||||
F(w2 ,0x4d2c6dfc5ac42aedULL)
|
||||
F(w3 ,0x53380d139d95b3dfULL)
|
||||
F(w4 ,0x650a73548baf63deULL)
|
||||
F(w5 ,0x766a0abb3c77b2a8ULL)
|
||||
F(w6 ,0x81c2c92e47edaee6ULL)
|
||||
F(w7 ,0x92722c851482353bULL)
|
||||
F(w8 ,0xa2bfe8a14cf10364ULL)
|
||||
F(w9 ,0xa81a664bbc423001ULL)
|
||||
F(w10,0xc24b8b70d0f89791ULL)
|
||||
F(w11,0xc76c51a30654be30ULL)
|
||||
F(w12,0xd192e819d6ef5218ULL)
|
||||
F(w13,0xd69906245565a910ULL)
|
||||
F(w14,0xf40e35855771202aULL)
|
||||
F(w15,0x106aa07032bbd1b8ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0x19a4c116b8d2d0c8ULL)
|
||||
F(w1 ,0x1e376c085141ab53ULL)
|
||||
F(w2 ,0x2748774cdf8eeb99ULL)
|
||||
F(w3 ,0x34b0bcb5e19b48a8ULL)
|
||||
F(w4 ,0x391c0cb3c5c95a63ULL)
|
||||
F(w5 ,0x4ed8aa4ae3418acbULL)
|
||||
F(w6 ,0x5b9cca4f7763e373ULL)
|
||||
F(w7 ,0x682e6ff3d6b2b8a3ULL)
|
||||
F(w8 ,0x748f82ee5defb2fcULL)
|
||||
F(w9 ,0x78a5636f43172f60ULL)
|
||||
F(w10,0x84c87814a1f0ab72ULL)
|
||||
F(w11,0x8cc702081a6439ecULL)
|
||||
F(w12,0x90befffa23631e28ULL)
|
||||
F(w13,0xa4506cebde82bde9ULL)
|
||||
F(w14,0xbef9a3f7b2c67915ULL)
|
||||
F(w15,0xc67178f2e372532bULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0xca273eceea26619cULL)
|
||||
F(w1 ,0xd186b8c721c0c207ULL)
|
||||
F(w2 ,0xeada7dd6cde0eb1eULL)
|
||||
F(w3 ,0xf57d4f7fee6ed178ULL)
|
||||
F(w4 ,0x06f067aa72176fbaULL)
|
||||
F(w5 ,0x0a637dc5a2c898a6ULL)
|
||||
F(w6 ,0x113f9804bef90daeULL)
|
||||
F(w7 ,0x1b710b35131c471bULL)
|
||||
F(w8 ,0x28db77f523047d84ULL)
|
||||
F(w9 ,0x32caab7b40c72493ULL)
|
||||
F(w10,0x3c9ebe0a15c9bebcULL)
|
||||
F(w11,0x431d67c49c100d4cULL)
|
||||
F(w12,0x4cc5d4becb3e42b6ULL)
|
||||
F(w13,0x597f299cfc657e2aULL)
|
||||
F(w14,0x5fcb6fab3ad6faecULL)
|
||||
F(w15,0x6c44198c4a475817ULL)
|
||||
|
||||
a += state[0];
|
||||
b += state[1];
|
||||
c += state[2];
|
||||
d += state[3];
|
||||
e += state[4];
|
||||
f += state[5];
|
||||
g += state[6];
|
||||
h += state[7];
|
||||
|
||||
state[0] = a;
|
||||
state[1] = b;
|
||||
state[2] = c;
|
||||
state[3] = d;
|
||||
state[4] = e;
|
||||
state[5] = f;
|
||||
state[6] = g;
|
||||
state[7] = h;
|
||||
|
||||
in += 128;
|
||||
inlen -= 128;
|
||||
}
|
||||
|
||||
store_bigendian(statebytes + 0,state[0]);
|
||||
store_bigendian(statebytes + 8,state[1]);
|
||||
store_bigendian(statebytes + 16,state[2]);
|
||||
store_bigendian(statebytes + 24,state[3]);
|
||||
store_bigendian(statebytes + 32,state[4]);
|
||||
store_bigendian(statebytes + 40,state[5]);
|
||||
store_bigendian(statebytes + 48,state[6]);
|
||||
store_bigendian(statebytes + 56,state[7]);
|
||||
|
||||
return inlen;
|
||||
}
|
||||
|
||||
/* -- crypto_hash_sha512 (hash.c) -- */
|
||||
|
||||
static const unsigned char iv[64] = {
|
||||
0x6a,0x09,0xe6,0x67,0xf3,0xbc,0xc9,0x08,
|
||||
0xbb,0x67,0xae,0x85,0x84,0xca,0xa7,0x3b,
|
||||
0x3c,0x6e,0xf3,0x72,0xfe,0x94,0xf8,0x2b,
|
||||
0xa5,0x4f,0xf5,0x3a,0x5f,0x1d,0x36,0xf1,
|
||||
0x51,0x0e,0x52,0x7f,0xad,0xe6,0x82,0xd1,
|
||||
0x9b,0x05,0x68,0x8c,0x2b,0x3e,0x6c,0x1f,
|
||||
0x1f,0x83,0xd9,0xab,0xfb,0x41,0xbd,0x6b,
|
||||
0x5b,0xe0,0xcd,0x19,0x13,0x7e,0x21,0x79
|
||||
};
|
||||
|
||||
void crypto_hash_sha512(unsigned char *out,
|
||||
const unsigned char *in,
|
||||
unsigned long long inlen)
|
||||
{
|
||||
unsigned char h[64];
|
||||
unsigned char padded[256];
|
||||
int i;
|
||||
unsigned long long bytes = inlen;
|
||||
|
||||
for (i = 0;i < 64;++i) h[i] = iv[i];
|
||||
|
||||
crypto_hashblocks_sha512(h,in,inlen);
|
||||
in += inlen;
|
||||
inlen &= 127;
|
||||
in -= inlen;
|
||||
|
||||
for (i = 0;i < (int)inlen;++i) padded[i] = in[i];
|
||||
padded[inlen] = 0x80;
|
||||
|
||||
if (inlen < 112) {
|
||||
for (i = inlen + 1;i < 119;++i) padded[i] = 0;
|
||||
padded[119] = bytes >> 61;
|
||||
padded[120] = bytes >> 53;
|
||||
padded[121] = bytes >> 45;
|
||||
padded[122] = bytes >> 37;
|
||||
padded[123] = bytes >> 29;
|
||||
padded[124] = bytes >> 21;
|
||||
padded[125] = bytes >> 13;
|
||||
padded[126] = bytes >> 5;
|
||||
padded[127] = bytes << 3;
|
||||
crypto_hashblocks_sha512(h,padded,128);
|
||||
} else {
|
||||
for (i = inlen + 1;i < 247;++i) padded[i] = 0;
|
||||
padded[247] = bytes >> 61;
|
||||
padded[248] = bytes >> 53;
|
||||
padded[249] = bytes >> 45;
|
||||
padded[250] = bytes >> 37;
|
||||
padded[251] = bytes >> 29;
|
||||
padded[252] = bytes >> 21;
|
||||
padded[253] = bytes >> 13;
|
||||
padded[254] = bytes >> 5;
|
||||
padded[255] = bytes << 3;
|
||||
crypto_hashblocks_sha512(h,padded,256);
|
||||
}
|
||||
|
||||
for (i = 0;i < 64;++i) out[i] = h[i];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface Sntrup761Module {
|
||||
_sntrup761_wasm_keypair(pk: number, sk: number): void
|
||||
_sntrup761_wasm_enc(c: number, k: number, pk: number): void
|
||||
_sntrup761_wasm_dec(k: number, c: number, sk: number): void
|
||||
_malloc(size: number): number
|
||||
_free(ptr: number): void
|
||||
HEAPU8: Uint8Array
|
||||
}
|
||||
|
||||
declare function createSntrup761(): Promise<Sntrup761Module>
|
||||
export default createSntrup761
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* WASM wrapper for sntrup761.
|
||||
* Provides JS-callable functions with RNG from JS imports.
|
||||
*
|
||||
* Build: emcc sntrup761_wasm.c sntrup761.c sha512.c -O2 -o sntrup761.js \
|
||||
* -s EXPORTED_FUNCTIONS='["_sntrup761_wasm_keypair","_sntrup761_wasm_enc","_sntrup761_wasm_dec","_malloc","_free"]' \
|
||||
* -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
|
||||
*/
|
||||
|
||||
#include "sntrup761.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Import RNG from JS environment */
|
||||
extern void js_random_bytes(unsigned char *buf, int len);
|
||||
|
||||
/* RNG callback adapter for sntrup761 */
|
||||
static void wasm_random(void *ctx, size_t length, uint8_t *dst) {
|
||||
(void)ctx;
|
||||
js_random_bytes(dst, (int)length);
|
||||
}
|
||||
|
||||
/* JS-callable wrappers */
|
||||
|
||||
void sntrup761_wasm_keypair(unsigned char *pk, unsigned char *sk) {
|
||||
sntrup761_keypair(pk, sk, NULL, wasm_random);
|
||||
}
|
||||
|
||||
void sntrup761_wasm_enc(unsigned char *c, unsigned char *k, const unsigned char *pk) {
|
||||
sntrup761_enc(c, k, pk, NULL, wasm_random);
|
||||
}
|
||||
|
||||
void sntrup761_wasm_dec(unsigned char *k, const unsigned char *c, const unsigned char *sk) {
|
||||
sntrup761_dec(k, c, sk);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@simplex-chat/smp-web",
|
||||
"version": "0.1.0",
|
||||
"description": "SMP protocol client for web/browser environments",
|
||||
"license": "AGPL-3.0-only",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/simplex-chat/simplexmq.git",
|
||||
"directory": "smp-web"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build:wasm": "mkdir -p dist/wasm && npx emcc cbits/sntrup761_wasm.c ../cbits/sntrup761.c cbits/sha512.c -I../cbits -O2 -o dist/wasm/sntrup761.mjs -s EXPORTED_FUNCTIONS='[\"_sntrup761_wasm_keypair\",\"_sntrup761_wasm_enc\",\"_sntrup761_wasm_dec\",\"_malloc\",\"_free\"]' -s EXPORTED_RUNTIME_METHODS='[\"ccall\",\"cwrap\",\"HEAPU8\"]' -s MODULARIZE=1 -s EXPORT_NAME='createSntrup761' -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT='web,node' --js-library cbits/js_random.js && cp cbits/sntrup761.d.mts dist/wasm/",
|
||||
"build:ts": "tsc",
|
||||
"build:test": "tsc -p tsconfig.test.json",
|
||||
"build": "npm run build:wasm && npm run build:ts && npm run build:test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@simplex-chat/xftp-web": "file:../xftp-web",
|
||||
"emsdk": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"typescript": "^5.4.0",
|
||||
"ws": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// AgentClient — agent state, worker infrastructure, locking, server selection.
|
||||
// Transpilation of Agent/Client.hs (AgentClient record, workers, operation state, locks, server selection).
|
||||
// Session management and queue operations will be added in subsequent steps.
|
||||
|
||||
import {ABQueue} from "./queue.js"
|
||||
import {TMVar} from "./tmvar.js"
|
||||
import {Sem} from "./queue.js"
|
||||
import {TSessionSubs} from "./subscriptions.js"
|
||||
import type {AgentStore} from "./store.js"
|
||||
import type {RetryInterval2} from "./retry.js"
|
||||
|
||||
// -- Error types (Client.hs:2296-2310 storeError, Client.hs:2104-2115 cryptoError)
|
||||
|
||||
export class AgentError extends Error {
|
||||
constructor(public readonly type: AgentErrorType) {
|
||||
super(agentErrorToString(type))
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentErrorType =
|
||||
| {tag: "AGENT", err: AgentErr}
|
||||
| {tag: "BROKER", addr: string, err: BrokerErr}
|
||||
| {tag: "SMP", addr: string, err: string}
|
||||
| {tag: "PROXY", proxyServer: string, relayServer: string, proxyErr: string}
|
||||
| {tag: "CONN", err: ConnErr, context: string}
|
||||
| {tag: "CMD", err: CmdErr, context: string}
|
||||
| {tag: "INTERNAL", msg: string}
|
||||
| {tag: "CRITICAL", important: boolean, msg: string}
|
||||
| {tag: "INACTIVE"}
|
||||
| {tag: "NO_USER"}
|
||||
|
||||
export type AgentErr =
|
||||
| "A_VERSION" | "A_ENCRYPTION" | "A_DUPLICATE" | "A_PROHIBITED" | "A_MESSAGE"
|
||||
| {tag: "A_QUEUE", msg: string}
|
||||
| {tag: "A_CRYPTO", err: string}
|
||||
|
||||
export type BrokerErr = "TIMEOUT" | "NETWORK" | "HOST" | "TRANSPORT" | {tag: "RESPONSE", err: string} | {tag: "UNEXPECTED", msg: string}
|
||||
|
||||
export type ConnErr = "NOT_FOUND" | "DUPLICATE" | "SIMPLEX" | "NOT_ACCEPTED" | "NOT_AVAILABLE"
|
||||
|
||||
export type CmdErr = "PROHIBITED" | "SYNTAX" | "NO_CONN" | {tag: "LARGE", msg: string}
|
||||
|
||||
function agentErrorToString(e: AgentErrorType): string {
|
||||
switch (e.tag) {
|
||||
case "INTERNAL": return `INTERNAL: ${e.msg}`
|
||||
case "CRITICAL": return `CRITICAL: ${e.msg}`
|
||||
case "AGENT": return `AGENT ${typeof e.err === "string" ? e.err : e.err.tag}`
|
||||
case "BROKER": return `BROKER ${e.addr} ${typeof e.err === "string" ? e.err : e.err.tag}`
|
||||
case "SMP": return `SMP ${e.addr} ${e.err}`
|
||||
case "CONN": return `CONN ${e.err} ${e.context}`
|
||||
case "CMD": return `CMD ${typeof e.err === "string" ? e.err : e.err.tag} ${e.context}`
|
||||
default: return e.tag
|
||||
}
|
||||
}
|
||||
|
||||
// -- Agent config (Env/SQLite.hs:136-180, 182-205)
|
||||
|
||||
export interface AgentConfig {
|
||||
tbqSize: number
|
||||
connIdBytes: number
|
||||
smpAgentVRange: [number, number] // [min, max]
|
||||
smpClientVRange: [number, number]
|
||||
e2eEncryptVRange: [number, number]
|
||||
messageRetryInterval: RetryInterval2
|
||||
messageTimeout: number // ms
|
||||
helloTimeout: number // ms
|
||||
quotaExceededTimeout: number // ms
|
||||
maxWorkerRestartsPerMin: number
|
||||
}
|
||||
|
||||
export const defaultAgentConfig: AgentConfig = {
|
||||
tbqSize: 128,
|
||||
connIdBytes: 12,
|
||||
// supportedSMPAgentVRange = [minSupportedSMPAgentVersion=2, currentSMPAgentVersion=7] (Agent/Protocol.hs:315-322)
|
||||
smpAgentVRange: [2, 7],
|
||||
// supportedSMPClientVRange = [initialSMPClientVersion=1, currentSMPClientVersion=4] (Protocol.hs:282-297)
|
||||
// NOTE: this is VersionSMPC (SMP client protocol), NOT the SMP transport version (≤18)
|
||||
smpClientVRange: [1, 4],
|
||||
// supportedE2EEncryptVRange = [kdfX3DHE2EEncryptVersion=2, currentE2EEncryptVersion=3] (Ratchet.hs:146-155)
|
||||
e2eEncryptVRange: [2, 3],
|
||||
messageRetryInterval: {
|
||||
riFast: {initialInterval: 2_000_000, increaseAfter: 10_000_000, maxInterval: 120_000_000},
|
||||
riSlow: {initialInterval: 300_000_000, increaseAfter: 60_000_000, maxInterval: 6 * 3600_000_000},
|
||||
},
|
||||
messageTimeout: 2 * 86400_000,
|
||||
helloTimeout: 2 * 86400_000,
|
||||
quotaExceededTimeout: 7 * 86400_000,
|
||||
maxWorkerRestartsPerMin: 5,
|
||||
}
|
||||
|
||||
// -- Server types
|
||||
|
||||
export interface SMPServerWithAuth {
|
||||
server: string // serialized server address
|
||||
auth: Uint8Array | null
|
||||
}
|
||||
|
||||
export interface UserServers {
|
||||
storageSrvs: Array<[number | null, SMPServerWithAuth]> // [(Maybe OperatorId, ProtoServerWithAuth)]
|
||||
proxySrvs: Array<[number | null, SMPServerWithAuth]>
|
||||
knownHosts: Set<string>
|
||||
}
|
||||
|
||||
// -- Worker (Env/SQLite.hs:317-332)
|
||||
|
||||
export interface Worker {
|
||||
workerId: number
|
||||
doWork: TMVar<void>
|
||||
action: TMVar<number | null> // null = not running, number = "running" placeholder (no threadId in JS)
|
||||
restarts: {restartMinute: number, restartCount: number}
|
||||
}
|
||||
|
||||
// updateRestartCount (Env/SQLite.hs:329-332)
|
||||
function updateRestartCount(now: number, rc: {restartMinute: number, restartCount: number}): {restartMinute: number, restartCount: number} {
|
||||
const min = Math.floor(now / 60000)
|
||||
return {restartMinute: min, restartCount: min === rc.restartMinute ? rc.restartCount + 1 : 1}
|
||||
}
|
||||
|
||||
// -- AgentOperation (Client.hs:456-470)
|
||||
|
||||
export type AgentOperation = "AORcvNetwork" | "AOMsgDelivery" | "AOSndNetwork" | "AODatabase"
|
||||
|
||||
export interface AgentOpState {
|
||||
opSuspended: boolean
|
||||
opsInProgress: number
|
||||
}
|
||||
|
||||
export type AgentState = "ASForeground" | "ASSuspending" | "ASSuspended"
|
||||
|
||||
// -- ATransmission event type
|
||||
|
||||
export type ATransmission = [string, Uint8Array, any] // (corrId, connId, event)
|
||||
|
||||
// -- AgentClient (Client.hs:328-378)
|
||||
|
||||
export interface AgentClient {
|
||||
active: boolean
|
||||
subQ: ABQueue<ATransmission>
|
||||
msgQ: ABQueue<any> // ServerMsg from SMP clients, processed by subscriber loop
|
||||
config: AgentConfig
|
||||
store: AgentStore
|
||||
smpServers: Map<number, UserServers> // userId → servers
|
||||
smpClients: Map<string, any> // tSessKey → SMPClient or pending
|
||||
smpProxiedRelays: Map<string, SMPServerWithAuth>
|
||||
userNetworkInfo: {networkType: string, online: boolean}
|
||||
subscrConns: Set<string> // hex connIds being subscribed
|
||||
currentSubs: TSessionSubs
|
||||
// Monotonic counter shared by newWorker (workerId) and getSessVar (sessionVarId).
|
||||
// Mutable ref so getSessVar (session.ts) can increment the same counter — Haskell uses one TVar.
|
||||
workerSeq: {val: number}
|
||||
smpDeliveryWorkers: Map<string, {worker: Worker, retryLock: TMVar<void>}>
|
||||
asyncCmdWorkers: Map<string, Worker>
|
||||
rcvNetworkOp: AgentOpState
|
||||
msgDeliveryOp: AgentOpState
|
||||
sndNetworkOp: AgentOpState
|
||||
databaseOp: AgentOpState
|
||||
agentState: AgentState
|
||||
connLocks: Map<string, Sem>
|
||||
invLocks: Map<string, Sem>
|
||||
randomServer: {gen: () => number} // random index generator
|
||||
}
|
||||
|
||||
// newAgentClient (Client.hs:498-584)
|
||||
export function newAgentClient(config: AgentConfig, store: AgentStore, smpServers: Map<number, UserServers>): AgentClient {
|
||||
return {
|
||||
active: true,
|
||||
subQ: new ABQueue<ATransmission>(config.tbqSize),
|
||||
msgQ: new ABQueue<any>(config.tbqSize),
|
||||
config,
|
||||
store,
|
||||
smpServers,
|
||||
smpClients: new Map(),
|
||||
smpProxiedRelays: new Map(),
|
||||
userNetworkInfo: {networkType: "UNOther", online: true},
|
||||
subscrConns: new Set(),
|
||||
currentSubs: new TSessionSubs(),
|
||||
workerSeq: {val: 0},
|
||||
smpDeliveryWorkers: new Map(),
|
||||
asyncCmdWorkers: new Map(),
|
||||
rcvNetworkOp: {opSuspended: false, opsInProgress: 0},
|
||||
msgDeliveryOp: {opSuspended: false, opsInProgress: 0},
|
||||
sndNetworkOp: {opSuspended: false, opsInProgress: 0},
|
||||
databaseOp: {opSuspended: false, opsInProgress: 0},
|
||||
agentState: "ASForeground",
|
||||
connLocks: new Map(),
|
||||
invLocks: new Map(),
|
||||
randomServer: {gen: () => Math.random()},
|
||||
}
|
||||
}
|
||||
|
||||
// -- Worker functions (Client.hs:439-454, 2118-2176)
|
||||
|
||||
// newWorker (Client.hs:439-445)
|
||||
export function newWorker(c: AgentClient): Worker {
|
||||
const workerId = c.workerSeq.val++
|
||||
return {
|
||||
workerId,
|
||||
doWork: TMVar.new<void>(undefined), // starts with "has work"
|
||||
action: TMVar.new<number | null>(null), // not running
|
||||
restarts: {restartMinute: 0, restartCount: 0},
|
||||
}
|
||||
}
|
||||
|
||||
// waitForWork (Client.hs:2118-2119)
|
||||
export function waitForWork(doWork: TMVar<void>): Promise<void> {
|
||||
return doWork.read().then(() => {})
|
||||
}
|
||||
|
||||
// noWorkToDo (Client.hs:2167-2168)
|
||||
export function noWorkToDo(doWork: TMVar<void>): void {
|
||||
doWork.tryTake()
|
||||
}
|
||||
|
||||
// hasWorkToDo (Client.hs:2171-2172)
|
||||
export function hasWorkToDo(w: Worker): void {
|
||||
hasWorkToDo_(w.doWork)
|
||||
}
|
||||
|
||||
// hasWorkToDo' (Client.hs:2175-2176)
|
||||
export function hasWorkToDo_(doWork: TMVar<void>): void {
|
||||
doWork.tryPut(undefined)
|
||||
}
|
||||
|
||||
// runWorkerAsync (Client.hs:447-454)
|
||||
// Ensures work runs at most once concurrently. If already running, no-op.
|
||||
// In Haskell this uses bracket + forkIO. In JS, fire-and-forget Promise.
|
||||
//
|
||||
// bracket (takeTMVar action) (tryPutTMVar action) (\a -> when (isNothing a) start)
|
||||
// start = putTMVar action . Just =<< mkWeakThreadId =<< forkIO work
|
||||
export async function runWorkerAsync(w: Worker, work: () => Promise<void>): Promise<void> {
|
||||
const a = await w.action.take()
|
||||
if (a !== null) {
|
||||
// Already running — put back and return
|
||||
w.action.tryPut(a)
|
||||
return
|
||||
}
|
||||
// Mark as running, start work in background
|
||||
await w.action.put(1)
|
||||
// forkIO — fire and forget. Work function contains its own restart loop (runWork).
|
||||
// When work eventually stops (max restarts or worker removed), reset action to null.
|
||||
work().catch(() => {}).finally(() => {
|
||||
w.action.tryTake()
|
||||
w.action.tryPut(null)
|
||||
})
|
||||
}
|
||||
|
||||
// getAgentWorker (Client.hs:387-437)
|
||||
// Get or create a worker for the given key. If hasWork=true, signal the worker.
|
||||
// Starts the worker async loop if not already running.
|
||||
// The work function should use `forever` internally — this function handles crash restart.
|
||||
export async function getAgentWorker(
|
||||
name: string,
|
||||
hasWork_: boolean,
|
||||
c: AgentClient,
|
||||
key: string,
|
||||
workers: Map<string, Worker>,
|
||||
work: (w: Worker) => Promise<void>,
|
||||
): Promise<Worker> {
|
||||
// getWorker >>= maybe createWorker whenExists
|
||||
let w = workers.get(key)
|
||||
if (w) {
|
||||
if (hasWork_) hasWorkToDo(w)
|
||||
} else {
|
||||
w = newWorker(c)
|
||||
workers.set(key, w)
|
||||
}
|
||||
const worker = w
|
||||
// runWorker w = runWorkerAsync (toW w) runWork
|
||||
await runWorkerAsync(worker, () => runWork(name, c, key, workers, worker, work))
|
||||
return worker
|
||||
}
|
||||
|
||||
// runWork (Client.hs:405-413) — runs work, on error checks whether to restart
|
||||
async function runWork(
|
||||
name: string,
|
||||
c: AgentClient,
|
||||
key: string,
|
||||
workers: Map<string, Worker>,
|
||||
worker: Worker,
|
||||
work: (w: Worker) => Promise<void>,
|
||||
): Promise<void> {
|
||||
// tryAllErrors' (work w) >>= restartOrDelete
|
||||
let error: unknown = undefined
|
||||
try {
|
||||
await work(worker)
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
// restartOrDelete (Client.hs:407-413)
|
||||
const now = Date.now()
|
||||
// getWorker >>= maybe (pure False) (shouldRestart ...)
|
||||
const currentWorker = workers.get(key)
|
||||
if (!currentWorker) return // worker was removed from map, don't restart
|
||||
if (currentWorker.workerId !== worker.workerId) return // replaced by new worker
|
||||
// shouldRestart (Client.hs:414-437)
|
||||
const rc = updateRestartCount(now, worker.restarts)
|
||||
const isActive = c.active
|
||||
const errStr = error !== undefined ? `, error: ${error}` : ", no error"
|
||||
const msg = `Worker ${name} for ${key} terminated ${rc.restartCount} times${errStr}`
|
||||
if (isActive && rc.restartCount < c.config.maxWorkerRestartsPerMin) {
|
||||
// checkRestarts: restart
|
||||
worker.restarts = rc
|
||||
hasWorkToDo_(worker.doWork)
|
||||
// Haskell: `void $ tryPutTMVar action Nothing` — a no-op here because `action` is
|
||||
// full (=1) for the whole restart chain (recursion stays inside the fired work()).
|
||||
// We must NOT empty it: doing so would let a concurrent getAgentWorker start a
|
||||
// second worker. tryPut on a full TMVar is a no-op, matching Haskell exactly.
|
||||
worker.action.tryPut(null)
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "INTERNAL", msg}}])
|
||||
// when restart runWork — restart the worker
|
||||
await runWork(name, c, key, workers, worker, work)
|
||||
} else {
|
||||
// checkRestarts: delete
|
||||
workers.delete(key)
|
||||
if (isActive) {
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "CRITICAL", important: true, msg}}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withWork_ (Client.hs:2126-2140)
|
||||
// Clear work signal, get work from store, if found re-signal and run action.
|
||||
export async function withWork<T>(
|
||||
c: AgentClient,
|
||||
doWork: TMVar<void>,
|
||||
getWork: () => Promise<T | null>,
|
||||
action: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
noWorkToDo(doWork)
|
||||
let item: T | null
|
||||
try {
|
||||
item = await getWork()
|
||||
} catch (e) {
|
||||
hasWorkToDo_(doWork)
|
||||
const msg = `withWork error: ${e}`
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "INTERNAL", msg}}])
|
||||
return
|
||||
}
|
||||
if (item !== null) {
|
||||
hasWorkToDo_(doWork)
|
||||
await action(item)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Operation state (Client.hs:2179-2253)
|
||||
|
||||
function agentOpState(c: AgentClient, op: AgentOperation): AgentOpState {
|
||||
switch (op) {
|
||||
case "AORcvNetwork": return c.rcvNetworkOp
|
||||
case "AOMsgDelivery": return c.msgDeliveryOp
|
||||
case "AOSndNetwork": return c.sndNetworkOp
|
||||
case "AODatabase": return c.databaseOp
|
||||
}
|
||||
}
|
||||
|
||||
// beginAgentOperation (Client.hs:2223-2230)
|
||||
// DEVIATION: Haskell blocks (STM `retry`) while opSuspended, resuming when the agent
|
||||
// returns to foreground. Single-threaded JS can't synchronously block; the widget never
|
||||
// suspends (no suspendAgent), so opSuspended stays false and this path is unreachable.
|
||||
// We throw rather than silently proceed, to surface any unexpected suspend during dev.
|
||||
export function beginAgentOperation(c: AgentClient, op: AgentOperation): void {
|
||||
const s = agentOpState(c, op)
|
||||
if (s.opSuspended) throw new AgentError({tag: "INACTIVE"})
|
||||
s.opsInProgress++
|
||||
}
|
||||
|
||||
// endAgentOperation (Client.hs:2179-2197)
|
||||
export function endAgentOperation(c: AgentClient, op: AgentOperation): void {
|
||||
const s = agentOpState(c, op)
|
||||
s.opsInProgress = Math.max(0, s.opsInProgress - 1)
|
||||
if (s.opSuspended && s.opsInProgress === 0 && c.agentState === "ASSuspending") {
|
||||
cascadeSuspend(c, op)
|
||||
}
|
||||
}
|
||||
|
||||
function cascadeSuspend(c: AgentClient, op: AgentOperation): void {
|
||||
switch (op) {
|
||||
case "AORcvNetwork":
|
||||
suspendOp(c, "AOMsgDelivery", () => suspendSendingAndDatabase(c))
|
||||
break
|
||||
case "AOMsgDelivery":
|
||||
suspendSendingAndDatabase(c)
|
||||
break
|
||||
case "AOSndNetwork":
|
||||
suspendOp(c, "AODatabase", () => notifySuspended(c))
|
||||
break
|
||||
case "AODatabase":
|
||||
notifySuspended(c)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function suspendSendingAndDatabase(c: AgentClient): void {
|
||||
suspendOp(c, "AOSndNetwork", () => suspendOp(c, "AODatabase", () => notifySuspended(c)))
|
||||
}
|
||||
|
||||
function suspendOp(c: AgentClient, op: AgentOperation, endedAction: () => void): void {
|
||||
const s = agentOpState(c, op)
|
||||
s.opSuspended = true
|
||||
if (s.opsInProgress === 0 && c.agentState === "ASSuspending") endedAction()
|
||||
}
|
||||
|
||||
function notifySuspended(c: AgentClient): void {
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "SUSPENDED"}])
|
||||
c.agentState = "ASSuspended"
|
||||
}
|
||||
|
||||
// throwWhenInactive (Client.hs:959-962)
|
||||
export function throwWhenInactive(c: AgentClient): void {
|
||||
if (!c.active) throw new AgentError({tag: "INACTIVE"})
|
||||
}
|
||||
|
||||
// waitForUserNetwork (Client.hs:924-928)
|
||||
// In browser: if offline, we just throw. No blocking wait.
|
||||
export function checkUserNetwork(c: AgentClient): void {
|
||||
if (!c.userNetworkInfo.online) throw new AgentError({tag: "BROKER", addr: "", err: "NETWORK"})
|
||||
}
|
||||
|
||||
// -- Locking (Lock.hs, Client.hs:1003-1030)
|
||||
|
||||
// withConnLock (Client.hs:1003-1006)
|
||||
export async function withConnLock<T>(c: AgentClient, connId: Uint8Array, fn: () => Promise<T>): Promise<T> {
|
||||
const key = toHex(connId)
|
||||
return withLock(c.connLocks, key, fn)
|
||||
}
|
||||
|
||||
// withInvLock (Client.hs:1012-1015)
|
||||
export async function withInvLock<T>(c: AgentClient, invKey: Uint8Array, fn: () => Promise<T>): Promise<T> {
|
||||
return withLock(c.invLocks, toHex(invKey), fn)
|
||||
}
|
||||
|
||||
async function withLock<T>(locks: Map<string, Sem>, key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let sem = locks.get(key)
|
||||
if (!sem) { sem = new Sem(1); locks.set(key, sem) }
|
||||
await sem.wait()
|
||||
try { return await fn() } finally { sem.signal() }
|
||||
}
|
||||
|
||||
// -- Server selection (Client.hs:2312-2394)
|
||||
|
||||
// getNextServer (Client.hs:2325-2334)
|
||||
export function getNextServer(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
srvsSel: (us: UserServers) => Array<[number | null, SMPServerWithAuth]>,
|
||||
usedSrvs: string[],
|
||||
): SMPServerWithAuth {
|
||||
const us = c.smpServers.get(userId)
|
||||
if (!us) throw new AgentError({tag: "INTERNAL", msg: "unknown userId - no user servers"})
|
||||
const srvs = srvsSel(us)
|
||||
if (srvs.length === 0) throw new AgentError({tag: "INTERNAL", msg: "no servers configured"})
|
||||
const usedHosts = new Set(usedSrvs)
|
||||
// Prefer servers with unused hosts
|
||||
const unused = srvs.filter(([, s]) => !usedHosts.has(s.server))
|
||||
const pool = unused.length > 0 ? unused : srvs
|
||||
return pickServer(pool, c.randomServer)
|
||||
}
|
||||
|
||||
// pickServer (Client.hs:2318-2323)
|
||||
function pickServer(
|
||||
srvs: Array<[number | null, SMPServerWithAuth]>,
|
||||
rng: {gen: () => number},
|
||||
): SMPServerWithAuth {
|
||||
if (srvs.length === 1) return srvs[0][1]
|
||||
const idx = Math.floor(rng.gen() * srvs.length)
|
||||
return srvs[idx][1]
|
||||
}
|
||||
|
||||
// -- Helpers
|
||||
|
||||
function toHex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Agent message encoding/decoding.
|
||||
// Mirrors: Simplex.Messaging.Agent.Protocol (AgentMsgEnvelope, AgentMessage, APrivHeader, AMessage)
|
||||
|
||||
import {
|
||||
Decoder, concatBytes,
|
||||
encodeBytes, decodeBytes,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeInt64, decodeInt64,
|
||||
encodeWord16, decodeWord16,
|
||||
encodeMaybe, decodeMaybe,
|
||||
encodeNonEmpty, decodeNonEmpty,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Constants (Agent/Protocol.hs:318-319)
|
||||
|
||||
export const currentSMPAgentVersion = 7
|
||||
|
||||
// -- AMessage (Agent/Protocol.hs:1001-1020)
|
||||
|
||||
export type AMessage =
|
||||
| {type: "HELLO"}
|
||||
| {type: "A_MSG", body: Uint8Array}
|
||||
| {type: "A_RCVD", receipts: AMessageReceipt[]} // NonEmpty
|
||||
| {type: "EREADY", lastDecryptedMsgId: bigint}
|
||||
|
||||
// Agent/Protocol.hs:1040-1045
|
||||
export interface AMessageReceipt {
|
||||
agentMsgId: bigint // Int64
|
||||
msgHash: Uint8Array // ByteString (32-byte SHA-256)
|
||||
rcptInfo: Uint8Array // MsgReceiptInfo (ByteString, Large-encoded)
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:1078-1100
|
||||
export function encodeAMessage(msg: AMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "HELLO": return new Uint8Array([0x48]) // "H"
|
||||
case "A_MSG": return concatBytes(new Uint8Array([0x4D]), msg.body) // "M" + Tail
|
||||
case "A_RCVD": return concatBytes(new Uint8Array([0x56]), encodeNonEmpty(encodeAMessageReceipt, msg.receipts)) // "V" + NonEmpty
|
||||
case "EREADY": return concatBytes(new Uint8Array([0x45]), encodeInt64(msg.lastDecryptedMsgId)) // "E" + Int64
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAMessage(d: Decoder): AMessage {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x48: return {type: "HELLO"} // 'H'
|
||||
case 0x4D: return {type: "A_MSG", body: d.takeAll()} // 'M' + Tail
|
||||
case 0x56: return {type: "A_RCVD", receipts: decodeNonEmpty(decodeAMessageReceipt, d)} // 'V'
|
||||
case 0x45: return {type: "EREADY", lastDecryptedMsgId: decodeInt64(d)} // 'E'
|
||||
// Queue management tags (not needed for chat messages, but recognized for decoding)
|
||||
case 0x51: { // 'Q'
|
||||
const sub = d.anyByte()
|
||||
switch (sub) {
|
||||
case 0x43: // 'C' = A_QCONT
|
||||
case 0x41: // 'A' = QADD
|
||||
case 0x4B: // 'K' = QKEY
|
||||
case 0x55: // 'U' = QUSE
|
||||
case 0x54: // 'T' = QTEST
|
||||
throw new Error("decodeAMessage: queue management message (Q" + String.fromCharCode(sub) + ") not implemented")
|
||||
default:
|
||||
throw new Error("decodeAMessage: unknown Q-subtag " + sub)
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error("decodeAMessage: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:1106-1111
|
||||
function encodeAMessageReceipt(r: AMessageReceipt): Uint8Array {
|
||||
return concatBytes(encodeInt64(r.agentMsgId), encodeBytes(r.msgHash), encodeLarge(r.rcptInfo))
|
||||
}
|
||||
|
||||
function decodeAMessageReceipt(d: Decoder): AMessageReceipt {
|
||||
return {agentMsgId: decodeInt64(d), msgHash: decodeBytes(d), rcptInfo: decodeLarge(d)}
|
||||
}
|
||||
|
||||
// -- APrivHeader (Agent/Protocol.hs:946-957)
|
||||
|
||||
export interface APrivHeader {
|
||||
sndMsgId: bigint // AgentMsgId = Int64
|
||||
prevMsgHash: Uint8Array // MsgHash = ByteString
|
||||
}
|
||||
|
||||
export function encodeAPrivHeader(h: APrivHeader): Uint8Array {
|
||||
return concatBytes(encodeInt64(h.sndMsgId), encodeBytes(h.prevMsgHash))
|
||||
}
|
||||
|
||||
export function decodeAPrivHeader(d: Decoder): APrivHeader {
|
||||
return {sndMsgId: decodeInt64(d), prevMsgHash: decodeBytes(d)}
|
||||
}
|
||||
|
||||
// -- AgentMessage (Agent/Protocol.hs:866-888)
|
||||
|
||||
export type AgentMessage =
|
||||
| {type: "connInfo", cInfo: Uint8Array}
|
||||
| {type: "connInfoReply", smpQueues: Uint8Array[], cInfo: Uint8Array} // NonEmpty raw-encoded SMPQueueInfo
|
||||
| {type: "ratchetInfo", info: Uint8Array}
|
||||
| {type: "message", header: APrivHeader, msg: AMessage}
|
||||
|
||||
export function encodeAgentMessage(msg: AgentMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "connInfo":
|
||||
return concatBytes(new Uint8Array([0x49]), msg.cInfo) // 'I' + Tail
|
||||
case "connInfoReply":
|
||||
// 'D' + NonEmpty SMPQueueInfo + Tail cInfo
|
||||
// SMPQueueInfo encoding is complex; for now encode the raw bytes
|
||||
return concatBytes(
|
||||
new Uint8Array([0x44]),
|
||||
encodeNonEmpty(b => b, msg.smpQueues),
|
||||
msg.cInfo,
|
||||
)
|
||||
case "ratchetInfo":
|
||||
return concatBytes(new Uint8Array([0x52]), msg.info) // 'R' + Tail
|
||||
case "message":
|
||||
return concatBytes(new Uint8Array([0x4D]), encodeAPrivHeader(msg.header), encodeAMessage(msg.msg)) // 'M' + header + msg
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAgentMessage(d: Decoder): AgentMessage {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x49: return {type: "connInfo", cInfo: d.takeAll()} // 'I' + Tail
|
||||
case 0x44: { // 'D'
|
||||
// NonEmpty SMPQueueInfo is complex to decode; skip for now, just capture raw
|
||||
throw new Error("decodeAgentMessage: connInfoReply ('D') not implemented")
|
||||
}
|
||||
case 0x52: return {type: "ratchetInfo", info: d.takeAll()} // 'R' + Tail
|
||||
case 0x4D: return {type: "message", header: decodeAPrivHeader(d), msg: decodeAMessage(d)} // 'M'
|
||||
default:
|
||||
throw new Error("decodeAgentMessage: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// -- AgentMsgEnvelope (Agent/Protocol.hs:812-861)
|
||||
|
||||
export type AgentMsgEnvelope =
|
||||
| {type: "confirmation", agentVersion: number, e2eEncryption: Uint8Array | null, encConnInfo: Uint8Array}
|
||||
| {type: "envelope", agentVersion: number, encAgentMessage: Uint8Array}
|
||||
| {type: "invitation", agentVersion: number, connReqBytes: Uint8Array, connInfo: Uint8Array}
|
||||
| {type: "ratchetKey", agentVersion: number, e2eEncryption: Uint8Array, info: Uint8Array}
|
||||
|
||||
// Agent/Protocol.hs:835-843
|
||||
export function encodeAgentMsgEnvelope(env: AgentMsgEnvelope): Uint8Array {
|
||||
switch (env.type) {
|
||||
case "confirmation":
|
||||
// (agentVersion, 'C', Maybe SndE2ERatchetParams, Tail encConnInfo)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x43]), // 'C'
|
||||
encodeMaybe(b => b, env.e2eEncryption), // e2eEncryption is already smpEncoded bytes or null
|
||||
env.encConnInfo, // Tail
|
||||
)
|
||||
case "envelope":
|
||||
// (agentVersion, 'M', Tail encAgentMessage)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x4D]), // 'M'
|
||||
env.encAgentMessage, // Tail
|
||||
)
|
||||
case "invitation":
|
||||
// (agentVersion, 'I', Large connReqBytes, Tail connInfo)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x49]), // 'I'
|
||||
encodeLarge(env.connReqBytes),
|
||||
env.connInfo, // Tail
|
||||
)
|
||||
case "ratchetKey":
|
||||
// (agentVersion, 'R', e2eEncryption, Tail info)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x52]), // 'R'
|
||||
env.e2eEncryption, // already smpEncoded
|
||||
env.info, // Tail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:844-861
|
||||
export function decodeAgentMsgEnvelope(d: Decoder): AgentMsgEnvelope {
|
||||
const agentVersion = decodeWord16(d)
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x43: // 'C' Confirmation
|
||||
// e2eEncryption_ is Maybe (SndE2ERatchetParams 'X448), encConnInfo is Tail
|
||||
// Full parsing of E2ERatchetParams needed to split the boundary — not implemented in spike
|
||||
throw new Error("decodeAgentMsgEnvelope: confirmation ('C') not fully implemented")
|
||||
case 0x4D: // 'M' Message envelope
|
||||
return {type: "envelope", agentVersion, encAgentMessage: d.takeAll()} // Tail
|
||||
case 0x49: { // 'I' Invitation
|
||||
const connReqBytes = decodeLarge(d)
|
||||
const connInfo = d.takeAll() // Tail
|
||||
return {type: "invitation", agentVersion, connReqBytes, connInfo}
|
||||
}
|
||||
case 0x52: { // 'R' RatchetKey
|
||||
// e2eEncryption is an E2ERatchetParams — variable-length, not Tail
|
||||
// For now, capture remaining minus nothing (since info is Tail and comes last)
|
||||
// This is tricky: e2eEncryption is smpEncoded E2ERatchetParams, info is Tail
|
||||
// We can't easily split without knowing the E2ERatchetParams length
|
||||
// For the spike, just capture all remaining as raw
|
||||
throw new Error("decodeAgentMsgEnvelope: ratchetKey ('R') not fully implemented")
|
||||
}
|
||||
default:
|
||||
throw new Error("decodeAgentMsgEnvelope: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
// Agent protocol types and short link parsing.
|
||||
// Mirrors: Simplex.Messaging.Agent.Protocol
|
||||
|
||||
import {base64urlDecode} from "@simplex-chat/xftp-web/dist/protocol/description.js"
|
||||
import {
|
||||
Decoder, concatBytes,
|
||||
encodeBytes, decodeBytes,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeWord16, decodeWord16,
|
||||
encodeBool, decodeBool,
|
||||
encodeMaybe, decodeMaybe,
|
||||
encodeNonEmpty, decodeNonEmpty,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
import {encodeProtocolServer} from "../protocol.js"
|
||||
|
||||
// -- Short link types (Agent/Protocol.hs:1462-1470)
|
||||
|
||||
export type ShortLinkScheme = "simplex" | "https"
|
||||
|
||||
export type ContactConnType = "contact" | "channel" | "group" | "relay"
|
||||
|
||||
export interface ProtocolServer {
|
||||
hosts: Uint8Array[] // NonEmpty, each is the strEncoded host bytes
|
||||
port: Uint8Array
|
||||
keyHash: Uint8Array
|
||||
}
|
||||
|
||||
export type ConnShortLink =
|
||||
| {mode: "invitation", scheme: ShortLinkScheme, server: ProtocolServer, linkId: Uint8Array, linkKey: Uint8Array}
|
||||
| {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ProtocolServer, linkKey: Uint8Array}
|
||||
|
||||
// -- ProtocolServer binary encoding (Protocol.hs:1264-1269)
|
||||
// smpEncode (host, port, keyHash)
|
||||
// host: NonEmpty TransportHost = smpEncodeList (1-byte count + each as ByteString)
|
||||
// port: String = ByteString (1-byte len + bytes)
|
||||
// keyHash: KeyHash = ByteString (1-byte len + bytes)
|
||||
|
||||
export function decodeProtocolServer(d: Decoder): ProtocolServer {
|
||||
const hostCount = d.anyByte()
|
||||
if (hostCount === 0) throw new Error("empty server host list")
|
||||
const hosts: Uint8Array[] = []
|
||||
for (let i = 0; i < hostCount; i++) hosts.push(decodeBytes(d))
|
||||
const port = decodeBytes(d)
|
||||
const keyHash = decodeBytes(d)
|
||||
return {hosts, port, keyHash}
|
||||
}
|
||||
|
||||
// -- ConnShortLink binary encoding (Agent/Protocol.hs:1631-1649)
|
||||
// Contact: smpEncode (CMContact, ctTypeChar, srv, linkKey)
|
||||
// Invitation: smpEncode (CMInvitation, srv, linkId, linkKey)
|
||||
|
||||
export interface ConnShortLinkBinary {
|
||||
mode: "contact" | "invitation"
|
||||
connType?: ContactConnType
|
||||
server: ProtocolServer
|
||||
linkId?: Uint8Array
|
||||
linkKey: Uint8Array
|
||||
}
|
||||
|
||||
const ctTypeFromByte: Record<number, ContactConnType> = {
|
||||
0x41: "contact", // 'A'
|
||||
0x43: "channel", // 'C'
|
||||
0x47: "group", // 'G'
|
||||
0x52: "relay", // 'R'
|
||||
}
|
||||
|
||||
export function decodeConnShortLink(d: Decoder): ConnShortLinkBinary {
|
||||
const mode = d.anyByte()
|
||||
if (mode === 0x49) {
|
||||
// Invitation: (srv, linkId, linkKey)
|
||||
const server = decodeProtocolServer(d)
|
||||
const linkId = decodeBytes(d)
|
||||
const linkKey = decodeBytes(d)
|
||||
return {mode: "invitation", server, linkId, linkKey}
|
||||
} else if (mode === 0x43) {
|
||||
// Contact: (ctTypeChar, srv, linkKey)
|
||||
const ctByte = d.anyByte()
|
||||
const connType = ctTypeFromByte[ctByte]
|
||||
if (!connType) throw new Error("unknown contact type: 0x" + ctByte.toString(16))
|
||||
const server = decodeProtocolServer(d)
|
||||
const linkKey = decodeBytes(d)
|
||||
return {mode: "contact", connType, server, linkKey}
|
||||
}
|
||||
throw new Error("unknown ConnShortLink mode: 0x" + mode.toString(16))
|
||||
}
|
||||
|
||||
// -- OwnerAuth (Agent/Protocol.hs:1793-1800)
|
||||
// Outer ByteString wrapping inner: (ownerId, ownerKey, authOwnerSig)
|
||||
|
||||
export interface OwnerAuth {
|
||||
ownerId: Uint8Array
|
||||
ownerKey: Uint8Array
|
||||
authOwnerSig: Uint8Array
|
||||
}
|
||||
|
||||
export function decodeOwnerAuth(d: Decoder): OwnerAuth {
|
||||
const inner = decodeBytes(d)
|
||||
const id = new Decoder(inner)
|
||||
const ownerId = decodeBytes(id)
|
||||
const ownerKey = decodeBytes(id)
|
||||
const authOwnerSig = decodeBytes(id)
|
||||
return {ownerId, ownerKey, authOwnerSig}
|
||||
}
|
||||
|
||||
// -- UserLinkData (Agent/Protocol.hs:1891-1894)
|
||||
// If first byte is 0xFF, read Large; otherwise it's a ByteString (1-byte length)
|
||||
|
||||
export function decodeUserLinkData(d: Decoder): Uint8Array {
|
||||
const firstByte = d.anyByte()
|
||||
if (firstByte === 0xFF) return decodeLarge(d)
|
||||
return d.take(firstByte)
|
||||
}
|
||||
|
||||
// -- UserContactData (Agent/Protocol.hs:1881-1889)
|
||||
|
||||
export interface UserContactData {
|
||||
direct: boolean
|
||||
owners: OwnerAuth[]
|
||||
relays: ConnShortLinkBinary[]
|
||||
userData: Uint8Array
|
||||
}
|
||||
|
||||
export function decodeUserContactData(d: Decoder): UserContactData {
|
||||
const direct = decodeBool(d)
|
||||
const ownerCount = d.anyByte()
|
||||
const owners: OwnerAuth[] = []
|
||||
for (let i = 0; i < ownerCount; i++) owners.push(decodeOwnerAuth(d))
|
||||
const relayCount = d.anyByte()
|
||||
const relays: ConnShortLinkBinary[] = []
|
||||
for (let i = 0; i < relayCount; i++) relays.push(decodeConnShortLink(d))
|
||||
const userData = decodeUserLinkData(d)
|
||||
return {direct, owners, relays, userData}
|
||||
}
|
||||
|
||||
// -- ConnLinkData (Agent/Protocol.hs:1838-1855)
|
||||
// Contact: 'C' + versionRange + UserContactData
|
||||
|
||||
export interface ConnLinkDataContact {
|
||||
mode: "contact"
|
||||
agentVRange: {min: number; max: number}
|
||||
userContactData: UserContactData
|
||||
}
|
||||
|
||||
export function decodeConnLinkData(d: Decoder): ConnLinkDataContact {
|
||||
const modeChar = d.anyByte()
|
||||
if (modeChar !== 0x43) throw new Error("expected Contact mode 'C' (0x43), got 0x" + modeChar.toString(16))
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const userContactData = decodeUserContactData(d)
|
||||
return {mode: "contact", agentVRange: {min, max}, userContactData}
|
||||
}
|
||||
|
||||
// -- FixedLinkData (Agent/Protocol.hs:1830-1836)
|
||||
// Encoding: smpEncode (agentVRange, rootKey, linkConnReq) <> maybe "" smpEncode linkEntityId
|
||||
// rootKey is DER-encoded Ed25519 public key (ByteString: 1-byte len + 44 bytes DER)
|
||||
// linkConnReq is ConnectionRequestUri (variable length, not length-prefixed)
|
||||
// For now, we parse agentVRange + rootKey and keep the rest as raw bytes.
|
||||
// Full ConnectionRequestUri parsing is future work.
|
||||
|
||||
export interface FixedLinkData {
|
||||
agentVRange: {min: number; max: number}
|
||||
rootKey: Uint8Array // DER-encoded Ed25519 public key (44 bytes)
|
||||
rest: Uint8Array // raw linkConnReq + linkEntityId bytes
|
||||
}
|
||||
|
||||
export function decodeFixedLinkData(d: Decoder): FixedLinkData {
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const rootKey = decodeBytes(d)
|
||||
const rest = d.takeAll()
|
||||
return {agentVRange: {min, max}, rootKey, rest}
|
||||
}
|
||||
|
||||
// -- SMPQueueAddress (Agent/Protocol.hs:1350-1356)
|
||||
|
||||
export interface SMPQueueAddress {
|
||||
smpServer: {hosts: string[], port: string, keyHash: Uint8Array} // ProtocolServer
|
||||
senderId: Uint8Array // EntityId (ByteString)
|
||||
dhPublicKey: Uint8Array // PublicKeyX25519 (DER-encoded ByteString)
|
||||
queueMode: string | null // Maybe QueueMode: 'M' = Messaging, 'C' = Contact
|
||||
}
|
||||
|
||||
// -- SMPQueueInfo (Agent/Protocol.hs:1310-1327)
|
||||
// Version-dependent encoding
|
||||
|
||||
// SMP client version constants (Protocol.hs:281-294)
|
||||
const initialSMPClientVersion = 1
|
||||
const sndAuthKeySMPClientVersion = 3
|
||||
const shortLinksSMPClientVersion = 4
|
||||
|
||||
export interface SMPQueueInfo {
|
||||
clientVersion: number // VersionSMPC (Word16)
|
||||
queueAddress: SMPQueueAddress
|
||||
}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1313-1321)
|
||||
export function encodeSMPQueueInfo(q: SMPQueueInfo): Uint8Array {
|
||||
const {clientVersion, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}} = q
|
||||
const addrEnc = concatBytes(
|
||||
encodeWord16(clientVersion),
|
||||
encodeProtocolServer(smpServer.hosts, smpServer.port, smpServer.keyHash),
|
||||
encodeBytes(senderId),
|
||||
encodeBytes(dhPublicKey),
|
||||
)
|
||||
if (clientVersion >= shortLinksSMPClientVersion) {
|
||||
// encode queueMode directly (Maybe QueueMode as char or empty)
|
||||
const qmBytes = queueMode ? new Uint8Array([queueMode.charCodeAt(0)]) : new Uint8Array(0)
|
||||
return concatBytes(addrEnc, qmBytes)
|
||||
}
|
||||
if (clientVersion >= sndAuthKeySMPClientVersion && senderCanSecure(queueMode)) {
|
||||
return concatBytes(addrEnc, encodeBool(true))
|
||||
}
|
||||
if (clientVersion > initialSMPClientVersion) {
|
||||
return addrEnc
|
||||
}
|
||||
// v1 legacy — not supported by web widget
|
||||
throw new Error("encodeSMPQueueInfo: legacy v1 not supported")
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1322-1327)
|
||||
export function decodeSMPQueueInfo(d: Decoder): SMPQueueInfo {
|
||||
const clientVersion = decodeWord16(d)
|
||||
// v1 legacy server encoding not supported
|
||||
if (clientVersion <= initialSMPClientVersion) throw new Error("decodeSMPQueueInfo: legacy v1 not supported")
|
||||
const smpServer = decodeProtocolServerTyped(d)
|
||||
const senderId = decodeBytes(d)
|
||||
const dhPublicKey = decodeBytes(d)
|
||||
const queueMode = decodeQueueMode(d)
|
||||
return {clientVersion, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}}
|
||||
}
|
||||
|
||||
// -- SMPQueueUri (Agent/Protocol.hs:1347-1431)
|
||||
|
||||
export interface SMPQueueUri {
|
||||
clientVRange: {min: number, max: number} // VersionRangeSMPC
|
||||
queueAddress: SMPQueueAddress
|
||||
}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1417-1427)
|
||||
export function encodeSMPQueueUri(q: SMPQueueUri): Uint8Array {
|
||||
const {clientVRange: {min: minV, max: maxV}, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}} = q
|
||||
const addrEnc = concatBytes(
|
||||
encodeWord16(minV), encodeWord16(maxV),
|
||||
encodeProtocolServer(smpServer.hosts, smpServer.port, smpServer.keyHash),
|
||||
encodeBytes(senderId),
|
||||
encodeBytes(dhPublicKey),
|
||||
)
|
||||
if (minV >= shortLinksSMPClientVersion) {
|
||||
const qmBytes = queueMode ? new Uint8Array([queueMode.charCodeAt(0)]) : new Uint8Array(0)
|
||||
return concatBytes(addrEnc, qmBytes)
|
||||
}
|
||||
if (minV >= sndAuthKeySMPClientVersion || (maxV >= sndAuthKeySMPClientVersion && senderCanSecure(queueMode))) {
|
||||
return concatBytes(addrEnc, encodeBool(senderCanSecure(queueMode)))
|
||||
}
|
||||
return addrEnc
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1428-1431)
|
||||
export function decodeSMPQueueUri(d: Decoder): SMPQueueUri {
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const smpServer = decodeProtocolServerTyped(d)
|
||||
const senderId = decodeBytes(d)
|
||||
const dhPublicKey = decodeBytes(d)
|
||||
const queueMode = decodeQueueMode(d)
|
||||
return {clientVRange: {min, max}, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}}
|
||||
}
|
||||
|
||||
// -- ConnReqUriData (Agent/Protocol.hs:1728-1734, 1145-1158)
|
||||
|
||||
export interface ConnReqUriData {
|
||||
crAgentVRange: {min: number, max: number} // VersionRangeSMPA
|
||||
crSmpQueues: SMPQueueUri[] // NonEmpty SMPQueueUri
|
||||
crClientData: string | null // Maybe CRClientData (Text)
|
||||
}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1145-1147)
|
||||
export function encodeConnReqUriData(d: ConnReqUriData): Uint8Array {
|
||||
const vr = concatBytes(encodeWord16(d.crAgentVRange.min), encodeWord16(d.crAgentVRange.max))
|
||||
const queues = encodeNonEmpty(encodeSMPQueueUri, d.crSmpQueues)
|
||||
const clientData = d.crClientData !== null
|
||||
? concatBytes(new Uint8Array([0x31]), encodeLarge(new TextEncoder().encode(d.crClientData)))
|
||||
: new Uint8Array([0x30])
|
||||
return concatBytes(vr, queues, clientData)
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1148-1158)
|
||||
export function decodeConnReqUriData(d: Decoder): ConnReqUriData {
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const crSmpQueues = decodeNonEmpty(decodeSMPQueueUri, d)
|
||||
// Patch queueMode: if Nothing, set to QMContact (Agent/Protocol.hs:1156-1158)
|
||||
for (const q of crSmpQueues) {
|
||||
if (q.queueAddress.queueMode === null) q.queueAddress.queueMode = "C"
|
||||
}
|
||||
const clientData = decodeMaybe((dd) => {
|
||||
const large = decodeLarge(dd)
|
||||
return new TextDecoder().decode(large)
|
||||
}, d)
|
||||
return {crAgentVRange: {min, max}, crSmpQueues, crClientData: clientData}
|
||||
}
|
||||
|
||||
// -- ConnectionRequestUri (Agent/Protocol.hs:1130-1143, 1436-1441)
|
||||
|
||||
export type ConnectionRequestUri =
|
||||
| {mode: "invitation", crData: ConnReqUriData, e2eParams: Uint8Array} // raw smpEncoded E2ERatchetParams
|
||||
| {mode: "contact", crData: ConnReqUriData}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1130-1133)
|
||||
export function encodeConnectionRequestUri(cr: ConnectionRequestUri): Uint8Array {
|
||||
switch (cr.mode) {
|
||||
case "invitation":
|
||||
return concatBytes(new Uint8Array([0x49]), encodeConnReqUriData(cr.crData), cr.e2eParams) // 'I' + crData + e2eParams
|
||||
case "contact":
|
||||
return concatBytes(new Uint8Array([0x43]), encodeConnReqUriData(cr.crData)) // 'C' + crData
|
||||
}
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1140-1143)
|
||||
export function decodeConnectionRequestUri(d: Decoder): ConnectionRequestUri {
|
||||
const mode = d.anyByte()
|
||||
if (mode === 0x49) { // 'I' Invitation
|
||||
const crData = decodeConnReqUriData(d)
|
||||
const e2eParams = d.takeAll() // E2ERatchetParams consumes rest
|
||||
return {mode: "invitation", crData, e2eParams}
|
||||
}
|
||||
if (mode === 0x43) { // 'C' Contact
|
||||
const crData = decodeConnReqUriData(d)
|
||||
return {mode: "contact", crData}
|
||||
}
|
||||
throw new Error("decodeConnectionRequestUri: unknown mode 0x" + mode.toString(16))
|
||||
}
|
||||
|
||||
// -- Helpers
|
||||
|
||||
function senderCanSecure(queueMode: string | null): boolean {
|
||||
return queueMode === "M"
|
||||
}
|
||||
|
||||
// queueModeP (Agent/Protocol.hs:1433-1434)
|
||||
// Just <$> smpP <|> optional ((\case True -> QMMessaging; _ -> QMContact) <$> smpP)
|
||||
function decodeQueueMode(d: Decoder): string | null {
|
||||
if (d.remaining() === 0) return null
|
||||
const b = d.anyByte()
|
||||
if (b === 0x4D) return "M" // QMMessaging
|
||||
if (b === 0x43) return "C" // QMContact
|
||||
// Could be a Bool (sndSecure) for older versions — True='T'(0x54) → QMMessaging, False='F'(0x46) → QMContact
|
||||
if (b === 0x54) return "M" // True → QMMessaging
|
||||
if (b === 0x46) return null // False → no queueMode (not secured)
|
||||
return null
|
||||
}
|
||||
|
||||
// Decode ProtocolServer into typed format with string hosts
|
||||
function decodeProtocolServerTyped(d: Decoder): {hosts: string[], port: string, keyHash: Uint8Array} {
|
||||
const hostCount = d.anyByte()
|
||||
if (hostCount === 0) throw new Error("empty server host list")
|
||||
const hosts: string[] = []
|
||||
for (let i = 0; i < hostCount; i++) hosts.push(new TextDecoder().decode(decodeBytes(d)))
|
||||
const port = new TextDecoder().decode(decodeBytes(d))
|
||||
const keyHash = decodeBytes(d)
|
||||
return {hosts, port, keyHash}
|
||||
}
|
||||
|
||||
// -- Profile extraction
|
||||
|
||||
export function parseProfile(userData: Uint8Array): unknown {
|
||||
if (userData.length > 0 && userData[0] === 0x58) {
|
||||
throw new Error("zstd-compressed profile not yet supported")
|
||||
}
|
||||
return JSON.parse(new TextDecoder().decode(userData))
|
||||
}
|
||||
|
||||
// -- Short link URI parsing (below) --
|
||||
|
||||
export interface ShortLinkServer {
|
||||
hosts: string[]
|
||||
port: string
|
||||
keyHash: Uint8Array
|
||||
}
|
||||
|
||||
export type ConnShortLinkURI =
|
||||
| {mode: "invitation", scheme: ShortLinkScheme, server: ShortLinkServer, linkId: Uint8Array, linkKey: Uint8Array}
|
||||
| {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ShortLinkServer, linkKey: Uint8Array}
|
||||
|
||||
const ctTypeFromChar: Record<string, ContactConnType> = {
|
||||
a: "contact",
|
||||
c: "channel",
|
||||
g: "group",
|
||||
r: "relay",
|
||||
}
|
||||
|
||||
// Mirrors strP for AConnShortLink (Agent/Protocol.hs:1596-1629)
|
||||
export function connShortLinkStrP(uri: string): ConnShortLinkURI {
|
||||
let scheme: ShortLinkScheme
|
||||
let firstHost: string | null = null
|
||||
let rest: string
|
||||
|
||||
if (uri.startsWith("simplex:")) {
|
||||
scheme = "simplex"
|
||||
rest = uri.slice("simplex:".length)
|
||||
} else if (uri.startsWith("https://")) {
|
||||
scheme = "https"
|
||||
const afterScheme = uri.slice("https://".length)
|
||||
const slashIdx = afterScheme.indexOf("/")
|
||||
if (slashIdx < 0) throw new Error("bad short link: no path")
|
||||
firstHost = afterScheme.slice(0, slashIdx)
|
||||
rest = afterScheme.slice(slashIdx)
|
||||
} else {
|
||||
throw new Error("bad short link scheme")
|
||||
}
|
||||
|
||||
if (rest[0] !== "/") throw new Error("bad short link: expected /")
|
||||
const typeChar = rest[1]
|
||||
const hashIdx = rest.indexOf("#")
|
||||
if (hashIdx < 0) throw new Error("bad short link: no #")
|
||||
const afterHash = rest.slice(hashIdx + 1)
|
||||
|
||||
const qIdx = afterHash.indexOf("?")
|
||||
const fragment = qIdx >= 0 ? afterHash.slice(0, qIdx) : afterHash
|
||||
const queryStr = qIdx >= 0 ? afterHash.slice(qIdx + 1) : ""
|
||||
const params = new URLSearchParams(queryStr)
|
||||
|
||||
const hParam = params.get("h")
|
||||
const additionalHosts = hParam ? hParam.split(",") : []
|
||||
const allHosts = firstHost ? [firstHost, ...additionalHosts] : additionalHosts
|
||||
if (allHosts.length === 0) throw new Error("short link without server")
|
||||
|
||||
const port = params.get("p") ?? ""
|
||||
const keyHash = params.has("c") ? base64urlDecode(params.get("c")!) : new Uint8Array(0)
|
||||
const server: ShortLinkServer = {hosts: allHosts, port, keyHash}
|
||||
|
||||
if (typeChar === "i") {
|
||||
const slashIdx = fragment.indexOf("/")
|
||||
if (slashIdx < 0) throw new Error("invitation link must have linkId/linkKey")
|
||||
const linkId = base64urlDecode(fragment.slice(0, slashIdx))
|
||||
const linkKey = base64urlDecode(fragment.slice(slashIdx + 1))
|
||||
return {mode: "invitation", scheme, server, linkId, linkKey}
|
||||
} else {
|
||||
const connType = ctTypeFromChar[typeChar]
|
||||
if (!connType) throw new Error("unknown contact type: " + typeChar)
|
||||
const linkKey = base64urlDecode(fragment)
|
||||
return {mode: "contact", scheme, connType, server, linkKey}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copied verbatim from simplex-chat/packages/simplex-chat-client/typescript/src/queue.ts
|
||||
|
||||
export class Sem {
|
||||
private readonly promises: ((x: unknown) => void)[] = []
|
||||
|
||||
constructor(private permits: number) {}
|
||||
|
||||
signal(): void {
|
||||
this.permits += 1
|
||||
if (this.promises.length > 0) (this.promises.pop() as () => void)()
|
||||
}
|
||||
|
||||
async wait(): Promise<void> {
|
||||
if (this.permits === 0 || this.promises.length > 0) {
|
||||
await new Promise((r) => this.promises.unshift(r))
|
||||
}
|
||||
this.permits -= 1
|
||||
}
|
||||
}
|
||||
|
||||
export type NextIter<T> = {value: T | Promise<T>; done?: false} | {value?: undefined; done: true}
|
||||
|
||||
const queueClosed = Symbol()
|
||||
|
||||
type QueueItem<T> = T | typeof queueClosed
|
||||
|
||||
export class ABQueueError extends Error {}
|
||||
|
||||
export class ABQueue<T> {
|
||||
private readonly queue: QueueItem<T>[] = []
|
||||
private readonly enq: Sem
|
||||
private readonly deq: Sem
|
||||
private enqClosed = false
|
||||
private deqClosed = false
|
||||
|
||||
constructor(readonly maxSize: number) {
|
||||
this.enq = new Sem(0)
|
||||
this.deq = new Sem(maxSize)
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): ABQueue<T> {
|
||||
return this
|
||||
}
|
||||
|
||||
enqueue(x: T): Promise<void> {
|
||||
return this._enqueue(x)
|
||||
}
|
||||
|
||||
private async _enqueue(x: QueueItem<T>): Promise<void> {
|
||||
if (this.enqClosed) throw new ABQueueError("enqueue: queue closed")
|
||||
await this.deq.wait()
|
||||
this.queue.push(x)
|
||||
this.enq.signal()
|
||||
}
|
||||
|
||||
async dequeue(): Promise<T> {
|
||||
if (this.deqClosed) throw new ABQueueError("dequeue: queue closed")
|
||||
this.deq.signal()
|
||||
await this.enq.wait()
|
||||
const x = this.queue.shift() as QueueItem<T>
|
||||
if (x === queueClosed) {
|
||||
this.deqClosed = true
|
||||
throw new ABQueueError("dequeue: queue closed")
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this._enqueue(queueClosed)
|
||||
this.enqClosed = true
|
||||
}
|
||||
|
||||
async next(): Promise<NextIter<T>> {
|
||||
if (this.deqClosed) return {done: true}
|
||||
try {
|
||||
return {value: await this.dequeue()}
|
||||
} catch (e) {
|
||||
if (e instanceof ABQueueError) return {done: true}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Retry interval logic.
|
||||
// Transpilation of Agent/RetryInterval.hs (lines 27-118).
|
||||
// withRetryForeground is skipped (uses registerDelay + STM retry, Haskell-specific).
|
||||
|
||||
import {TMVar} from "./tmvar.js"
|
||||
|
||||
// RetryInterval (RetryInterval.hs:27-31)
|
||||
// All intervals in microseconds (matching Haskell Int64).
|
||||
export interface RetryInterval {
|
||||
initialInterval: number
|
||||
increaseAfter: number
|
||||
maxInterval: number
|
||||
}
|
||||
|
||||
// RetryInterval2 (RetryInterval.hs:33-36)
|
||||
export interface RetryInterval2 {
|
||||
riSlow: RetryInterval
|
||||
riFast: RetryInterval
|
||||
}
|
||||
|
||||
// RI2State (RetryInterval.hs:38-41)
|
||||
export interface RI2State {
|
||||
slowInterval: number
|
||||
fastInterval: number
|
||||
}
|
||||
|
||||
// RetryIntervalMode (RetryInterval.hs:51)
|
||||
export type RetryIntervalMode = "RISlow" | "RIFast"
|
||||
|
||||
// nextRetryDelay (RetryInterval.hs:114-118)
|
||||
export function nextRetryDelay(elapsed: number, delay: number, ri: RetryInterval): number {
|
||||
if (elapsed < ri.increaseAfter || delay === ri.maxInterval) return delay
|
||||
return Math.min(Math.floor(delay * 3 / 2), ri.maxInterval)
|
||||
}
|
||||
|
||||
// updateRetryInterval2 (RetryInterval.hs:44-49)
|
||||
export function updateRetryInterval2(state: RI2State, ri2: RetryInterval2): RetryInterval2 {
|
||||
return {
|
||||
riSlow: {...ri2.riSlow, initialInterval: state.slowInterval, increaseAfter: 0},
|
||||
riFast: {...ri2.riFast, initialInterval: state.fastInterval, increaseAfter: 0},
|
||||
}
|
||||
}
|
||||
|
||||
function delay(us: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, us / 1000))
|
||||
}
|
||||
|
||||
// withRetryInterval (RetryInterval.hs:54-55)
|
||||
export function withRetryInterval(
|
||||
ri: RetryInterval,
|
||||
action: (delay: number, loop: () => Promise<void>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
return withRetryIntervalCount(ri, (_n, d, loop) => action(d, loop))
|
||||
}
|
||||
|
||||
// withRetryIntervalCount (RetryInterval.hs:57-66)
|
||||
export function withRetryIntervalCount(
|
||||
ri: RetryInterval,
|
||||
action: (n: number, delay: number, loop: () => Promise<void>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
function callAction(n: number, elapsed: number, d: number): Promise<void> {
|
||||
return action(n, d, async () => {
|
||||
await delay(d)
|
||||
const elapsed_ = elapsed + d
|
||||
return callAction(n + 1, elapsed_, nextRetryDelay(elapsed_, d, ri))
|
||||
})
|
||||
}
|
||||
return callAction(0, 0, ri.initialInterval)
|
||||
}
|
||||
|
||||
// withRetryLock2 (RetryInterval.hs:90-112)
|
||||
// Two-mode retry with lock. The lock (TMVar<void>) can be released early
|
||||
// by an external signal (e.g., QCONT message), cancelling the timer wait.
|
||||
//
|
||||
// The action receives the current RI2State and a loop function.
|
||||
// Calling loop(mode) sleeps for the appropriate interval, then recurses.
|
||||
// During sleep, if the lock is released externally, sleep ends early.
|
||||
export function withRetryLock2(
|
||||
ri2: RetryInterval2,
|
||||
lock: TMVar<void>,
|
||||
action: (state: RI2State, loop: (mode: RetryIntervalMode) => Promise<void>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
function callAction(slow: [number, number], fast: [number, number]): Promise<void> {
|
||||
return action({slowInterval: slow[1], fastInterval: fast[1]}, (mode) => {
|
||||
if (mode === "RISlow") return run(slow, ri2.riSlow, (s) => callAction(s, fast))
|
||||
return run(fast, ri2.riFast, (f) => callAction(slow, f))
|
||||
})
|
||||
}
|
||||
|
||||
async function run(
|
||||
[elapsed, d]: [number, number],
|
||||
ri: RetryInterval,
|
||||
call: (state: [number, number]) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await wait(d)
|
||||
const elapsed_ = elapsed + d
|
||||
const delay_ = nextRetryDelay(elapsed_, d, ri)
|
||||
return call([elapsed_, delay_])
|
||||
}
|
||||
|
||||
// wait (RetryInterval.hs:105-112)
|
||||
// Race between timer expiry and external lock release.
|
||||
// In Haskell: forkIO sets a timer that puts () into the lock TMVar,
|
||||
// then the main thread takes from the lock (blocking until timer or external signal).
|
||||
async function wait(d: number): Promise<void> {
|
||||
let waiting = true
|
||||
// Start timer that will release the lock after delay
|
||||
const timer = setTimeout(() => {
|
||||
if (waiting) lock.tryPut(undefined as any)
|
||||
}, d / 1000)
|
||||
// Block until lock is released (by timer or externally)
|
||||
await lock.take()
|
||||
waiting = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
return callAction([0, ri2.riSlow.initialInterval], [0, ri2.riFast.initialInterval])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SessionVar — pending protocol client connection tracking.
|
||||
// Transpilation of Simplex.Messaging.Session (Session.hs:18-42).
|
||||
//
|
||||
// SessionVar wraps a Promise that resolves when the client connection is established.
|
||||
// First caller creates it (Left/new), subsequent callers get the existing one (Right/existing)
|
||||
// and await the same Promise.
|
||||
|
||||
export interface SessionVar<T> {
|
||||
id: number
|
||||
ts: number // creation timestamp (ms)
|
||||
promise: Promise<T>
|
||||
resolve: (v: T) => void
|
||||
reject: (e: Error) => void
|
||||
value: T | undefined // set after resolve, for tryRead
|
||||
}
|
||||
|
||||
// getSessVar (Session.hs:24-33)
|
||||
// Get existing SessionVar for key, or create a new empty one.
|
||||
// Returns {isNew: true, v} for new, {isNew: false, v} for existing.
|
||||
// Mirrors Haskell Left (new) / Right (existing).
|
||||
export function getSessVar<T>(
|
||||
seq: {val: number},
|
||||
key: string,
|
||||
vars: Map<string, SessionVar<T>>,
|
||||
): {isNew: boolean, v: SessionVar<T>} {
|
||||
const existing = vars.get(key)
|
||||
if (existing) return {isNew: false, v: existing}
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
// When resolved, store value for tryRead
|
||||
const v: SessionVar<T> = {
|
||||
id: seq.val++,
|
||||
ts: Date.now(),
|
||||
promise,
|
||||
resolve: (val: T) => { v.value = val; resolve(val) },
|
||||
reject,
|
||||
value: undefined,
|
||||
}
|
||||
vars.set(key, v)
|
||||
return {isNew: true, v}
|
||||
}
|
||||
|
||||
// removeSessVar (Session.hs:35-39)
|
||||
// Remove only if the ID matches — guards against removing a replaced session.
|
||||
export function removeSessVar<T>(
|
||||
v: SessionVar<T>,
|
||||
key: string,
|
||||
vars: Map<string, SessionVar<T>>,
|
||||
): void {
|
||||
const current = vars.get(key)
|
||||
if (current && current.id === v.id) vars.delete(key)
|
||||
}
|
||||
|
||||
// tryReadSessVar (Session.hs:41-42)
|
||||
// Non-blocking read of resolved value. Returns undefined if not yet resolved.
|
||||
export function tryReadSessVar<T>(
|
||||
key: string,
|
||||
vars: Map<string, SessionVar<T>>,
|
||||
): T | undefined {
|
||||
return vars.get(key)?.value
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// SMP session management and queue operations.
|
||||
// Transpilation of Agent/Client.hs: getSMPServerClient, agentCbEncrypt/Decrypt,
|
||||
// sendConfirmation, sendAgentMessage, newRcvQueue, subscribeQueues, etc.
|
||||
|
||||
import type {SMPClient, ProxiedRelay} from "../client.js"
|
||||
import {createSMPClient} from "../client.js"
|
||||
import type {AuthKey, SMPResponse} from "../protocol.js"
|
||||
import {
|
||||
encodeClientMsgEnvelope, encodeClientMessage,
|
||||
type ClientMsgEnvelope, type ClientMessage, type PubHeader, type PrivHeader,
|
||||
} from "../protocol.js"
|
||||
import {getSessVar, removeSessVar, tryReadSessVar, type SessionVar} from "./session.js"
|
||||
import {AgentError, type AgentClient, type AgentErrorType} from "./client.js"
|
||||
import {ABQueue} from "./queue.js"
|
||||
import type {RcvQueueSub} from "./subscriptions.js"
|
||||
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {generateX25519KeyPair, generateEd25519KeyPair, dh, encodePubKeyX25519, encodePubKeyEd25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
import {concatBytes, encodeBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Transport session key (simplified: one session per server, no TSMEntity)
|
||||
|
||||
export function tSessKey(userId: number, server: string): string {
|
||||
return `${userId}:${server}`
|
||||
}
|
||||
|
||||
// -- SMP connected client with proxy relay sessions
|
||||
|
||||
export interface SMPConnectedClient {
|
||||
client: SMPClient
|
||||
proxiedRelays: Map<string, SessionVar<ProxiedRelay | AgentErrorType>>
|
||||
}
|
||||
|
||||
// -- Server message for msgQ (dispatched by subscriber loop)
|
||||
|
||||
export interface ServerMsg {
|
||||
userId: number
|
||||
server: string
|
||||
sessionId: Uint8Array
|
||||
entityId: Uint8Array
|
||||
msg: SMPResponse
|
||||
}
|
||||
|
||||
// -- getSMPServerClient (Client.hs:642-651)
|
||||
// Get or create SMP client for the given server.
|
||||
// Returns existing if connected, waits if pending, connects if new.
|
||||
export async function getSMPServerClient(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
keyHash: Uint8Array,
|
||||
wsUrl: string,
|
||||
): Promise<SMPConnectedClient> {
|
||||
if (!c.active) throw new AgentError({tag: "INACTIVE"})
|
||||
const key = tSessKey(userId, server)
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
const {isNew, v} = getSessVar(c.workerSeq, key, clients)
|
||||
if (isNew) {
|
||||
return smpConnectClient(c, userId, server, keyHash, wsUrl, key, v)
|
||||
}
|
||||
return waitForSMPClient(server, v)
|
||||
}
|
||||
|
||||
// smpConnectClient (Client.hs:704-718)
|
||||
async function smpConnectClient(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
keyHash: Uint8Array,
|
||||
wsUrl: string,
|
||||
key: string,
|
||||
v: SessionVar<SMPConnectedClient>,
|
||||
): Promise<SMPConnectedClient> {
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
try {
|
||||
const smp = await createSMPClient(
|
||||
wsUrl, keyHash,
|
||||
// onMessage (Client.hs:716 — messages go to msgQ)
|
||||
(entityId: Uint8Array, msg: SMPResponse) => {
|
||||
const serverMsg: ServerMsg = {userId, server, sessionId: smp.sessionId, entityId, msg}
|
||||
c.msgQ.enqueue(serverMsg)
|
||||
},
|
||||
// onDisconnected (Client.hs:720-754 — smpClientDisconnected)
|
||||
() => smpClientDisconnected(c, userId, server, key, v, smp),
|
||||
)
|
||||
// setSessionId in subscription tracker (Client.hs:717)
|
||||
c.currentSubs.setSessionId(tSessKey(userId, server), smp.sessionId)
|
||||
const connected: SMPConnectedClient = {client: smp, proxiedRelays: new Map()}
|
||||
v.resolve(connected)
|
||||
// Notify CONNECT (Client.hs:884)
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "CONNECT", server}])
|
||||
return connected
|
||||
} catch (e) {
|
||||
// Connection failed (Client.hs:886-895)
|
||||
removeSessVar(v, key, clients)
|
||||
v.reject(e instanceof Error ? e : new Error(String(e)))
|
||||
throw new AgentError({tag: "BROKER", addr: server, err: "NETWORK"})
|
||||
}
|
||||
}
|
||||
|
||||
// waitForProtocolClient (Client.hs:847-868)
|
||||
async function waitForSMPClient(
|
||||
server: string,
|
||||
v: SessionVar<SMPConnectedClient>,
|
||||
): Promise<SMPConnectedClient> {
|
||||
try {
|
||||
return await v.promise
|
||||
} catch {
|
||||
throw new AgentError({tag: "BROKER", addr: server, err: "NETWORK"})
|
||||
}
|
||||
}
|
||||
|
||||
// smpClientDisconnected (Client.hs:720-754)
|
||||
// Handle WebSocket disconnect: move subs to pending, notify DOWN, remove client.
|
||||
function smpClientDisconnected(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
key: string,
|
||||
v: SessionVar<SMPConnectedClient>,
|
||||
smp: SMPClient,
|
||||
): void {
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
// removeSessVar (only if this is still the current client)
|
||||
removeSessVar(v, key, clients)
|
||||
if (!c.active) return
|
||||
// Move active subs to pending (Client.hs:731-737)
|
||||
const tSess = tSessKey(userId, server)
|
||||
const moved = c.currentSubs.setSubsPending(tSess, smp.sessionId)
|
||||
// Notify DISCONNECT (Client.hs:746)
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "DISCONNECT", server}])
|
||||
if (moved.size > 0) {
|
||||
// Notify DOWN with affected connIds (Client.hs:747)
|
||||
const connIds = [...new Set([...moved.values()].map(rq => toHex(rq.connId)))]
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "DOWN", server, connIds}])
|
||||
// TODO: trigger resubscription (Client.hs:750-754)
|
||||
}
|
||||
}
|
||||
|
||||
// -- agentCbEncrypt (Client.hs:2074-2082)
|
||||
// Per-queue E2E encrypt with stored DH secret.
|
||||
// e2ePubKey is the RAW 32-byte X25519 public key (or null for messages).
|
||||
// Haskell smpEncode of PubHeader's `Maybe C.PublicKeyX25519` DER-encodes the key
|
||||
// (Crypto.hs:568-570), so we DER-encode it before placing it in the header.
|
||||
// Returns encoded ClientMsgEnvelope.
|
||||
export function agentCbEncrypt(
|
||||
e2eDhSecret: Uint8Array,
|
||||
smpClientVersion: number,
|
||||
e2ePubKey: Uint8Array | null,
|
||||
msg: Uint8Array,
|
||||
): Uint8Array {
|
||||
const cmNonce = crypto.getRandomValues(new Uint8Array(24))
|
||||
// paddedLen: e2eEncConfirmationLength (15904) for confirmations, e2eEncMessageLength (16000) for messages
|
||||
// Protocol.hs:316-320
|
||||
const paddedLen = e2ePubKey !== null ? 15904 : 16000
|
||||
const cmEncBody = cbEncrypt(e2eDhSecret, cmNonce, msg, paddedLen)
|
||||
const env: ClientMsgEnvelope = {
|
||||
cmHeader: {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey !== null ? encodePubKeyX25519(e2ePubKey) : null},
|
||||
cmNonce,
|
||||
cmEncBody,
|
||||
}
|
||||
return encodeClientMsgEnvelope(env)
|
||||
}
|
||||
|
||||
// agentCbEncryptOnce (Client.hs:2085-2095)
|
||||
// Per-queue E2E encrypt with ephemeral DH key (for invitations).
|
||||
export function agentCbEncryptOnce(
|
||||
clientVersion: number,
|
||||
dhRcvPubKey: Uint8Array,
|
||||
msg: Uint8Array,
|
||||
): Uint8Array {
|
||||
const {publicKey: dhSndPubKey, privateKey: dhSndPrivKey} = generateX25519KeyPair()
|
||||
const e2eDhSecret = dh(dhRcvPubKey, dhSndPrivKey)
|
||||
return agentCbEncrypt(e2eDhSecret, clientVersion, dhSndPubKey, msg)
|
||||
}
|
||||
|
||||
// agentCbDecrypt (Client.hs:2099-2102)
|
||||
export function agentCbDecrypt(
|
||||
dhSecret: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
msg: Uint8Array,
|
||||
): Uint8Array {
|
||||
const result = cbDecrypt(dhSecret, nonce, msg)
|
||||
if (result === null) throw new AgentError({tag: "AGENT", err: {tag: "A_CRYPTO", err: "DECRYPT_CB"}})
|
||||
return result
|
||||
}
|
||||
|
||||
// -- sendAgentMessage (Client.hs:1948-1952)
|
||||
// Per-queue E2E encrypt message + SEND.
|
||||
// sq is raw IDB SndQueue row.
|
||||
export async function sendAgentMessage(
|
||||
c: AgentClient,
|
||||
sq: any,
|
||||
msgFlags: {notification: boolean},
|
||||
agentMsg: Uint8Array,
|
||||
): Promise<void> {
|
||||
const clientMsg: ClientMessage = {privHeader: {type: "PHEmpty"}, body: agentMsg}
|
||||
const msg = agentCbEncrypt(sq.e2e_dh_secret, sq.smp_client_version, null, encodeClientMessage(clientMsg))
|
||||
const smp = await getClientForQueue(c, sq)
|
||||
const privKey: AuthKey = {type: "ed25519", key: sq.snd_private_key}
|
||||
await smp.sendMessage(privKey, sq.snd_id, msgFlags.notification, msg)
|
||||
}
|
||||
|
||||
// sendConfirmation (Client.hs:1788-1794)
|
||||
export async function sendConfirmation(
|
||||
c: AgentClient,
|
||||
sq: any,
|
||||
agentConfirmation: Uint8Array,
|
||||
): Promise<void> {
|
||||
if (!sq.e2e_pub_key) throw new AgentError({tag: "INTERNAL", msg: "sendConfirmation: no e2e pub key"})
|
||||
const senderCanSecure_ = sq.queue_mode === "M"
|
||||
// PHConfirmation carries C.toPublic sndPrivateKey, DER-encoded by smpEncode (Crypto.hs:568-570).
|
||||
// (Only used for non-messaging queues; messaging queues use PHEmpty.)
|
||||
const privHeader: PrivHeader = senderCanSecure_
|
||||
? {type: "PHEmpty"}
|
||||
: {type: "PHConfirmation", key: encodePubKeyEd25519(toPublicEd25519(sq.snd_private_key))}
|
||||
const spKey: AuthKey | null = senderCanSecure_ ? {type: "ed25519", key: sq.snd_private_key} : null
|
||||
const clientMsg: ClientMessage = {privHeader, body: agentConfirmation}
|
||||
const msg = agentCbEncrypt(sq.e2e_dh_secret, sq.smp_client_version, sq.e2e_pub_key, encodeClientMessage(clientMsg))
|
||||
const smp = await getClientForQueue(c, sq)
|
||||
await smp.sendMessage(spKey, sq.snd_id, true, msg)
|
||||
}
|
||||
|
||||
// secureQueue (Client.hs:1830-1833)
|
||||
export async function secureQueue(
|
||||
c: AgentClient,
|
||||
rq: any,
|
||||
senderKey: Uint8Array,
|
||||
): Promise<void> {
|
||||
const smp = await getClientForQueue(c, rq)
|
||||
await smp.secureQueue({type: "ed25519", key: rq.rcv_private_key}, rq.rcv_id, senderKey)
|
||||
}
|
||||
|
||||
// secureSndQueue (Client.hs:1835-1841)
|
||||
export async function secureSndQueue(
|
||||
c: AgentClient,
|
||||
sq: any,
|
||||
): Promise<void> {
|
||||
const smp = await getClientForQueue(c, sq)
|
||||
await smp.secureSndQueue({type: "ed25519", key: sq.snd_private_key}, sq.snd_id)
|
||||
}
|
||||
|
||||
// sendAck (Client.hs:1904-1907)
|
||||
export async function sendAck(
|
||||
c: AgentClient,
|
||||
rq: any,
|
||||
msgId: Uint8Array,
|
||||
): Promise<void> {
|
||||
const smp = await getClientForQueue(c, rq)
|
||||
await smp.ackMessage({type: "ed25519", key: rq.rcv_private_key}, rq.rcv_id, msgId)
|
||||
}
|
||||
|
||||
// -- subscribeQueues (Client.hs:1543-1556)
|
||||
export async function subscribeQueues(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
queues: RcvQueueSub[],
|
||||
): Promise<void> {
|
||||
const byServer = new Map<string, RcvQueueSub[]>()
|
||||
for (const q of queues) {
|
||||
const list = byServer.get(q.server) ?? []
|
||||
list.push(q)
|
||||
byServer.set(q.server, list)
|
||||
}
|
||||
for (const [server, qs] of byServer) {
|
||||
c.currentSubs.batchAddPendingSubs(tSessKey(userId, server), qs)
|
||||
}
|
||||
for (const [server, qs] of byServer) {
|
||||
await subscribeServerQueues(c, userId, server, qs)
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeServerQueues(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
queues: RcvQueueSub[],
|
||||
): Promise<void> {
|
||||
const key = tSessKey(userId, server)
|
||||
const existing = tryReadSessVar(key, c.smpClients as Map<string, SessionVar<SMPConnectedClient>>)
|
||||
if (!existing) return
|
||||
const smp = existing.client
|
||||
const subReqs = queues.map(q => ({rcvId: q.rcvId, privKey: {type: "ed25519" as const, key: q.rcvPrivateKey}}))
|
||||
try {
|
||||
await smp.subscribeQueues(subReqs)
|
||||
c.currentSubs.batchAddActiveSubs(key, smp.sessionId, queues)
|
||||
} catch {
|
||||
// On error, subs stay pending
|
||||
}
|
||||
}
|
||||
|
||||
// addNewQueueSubscription (Client.hs:1724-1728)
|
||||
export function addNewQueueSubscription(
|
||||
c: AgentClient,
|
||||
rq: RcvQueueSub,
|
||||
userId: number,
|
||||
server: string,
|
||||
sessionId: Uint8Array,
|
||||
): void {
|
||||
c.currentSubs.addActiveSub(tSessKey(userId, server), sessionId, rq)
|
||||
}
|
||||
|
||||
// -- newRcvQueue (Client.hs:1373-1435, simplified: no short links, no ntf credentials)
|
||||
|
||||
export interface NewRcvQueueResult {
|
||||
rcvQueue: any
|
||||
sndId: Uint8Array
|
||||
e2eDhKey: Uint8Array
|
||||
sessionId: Uint8Array
|
||||
}
|
||||
|
||||
export async function newRcvQueue(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
connId: Uint8Array,
|
||||
server: string,
|
||||
keyHash: Uint8Array,
|
||||
wsUrl: string,
|
||||
subscribe: boolean,
|
||||
): Promise<NewRcvQueueResult> {
|
||||
const {publicKey: rcvPubKey, privateKey: rcvPrivateKey} = generateEd25519KeyPair()
|
||||
const {publicKey: dhPubKey, privateKey: dhPrivKey} = generateX25519KeyPair()
|
||||
const {publicKey: e2eDhKey, privateKey: e2ePrivKey} = generateX25519KeyPair()
|
||||
|
||||
const smpConn = await getSMPServerClient(c, userId, server, keyHash, wsUrl)
|
||||
const smp = smpConn.client
|
||||
|
||||
const ids = await smp.createQueue({publicKey: rcvPubKey, privateKey: rcvPrivateKey}, dhPubKey, subscribe)
|
||||
|
||||
const rcvDhSecret = dh(ids.srvDhKey, dhPrivKey)
|
||||
const rcvQueue = {
|
||||
host: server, port: "443",
|
||||
rcv_id: ids.rcvId,
|
||||
conn_id: connId,
|
||||
rcv_private_key: rcvPrivateKey,
|
||||
rcv_dh_secret: rcvDhSecret,
|
||||
e2e_priv_key: e2ePrivKey,
|
||||
e2e_dh_secret: null,
|
||||
snd_id: ids.sndId,
|
||||
snd_key: null,
|
||||
status: "new",
|
||||
// Haskell newRcvQueue_: smpClientVersion = maxVersion vRange (= maxVersion smpClientVRange).
|
||||
// This is VersionSMPC (used in the per-queue PubHeader), NOT the SMP transport version.
|
||||
smp_client_version: c.config.smpClientVRange[1],
|
||||
rcv_queue_id: 0,
|
||||
rcv_primary: 1,
|
||||
replace_rcv_queue_id: null,
|
||||
queue_mode: ids.queueMode,
|
||||
server_key_hash: keyHash,
|
||||
last_broker_ts: null,
|
||||
to_subscribe: subscribe ? 0 : 1,
|
||||
deleted: 0,
|
||||
}
|
||||
|
||||
return {rcvQueue, sndId: ids.sndId, e2eDhKey, sessionId: smp.sessionId}
|
||||
}
|
||||
|
||||
// -- Helpers
|
||||
|
||||
async function getClientForQueue(c: AgentClient, q: any): Promise<SMPClient> {
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
for (const [key, sv] of clients) {
|
||||
if (sv.value && key.endsWith(":" + q.host)) return sv.value.client
|
||||
}
|
||||
throw new AgentError({tag: "INTERNAL", msg: "no SMP client for " + q.host})
|
||||
}
|
||||
|
||||
// Ed25519 public key from 64-byte private key (NaCl convention: last 32 bytes)
|
||||
function toPublicEd25519(privateKey: Uint8Array): Uint8Array {
|
||||
return privateKey.slice(32, 64)
|
||||
}
|
||||
|
||||
function toHex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Agent store interface for IndexedDB.
|
||||
// Each method mirrors a Haskell function in AgentStore.hs.
|
||||
// Implementation in store-idb.ts.
|
||||
|
||||
// -- Types matching Haskell store types
|
||||
|
||||
export type ConnId = Uint8Array
|
||||
export type UserId = number
|
||||
export type EntityId = Uint8Array
|
||||
export type InternalId = number
|
||||
export type InternalRcvId = number
|
||||
export type InternalSndId = number
|
||||
|
||||
export type QueueStatus = "new" | "confirmed" | "secured" | "active" | "disabled" | "deleted"
|
||||
|
||||
// Matches Haskell SkippedMsgDiff (Crypto/Ratchet.hs:584-587)
|
||||
export type SkippedMsgDiff =
|
||||
| {type: "noChange"}
|
||||
| {type: "remove", headerKey: Uint8Array, msgN: number}
|
||||
| {type: "add", keys: Map<Uint8Array, Map<number, any>>} // Map<HeaderKey, Map<MsgN, MessageKey>>
|
||||
export type ConnectionMode = "INV" | "CON" // SCMInvitation | SCMContact
|
||||
export type RatchetSyncState = "ok" | "allowed" | "required" | "started" | "agreed"
|
||||
|
||||
export interface ConnData {
|
||||
connId: ConnId
|
||||
connMode: ConnectionMode
|
||||
userId: UserId
|
||||
smpAgentVersion: number
|
||||
enableNtfs: boolean
|
||||
duplexHandshake: boolean
|
||||
deleted: boolean
|
||||
ratchetSyncState: RatchetSyncState
|
||||
pqSupport: boolean
|
||||
// Message ID counters
|
||||
lastInternalMsgId: number
|
||||
lastInternalRcvMsgId: number
|
||||
lastInternalSndMsgId: number
|
||||
lastExternalSndMsgId: number
|
||||
lastRcvMsgHash: Uint8Array
|
||||
lastSndMsgHash: Uint8Array
|
||||
}
|
||||
|
||||
export interface RcvQueue {
|
||||
host: string
|
||||
port: string
|
||||
rcvId: Uint8Array
|
||||
connId: ConnId
|
||||
rcvPrivateKey: Uint8Array
|
||||
rcvDhSecret: Uint8Array
|
||||
e2ePrivKey: Uint8Array
|
||||
e2eDhSecret: Uint8Array | null
|
||||
sndId: Uint8Array
|
||||
sndKey: Uint8Array | null
|
||||
status: QueueStatus
|
||||
smpClientVersion: number | null
|
||||
dbQueueId: number
|
||||
primary: boolean
|
||||
replaceRcvQueueId: number | null
|
||||
queueMode: string | null
|
||||
serverKeyHash: Uint8Array | null
|
||||
lastBrokerTs: string | null
|
||||
}
|
||||
|
||||
export interface SndQueue {
|
||||
host: string
|
||||
port: string
|
||||
sndId: Uint8Array
|
||||
connId: ConnId
|
||||
sndPrivateKey: Uint8Array
|
||||
e2eDhSecret: Uint8Array
|
||||
status: QueueStatus
|
||||
smpClientVersion: number
|
||||
sndPublicKey: Uint8Array | null
|
||||
e2ePubKey: Uint8Array | null
|
||||
dbQueueId: number
|
||||
primary: boolean
|
||||
queueMode: string | null
|
||||
serverKeyHash: Uint8Array | null
|
||||
}
|
||||
|
||||
export interface MsgMeta {
|
||||
integrity: string // "OK" or error
|
||||
recipient: [number, string] // (internalId, internalTs)
|
||||
broker: [Uint8Array, string] // (brokerId/msgId, brokerTs)
|
||||
sndMsgId: number
|
||||
pqEncryption: boolean
|
||||
}
|
||||
|
||||
export interface RcvMsgData {
|
||||
msgMeta: MsgMeta
|
||||
msgType: string
|
||||
msgFlags: number
|
||||
msgBody: Uint8Array
|
||||
internalRcvId: number
|
||||
internalHash: Uint8Array
|
||||
externalPrevSndHash: Uint8Array
|
||||
encryptedMsgHash: Uint8Array
|
||||
}
|
||||
|
||||
export interface SndMsgData {
|
||||
internalId: number
|
||||
internalSndId: number
|
||||
internalTs: string
|
||||
msgType: string
|
||||
msgFlags: number
|
||||
msgBody: Uint8Array
|
||||
pqEncryption: boolean
|
||||
internalHash: Uint8Array
|
||||
prevMsgHash: Uint8Array
|
||||
msgEncryptKey: Uint8Array | null
|
||||
paddedMsgLen: number | null
|
||||
sndMessageBodyId: number | null
|
||||
}
|
||||
|
||||
export interface Confirmation {
|
||||
confirmationId: Uint8Array
|
||||
connId: ConnId
|
||||
e2eSndPubKey: Uint8Array
|
||||
senderKey: Uint8Array | null
|
||||
ratchetState: Uint8Array
|
||||
senderConnInfo: Uint8Array
|
||||
accepted: boolean
|
||||
ownConnInfo: Uint8Array | null
|
||||
smpReplyQueues: Uint8Array | null // serialized
|
||||
smpClientVersion: number | null
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
invitationId: Uint8Array
|
||||
contactConnId: ConnId | null
|
||||
crInvitation: Uint8Array
|
||||
recipientConnInfo: Uint8Array
|
||||
accepted: boolean
|
||||
ownConnInfo: Uint8Array | null
|
||||
}
|
||||
|
||||
export interface RcvMsg {
|
||||
internalId: number
|
||||
msgMeta: MsgMeta
|
||||
msgType: string
|
||||
msgBody: Uint8Array
|
||||
internalHash: Uint8Array
|
||||
userAck: boolean
|
||||
msgReceipt: {agentMsgId: number, msgRcptStatus: string} | null
|
||||
}
|
||||
|
||||
export interface PendingQueueMsg {
|
||||
connId: ConnId
|
||||
sndQueueId: number
|
||||
internalId: number
|
||||
internalTs: string
|
||||
internalSndId: number
|
||||
msgType: string
|
||||
msgFlags: number
|
||||
msgBody: Uint8Array
|
||||
internalHash: Uint8Array
|
||||
prevMsgHash: Uint8Array
|
||||
pqEncryption: boolean
|
||||
retryIntSlow: number | null
|
||||
retryIntFast: number | null
|
||||
msgEncryptKey: Uint8Array | null
|
||||
paddedMsgLen: number | null
|
||||
sndMsgBody: Uint8Array | null // agent_msg from snd_message_bodies (joined)
|
||||
}
|
||||
|
||||
export interface AsyncCommand {
|
||||
commandId: number
|
||||
connId: ConnId
|
||||
host: string | null
|
||||
port: string | null
|
||||
corrId: Uint8Array
|
||||
commandTag: string
|
||||
command: Uint8Array
|
||||
agentVersion: number
|
||||
serverKeyHash: Uint8Array | null
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
// -- Store interface
|
||||
// Each method name matches the Haskell function in AgentStore.hs.
|
||||
|
||||
export interface AgentStore {
|
||||
// -- Users (AgentStore.hs:201-230)
|
||||
createUserRecord(): Promise<UserId>
|
||||
getUserIds(): Promise<UserId[]>
|
||||
deleteUserRecord(userId: UserId): Promise<void>
|
||||
setUserDeleted(userId: UserId): Promise<ConnId[]>
|
||||
|
||||
// -- Servers (AgentStore.hs:233-240)
|
||||
createServer(host: string, port: string, keyHash: Uint8Array): Promise<void>
|
||||
|
||||
// -- Connections (AgentStore.hs:242-500)
|
||||
createNewConn(connData: ConnData, connMode: ConnectionMode): Promise<ConnId>
|
||||
getConn(connId: ConnId): Promise<{connData: ConnData, rcvQueues: RcvQueue[], sndQueues: SndQueue[]} | null>
|
||||
getRcvConn(host: string, port: string, rcvId: Uint8Array): Promise<{connData: ConnData, rcvQueue: RcvQueue} | null>
|
||||
getConnSubs(connIds: ConnId[]): Promise<Map<string, ConnData>>
|
||||
getConnsData(connIds: ConnId[]): Promise<Map<string, ConnData>>
|
||||
lockConnForUpdate(connId: ConnId): Promise<void> // no-op in IndexedDB (single-threaded)
|
||||
setConnDeleted(connId: ConnId, waitDelivery: boolean): Promise<void>
|
||||
setConnUserId(oldUserId: UserId, connId: ConnId, newUserId: UserId): Promise<void>
|
||||
setConnAgentVersion(connId: ConnId, version: number): Promise<void>
|
||||
setConnPQSupport(connId: ConnId, pqSupport: boolean): Promise<void>
|
||||
setConnRatchetSync(connId: ConnId, state: RatchetSyncState): Promise<void>
|
||||
updateNewConnJoin(connId: ConnId, agentVersion: number, pqSupport: boolean, enableNtfs: boolean): Promise<void>
|
||||
updateNewConnRcv(connId: ConnId, rcvQueue: RcvQueue, subMode: string): Promise<RcvQueue>
|
||||
getDeletedConnIds(): Promise<ConnId[]>
|
||||
getDeletedWaitingDeliveryConnIds(): Promise<ConnId[]>
|
||||
getConnIds(): Promise<ConnId[]>
|
||||
|
||||
// -- Queues (AgentStore.hs:500-700)
|
||||
addConnRcvQueue(connId: ConnId, rcvQueue: RcvQueue, subMode: string): Promise<RcvQueue>
|
||||
addConnSndQueue(connId: ConnId, sndQueue: SndQueue): Promise<SndQueue>
|
||||
setRcvQueueStatus(rcvQueue: RcvQueue, status: QueueStatus): Promise<void>
|
||||
setSndQueueStatus(sndQueue: SndQueue, status: QueueStatus): Promise<void>
|
||||
setRcvQueueConfirmedE2E(rcvQueue: RcvQueue, dhSecret: Uint8Array, smpClientVersion: number): Promise<void>
|
||||
setRcvQueuePrimary(connId: ConnId, rcvQueue: RcvQueue): Promise<void>
|
||||
deleteConnRcvQueue(rcvQueue: RcvQueue): Promise<void>
|
||||
deleteConnRecord(connId: ConnId): Promise<void>
|
||||
upgradeRcvConnToDuplex(connId: ConnId, sndQueue: SndQueue): Promise<SndQueue>
|
||||
upgradeSndConnToDuplex(connId: ConnId, rcvQueue: RcvQueue, subMode: string): Promise<RcvQueue>
|
||||
getPrimaryRcvQueue(connId: ConnId): Promise<RcvQueue | null>
|
||||
getRcvQueue(connId: ConnId, host: string, port: string, rcvId: Uint8Array): Promise<RcvQueue | null>
|
||||
getDeletedRcvQueue(connId: ConnId, host: string, port: string, rcvId: Uint8Array): Promise<RcvQueue | null>
|
||||
setConnectionNtfs(connId: ConnId, enable: boolean): Promise<void>
|
||||
|
||||
// -- Subscriptions (AgentStore.hs:700-800)
|
||||
getSubscriptionServers(onlyNeeded: boolean): Promise<Array<{userId: UserId, host: string, port: string, keyHash: Uint8Array}>>
|
||||
getUserServerRcvQueueSubs(userId: UserId, host: string, port: string, keyHash: Uint8Array, onlyNeeded: boolean, batchSize: number, cursor: number | null): Promise<{queues: RcvQueue[], nextCursor: number | null}>
|
||||
unsetQueuesToSubscribe(): Promise<void>
|
||||
getConnectionsForDelivery(): Promise<ConnId[]>
|
||||
getAllSndQueuesForDelivery(): Promise<SndQueue[]>
|
||||
|
||||
// -- Confirmations (AgentStore.hs:800-870)
|
||||
createConfirmation(confirmation: Confirmation): Promise<Uint8Array>
|
||||
acceptConfirmation(confirmationId: Uint8Array, ownConnInfo: Uint8Array): Promise<Confirmation>
|
||||
getAcceptedConfirmation(connId: ConnId): Promise<Confirmation | null>
|
||||
removeConfirmations(connId: ConnId): Promise<void>
|
||||
|
||||
// -- Invitations (AgentStore.hs:870-920)
|
||||
createInvitation(invitation: Invitation): Promise<Uint8Array>
|
||||
getInvitation(invitationId: Uint8Array): Promise<Invitation | null>
|
||||
acceptInvitation(invitationId: Uint8Array, ownConnInfo: Uint8Array): Promise<void>
|
||||
unacceptInvitation(invitationId: Uint8Array): Promise<void>
|
||||
deleteInvitation(invitationId: Uint8Array): Promise<void>
|
||||
|
||||
// -- Messages (AgentStore.hs:873-1050)
|
||||
updateRcvIds(connId: ConnId): Promise<{internalId: number, internalRcvId: number, prevExternalSndId: number, prevRcvMsgHash: Uint8Array}>
|
||||
createRcvMsg(connId: ConnId, rcvQueue: RcvQueue, rcvMsgData: RcvMsgData): Promise<void>
|
||||
setLastBrokerTs(connId: ConnId, dbQueueId: number, brokerTs: string): Promise<void>
|
||||
updateRcvMsgHash(connId: ConnId, sndMsgId: number, internalRcvId: number, hash: Uint8Array): Promise<void>
|
||||
createSndMsgBody(agentMsg: Uint8Array): Promise<number>
|
||||
updateSndIds(connId: ConnId): Promise<{internalId: number, internalSndId: number, prevSndMsgHash: Uint8Array}>
|
||||
createSndMsg(connId: ConnId, sndMsgData: SndMsgData): Promise<void>
|
||||
updateSndMsgHash(connId: ConnId, internalSndId: number, hash: Uint8Array): Promise<void>
|
||||
createSndMsgDelivery(connId: ConnId, sndQueue: SndQueue, internalId: number): Promise<void>
|
||||
getPendingQueueMsg(connId: ConnId, sndQueue: SndQueue): Promise<{rcvQueue: RcvQueue | null, msg: PendingQueueMsg} | null>
|
||||
updatePendingMsgRIState(connId: ConnId, msgId: number, retryIntSlow: number | null, retryIntFast: number | null): Promise<void>
|
||||
setMsgUserAck(connId: ConnId, internalId: number): Promise<{rcvQueue: RcvQueue, brokerId: Uint8Array}>
|
||||
getRcvMsg(connId: ConnId, internalId: number): Promise<RcvMsg | null>
|
||||
getLastMsg(connId: ConnId, brokerId: Uint8Array): Promise<RcvMsg | null>
|
||||
incMsgRcvAttempts(connId: ConnId, internalId: number): Promise<number>
|
||||
checkRcvMsgHashExists(connId: ConnId, hash: Uint8Array): Promise<boolean>
|
||||
getRcvMsgBrokerTs(connId: ConnId, brokerId: Uint8Array): Promise<string | null>
|
||||
deleteMsg(connId: ConnId, internalId: number): Promise<void>
|
||||
deleteDeliveredSndMsg(connId: ConnId, internalId: number): Promise<void>
|
||||
deleteSndMsgDelivery(connId: ConnId, sndQueue: SndQueue, msgId: number, keepForReceipt: boolean): Promise<void>
|
||||
getSndMsgViaRcpt(connId: ConnId, sndMsgId: number): Promise<{internalId: number, msgType: string, internalHash: Uint8Array, msgReceipt: {agentMsgId: number, msgRcptStatus: string} | null} | null>
|
||||
updateSndMsgRcpt(connId: ConnId, sndMsgId: number, receipt: {agentMsgId: number, msgRcptStatus: string}): Promise<void>
|
||||
|
||||
// -- Ratchet (AgentStore.hs:1300-1400)
|
||||
createRatchetX3dhKeys(connId: ConnId, privKey1: Uint8Array, privKey2: Uint8Array, pqKem: Uint8Array | null): Promise<void>
|
||||
getRatchetX3dhKeys(connId: ConnId): Promise<{privKey1: Uint8Array, privKey2: Uint8Array, pqKem: Uint8Array | null} | null>
|
||||
createRatchet(connId: ConnId, ratchetState: Uint8Array): Promise<void>
|
||||
getRatchet(connId: ConnId): Promise<Uint8Array | null>
|
||||
getRatchetForUpdate(connId: ConnId): Promise<Uint8Array | null> // same as getRatchet in IndexedDB (single-threaded)
|
||||
getSkippedMsgKeys(connId: ConnId): Promise<Map<string, Map<number, {mk: Uint8Array, iv: Uint8Array}>>>
|
||||
updateRatchet(connId: ConnId, ratchetState: Uint8Array, skippedMsgDiff: SkippedMsgDiff): Promise<void>
|
||||
|
||||
// -- Commands (AgentStore.hs:1400-1480)
|
||||
createCommand(corrId: Uint8Array, connId: ConnId, host: string | null, port: string | null, command: AsyncCommand): Promise<number>
|
||||
getPendingCommandServers(connIds: ConnId[]): Promise<Array<{connId: ConnId, host: string, port: string}>>
|
||||
getAllPendingCommandConns(): Promise<Array<{connId: ConnId, host: string, port: string}>>
|
||||
getPendingServerCommand(connId: ConnId, host: string | null, port: string | null): Promise<AsyncCommand | null>
|
||||
updateCommandServer(commandId: number, host: string, port: string): Promise<void>
|
||||
deleteCommand(commandId: number): Promise<void>
|
||||
|
||||
// -- Encrypted message hash dedup (AgentStore.hs:1200-1220)
|
||||
checkRcvMsgHashExists_encrypted(connId: ConnId, hash: Uint8Array): Promise<boolean>
|
||||
addEncryptedRcvMsgHash(connId: ConnId, hash: Uint8Array): Promise<void>
|
||||
}
|
||||