Compare commits

...
21 Commits
Author SHA1 Message Date
EvgenyandEvgeny @ SimpleX Chat e56c12ab3c smp web: client with tests (#1782)
* smp web: client with tests

* fixes

* support batching and SMP proxy

---------

Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-05-22 09:59:31 +01:00
EvgenyandEvgeny @ SimpleX Chat 34e3d30c78 smp web: implement protocol encodings and encryption end-to-end (#1778)
* smp web: protocol encodings and x3dh

* fix

* strnup761 compiled to wasm

* AES-256-GCM, comatibility tests

* core of double ratchet

* PQ double ratchet

* test typescript ratchets

* agent encoding/encryption stack with test

---------

Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-05-16 18:18:45 +01:00
Evgeny Poberezkin a0af7377ab Merge branch 'master' into smp-web 2026-05-11 18:25:19 +01:00
EvgenyandEvgeny @ SimpleX Chat 3f40febe60 smp web: spike (#1745)
* smp web: initial setup

* encoding/decoding of LGET/LNK

* SMP over websocket handshake works

* parse short connection links

* hkdf for short links

* refactor hkdf

* decrypt link data

* fetch link data via websocket

* signed challenge in web handshake

* test: skip smp-web tests when TS not built

* block encryption

* gitignore

* parse FixedLinkData

---------

Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-05-11 18:24:54 +01:00
sh fd298ae328 xftp: web page tweaks (#1772) 2026-05-06 09:24:18 +01:00
Evgeny Poberezkin 1f173abf6d 6.5.1.0 2026-05-01 17:57:19 +01:00
Evgeny 21f4597dad xftp: backwards compatible file header decoding (#1768) 2026-05-01 13:32:19 +01:00
Evgeny Poberezkin ba6af65c54 6.5.0.17 2026-04-29 20:31:42 +01:00
Evgeny Poberezkin 858fac7f4f 6.5.0.16 2026-04-21 20:26:51 +01:00
Evgeny Poberezkin 3536a156d1 Merge branch 'master' into smp-web 2026-04-20 13:43:10 +01:00
Evgeny Poberezkin 90432a44b4 tests: fix test compilation 2026-04-20 13:42:25 +01:00
sh 1e1f897c79 core: use = as INI key-value separator (#1767)
* core: use = as INI key-value separator

* core: update docker entrypoints for = INI separator

* core: update INI separator in README and test scripts
2026-04-20 09:22:14 +01:00
sh 0dc2940eff ci: add xftp-server postgres binaries (#1766) 2026-04-17 15:57:30 +01:00
sh 8833e5c1b5 xftp-server: support postgresql backend (#1755)
* xftp: add PostgreSQL backend design spec

* update doc

* adjust styling

* add implementation plan

* refactor: move usedStorage from FileStore to XFTPEnv

* refactor: add getUsedStorage, getFileCount, expiredFiles store functions

* refactor: change file store operations from STM to IO

* refactor: extract FileStoreClass typeclass, move STM impl to Store.STM

* refactor: make XFTPEnv and server polymorphic over FileStoreClass

* feat: add PostgreSQL store skeleton with schema migration

* feat: implement PostgresFileStore operations

* feat: add PostgreSQL INI config, store dispatch, startup validation

* feat: add database import/export CLI commands

* test: add PostgreSQL backend tests

* fix: map ForeignKeyViolation to AUTH in addRecipient

When a file is concurrently deleted while addRecipient runs, the FK
constraint on recipients.sender_id raises ForeignKeyViolation. Previously
this propagated as INTERNAL; now it returns AUTH (file not found).

* fix: only decrement usedStorage for uploaded files on expiration

expireServerFiles unconditionally subtracted file_size from usedStorage
for every expired file, including files that were never uploaded (no
file_path). Since reserve only increments usedStorage during upload,
expiring never-uploaded files caused usedStorage to drift negative.

* fix: handle setFilePath error in receiveServerFile

setFilePath result was discarded with void. If it failed (file deleted
concurrently, or double-upload where file_path IS NULL guard rejected
the second write), the server still reported FROk, incremented stats,
and left usedStorage permanently inflated. Now the error is checked:
on failure, reserved storage is released and AUTH is returned.

* fix: escape double quotes in COPY CSV status field

The status field (e.g. "blocked,reason=spam,notice={...}") is quoted in
CSV for COPY protocol, but embedded double quotes from BlockingInfo
notice (JSON) were not escaped. This could break CSV parsing during
import. Now double quotes are escaped as "" per CSV spec.

* fix: reject upload to blocked file in Postgres setFilePath

In Postgres mode, getFile returns a snapshot TVar for fileStatus. If a
file is blocked between getFile and setFilePath, the stale status check
passes but the upload should be rejected. Added status = 'active' to
the UPDATE WHERE clause so blocked files cannot receive uploads.

* fix: add CHECK constraint on file_size > 0

Prevents negative or zero file_size values at the database level.
Without this, corrupted data from import or direct DB access could
cause incorrect storage accounting (getUsedStorage sums file_size,
and expiredFiles casts to Word32 which wraps negative values).

* fix: check for existing data before database import

importFileStore now checks if the target database already contains
files and aborts with an error. Previously, importing into a non-empty
database would fail mid-COPY on duplicate primary keys, leaving the
database in a partially imported state.

* fix: clean up disk file when setFilePath fails in receiveServerFile

When setFilePath fails (file deleted or blocked concurrently, or
duplicate upload), the uploaded file was left orphaned on disk with
no DB record pointing to it. Now the file is removed on failure,
matching the cleanup in the receiveChunk error path.

* fix: check storeAction result in deleteOrBlockServerFile_

The store action result (deleteFile/blockFile) was discarded with void.
If the DB row was already deleted by a concurrent operation, the
function still decremented usedStorage, causing drift. Now the error
propagates via ExceptT, skipping the usedStorage adjustment.

* fix: check deleteFile result in expireServerFiles

deleteFile result was discarded with void. If a concurrent delete
already removed the file, deleteFile returned AUTH but usedStorage
was still decremented — causing double-decrement drift. Now the
usedStorage adjustment and filesExpired stat only run on success.

* refactor: merge STM store into Store.hs, parameterize server tests

- Move STMFileStore and its FileStoreClass instance from Store/STM.hs
  back into Store.hs — the separate file was unnecessary indirection
  for the always-present default implementation.

- Parameterize xftpFileTests over store backend using HSpec SpecWith
  pattern (following SMP's serverTests approach). The same 11 tests
  now run against both memory and PostgreSQL backends via a bracket
  parameter, eliminating all *Pg test duplicates.

- Extract shared run* functions (runTestFileChunkDeliveryAddRecipients,
  runTestWrongChunkSize, runTestFileChunkExpiration, runTestFileStorageQuota)
  from inlined test bodies.

* refactor: clean up per good-code review

- Remove internal helpers from Postgres.hs export list (withDB, withDB',
  handleDuplicate, assertUpdated, withLog are not imported externally)
- Replace local isNothing_ with Data.Maybe.isNothing in Env.hs
- Consolidate duplicate/unused imports in XFTPStoreTests.hs
- Add file_path IS NULL and status guards to STM setFilePath, matching
  the Postgres implementation semantics

* test: parameterize XFTP server, agent and CLI tests over store backend

- xftpTest/xftpTest2/xftpTest4/xftpTestN now take XFTPTestBracket as
  first argument, enabling the same test to run against both memory
  and PostgreSQL backends.

- xftpFileTests (server tests), xftpAgentFileTests (agent tests), and
  xftpCLIFileTests (CLI tests) are SpecWith-parameterized suites that
  receive the bracket from HSpec's before combinator.

- Test.hs runs each parameterized suite twice: once with
  xftpMemoryBracket, once with xftpPostgresBracket (CPP-guarded).

- STM-specific tests (store log restore/replay) stay in memory-only
  xftpAgentTests. SNI/CORS tests stay in memory-only xftpServerTests.

* refactor: remove dead test wrappers after parameterization

Remove old non-parameterized test wrapper functions that were
superseded by the store-backend-parameterized test suites.
All test bodies (run* and _ functions) are preserved and called
from the parameterized specs. Clean up unused imports.

* feat: add manual tests and guide

* refactor: merge file_size CHECK into initial migration

* refactor: extract rowToFileRec shared by getFile sender/recipient paths

* refactor: parameterize XFTPServerConfig over store type

Embed XFTPStoreConfig s as serverStoreCfg field, matching SMP's
ServerConfig. runXFTPServer and newXFTPServerEnv now take a single
XFTPServerConfig s. Restore verifyCmd local helper structure.

* refactor: minimize diff in tests

Restore xftpServerTests and xftpAgentTests bodies to match master
byte-for-byte (only type signatures change for XFTPTestBracket
parameterization); inline the runTestXXX helpers that were split
on this branch.

* refactor: restore getFile position to match master

* refactor: rename withSTMFile back to withFile

* refactor: close store log inside closeFileStore for STM backend

Move STM store log close responsibility into closeFileStore to
match PostgresFileStore, removing the asymmetry where only PG's
close was self-contained.

STMFileStore holds the log in a TVar populated by newXFTPServerEnv
after readWriteFileStore; stopServer no longer needs the explicit
withFileLog closeStoreLog call. Writes still go through XFTPEnv.storeLog
via withFileLog (unchanged).

* refactor: rename XFTPTestBracket to XFTPTestServer

* fix: move file_size check from PG schema to store log import

* refactor: use SQL-standard type names in XFTP schema

* perf: batch expired file deletions with deleteFiles

* refactor: stream export instead of loading recipients into memory

* refactor: parameterize XFTP store with FSType singleton dispatch

* refactor: minimize diff per review feedback

* refactor: use types over strings, deduplicate parser

* refactor: always parse database store type, fail at startup

* fix compilation without postgresql

* refactor: always parse database store type, fail at startup
2026-04-16 09:06:04 +01:00
Evgeny 95b17ada27 lib: fix incorrect encoding of Signature (incompatible with decoding, but never used together) - breaks backward compatibility for remote control connections (#1765)
* lib: fix incorrect StrEncoding of Signature (it was not compatible with decoding, but was never used)

* align encoding with used in links (breaks backward compatibility)
2026-04-15 15:11:06 +01:00
EvgenyandEvgeny @ SimpleX Chat 43cdf55f3b lib: add JSON instance to Signature type (#1764)
Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-04-14 19:47:20 +01:00
Evgeny Poberezkin bc5ea42bec 6.5.0.15 2026-04-11 19:39:50 +01:00
Evgeny Poberezkin 0933cbcb9c agent: add compression api 2026-04-11 18:14:48 +01:00
EvgenyandEvgeny @ SimpleX Chat f2dafd983b agent: export decompressedSize (#1763)
Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-04-11 17:28:00 +01:00
EvgenyandEvgeny @ SimpleX Chat 34c0909c1a agent: drop message after N reception attempts (#1762)
* agent: drop message after N reception attempts

* test

* increase count for message expiration

* fix migration

* update schema

---------

Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-04-11 16:24:30 +01:00
EvgenyandEvgeny @ SimpleX Chat 01fe841e3c smp: allow websocket connections on the same port (#1738)
* smp: allow websocket connections on the same port

* remove logs

* diff

* fix

* merge functions

* refactor

* remove unused

* refactor

---------

Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
2026-03-20 20:01:21 +00:00
83 changed files with 13066 additions and 636 deletions
+22 -12
View File
@@ -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 }}
+36
View File
@@ -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:
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -3,7 +3,7 @@ module Main where
import Control.Logger.Simple
import Simplex.Messaging.Server.CLI (getEnvPath)
import Simplex.Messaging.Server.Main (smpServerCLI_)
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticFiles)
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticAndWS)
import SMPWeb (smpGenerateSite)
defaultCfgPath :: FilePath
@@ -19,4 +19,4 @@ main :: IO ()
main = do
cfgPath <- getEnvPath "SMP_SERVER_CFG_PATH" defaultCfgPath
logPath <- getEnvPath "SMP_SERVER_LOG_PATH" defaultLogPath
withGlobalLogging logCfg $ smpServerCLI_ smpGenerateSite serveStaticFiles attachStaticFiles cfgPath logPath
withGlobalLogging logCfg $ smpServerCLI_ smpGenerateSite serveStaticFiles attachStaticAndWS cfgPath logPath
+6 -6
View File
@@ -31,10 +31,10 @@ 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")
xftpFilePageHtml :: ByteString
xftpFilePageHtml = $(embedFile "apps/xftp-server/static/file.html")
xftpGenerateSite :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
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
@@ -44,16 +44,16 @@ xftpGenerateSite cfg info onionHost path = do
filePage xftpDir xftpWebContent
filePage mediaDir xftpMediaContent
createDirectoryIfMissing True fileDir
-- B.writeFile (fileDir </> "index.html") $ render xftpFilePageHtml substs
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 -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
xftpServerInformation :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
xftpServerInformation cfg info onionHost = render (Web.indexHtml embeddedContent) (xftpSubsts cfg info onionHost)
xftpSubsts :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> [(ByteString, Maybe ByteString)]
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
@@ -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"
```
+299
View File
@@ -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` |
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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
+9 -1
View File
@@ -1,7 +1,7 @@
cabal-version: 1.12
name: simplexmq
version: 6.5.0.14
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
@@ -173,6 +173,7 @@ library
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
@@ -223,6 +224,7 @@ library
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:
@@ -281,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
@@ -354,6 +359,7 @@ library
, 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.*
@@ -509,6 +515,7 @@ test-suite simplexmq-test
XFTPServerTests
WebTests
XFTPWebTests
SMPWebTests
SMPWeb
XFTPWeb
Web.Embedded
@@ -523,6 +530,7 @@ test-suite simplexmq-test
if flag(server_postgres)
other-modules:
AgentTests.NotificationTests
CoreTests.XFTPStoreTests
NtfClient
NtfServerTests
PostgresSchemaDump
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
dist-test/
package-lock.json
+14
View File
@@ -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);
}
});
+315
View File
@@ -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];
}
+11
View File
@@ -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
+34
View File
@@ -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);
}
+35
View File
@@ -0,0 +1,35 @@
{
"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",
"typescript": "^5.4.0",
"ws": "^8.0.0"
}
}
+207
View File
@@ -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)
}
}
+247
View File
@@ -0,0 +1,247 @@
// 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, decodeBytes, decodeLarge, decodeWord16, decodeBool,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.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}
}
// -- 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}
}
}
+490
View File
@@ -0,0 +1,490 @@
// SMP client: command/response correlation, authentication, typed async API.
// Mirrors: Simplex.Messaging.Client
import {
encodeTransmission, encodeTransmissionForAuth, authTransmission,
tEncodeBatch1, tEncodeForBatch, batchTransmissions, tEncode,
tParse, tDecodeClient, protocolError, encodePING,
decodeResponse, paddedProxiedTLength, encodePRXY, encodePFWD,
type AuthKey, type SMPResponse, type RawTransmission,
encodeNEW, encodeKEY, encodeSKEY, encodeSUB, encodeACK,
encodeSEND, encodeOFF, encodeDEL, encodeGET, encodeQUE, encodeLGET,
type IDSResponse, type MSGResponse,
} from "./protocol.js"
import {
connectSMP,
type SMPConnection,
} from "./transport/websockets.js"
import {SMP_BLOCK_SIZE} from "./transport.js"
import {sbEncryptBlock, sbDecryptBlock, cbAuthenticator, reverseNonce, cbDecryptNoPad} from "./crypto.js"
import {blockPad, blockUnpad} from "@simplex-chat/xftp-web/dist/protocol/transmission.js"
import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {generateX25519KeyPair, x25519KeyPairFromPrivate, dh, encodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {extractSignedKey} from "@simplex-chat/xftp-web/dist/protocol/handshake.js"
// -- Error types (Client.hs:741-770)
// ProxiedRelay (Client.hs:1095-1100)
export interface ProxiedRelay {
sessionId: Uint8Array
version: number // negotiated version with relay
basicAuth: Uint8Array | null
relayKey: Uint8Array // relay's X25519 public key (raw 32 bytes)
}
// ProxyClientError (Client.hs:1102-1109)
export type ProxyClientError =
| {type: "ProxyProtocolError", error: string}
| {type: "ProxyUnexpectedResponse", response: string}
| {type: "ProxyResponseError", error: string}
export type SMPClientError =
| {type: "PROTOCOL", error: string} // ERR response from server
| {type: "RESPONSE", error: string} // failed to parse response
| {type: "UNEXPECTED", raw: string} // wrong response type for command
| {type: "TIMEOUT"} // response timeout
| {type: "NETWORK", error: string} // connection failure
| {type: "TRANSPORT", error: string} // handshake/transport error
// -- SMPClient
export interface SMPClient {
readonly sessionId: Uint8Array
readonly smpVersion: number
readonly serverPubKey: Uint8Array
// Core: send pre-encoded command, await correlated response
sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<SMPResponse>
// High-level commands
createQueue(authKeyPair: {publicKey: Uint8Array, privateKey: Uint8Array}, dhKey: Uint8Array, subscribe: boolean): Promise<IDSResponse>
subscribeQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void>
getMessage(privKey: AuthKey, rcvId: Uint8Array): Promise<MSGResponse | null>
sendMessage(privKey: AuthKey | null, sndId: Uint8Array, notification: boolean, msg: Uint8Array): Promise<void>
ackMessage(privKey: AuthKey, rcvId: Uint8Array, msgId: Uint8Array): Promise<void>
secureQueue(privKey: AuthKey, rcvId: Uint8Array, senderKey: Uint8Array): Promise<void>
secureSndQueue(privKey: AuthKey, sndId: Uint8Array): Promise<void>
getQueueLink(linkId: Uint8Array): Promise<SMPResponse>
deleteQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void>
suspendQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void>
// Batch commands (Client.hs:840-845, 1062-1065)
subscribeQueues(queues: Array<{rcvId: Uint8Array, privKey: AuthKey}>): Promise<void[]>
deleteQueues(queues: Array<{rcvId: Uint8Array, privKey: AuthKey}>): Promise<void[]>
// Proxy commands (Client.hs:1069-1206)
connectProxiedRelay(relayHosts: string[], relayPort: string, relayKeyHash: Uint8Array, basicAuth: Uint8Array | null): Promise<ProxiedRelay>
proxySMPCommand(relay: ProxiedRelay, privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<SMPResponse>
proxySendMessage(relay: ProxiedRelay, privKey: AuthKey | null, sndId: Uint8Array, notification: boolean, msg: Uint8Array): Promise<void>
close(): void
}
interface PendingRequest {
resolve: (resp: SMPResponse) => void
reject: (err: SMPClientError) => void
timer: ReturnType<typeof setTimeout>
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
}
export async function createSMPClient(
url: string,
keyHash: Uint8Array,
onMessage: (entityId: Uint8Array, msg: SMPResponse) => void,
onDisconnected: () => void,
config?: {timeout?: number, pingInterval?: number, pingMaxCount?: number, wsOptions?: object},
): Promise<SMPClient> {
const timeout_ = config?.timeout ?? 10_000
const pingInterval = config?.pingInterval ?? 600_000
const pingMaxCount = config?.pingMaxCount ?? 3
const conn = await connectSMP(url, keyHash, config?.wsOptions)
if (!conn.serverPubKey) throw new Error("createSMPClient: server has no auth key")
const serverPubKey = conn.serverPubKey
const pending = new Map<string, PendingRequest>()
let closed = false
let pingTimer: ReturnType<typeof setInterval> | null = null
let timeoutCount = 0
// -- Receive loop
function onBlock(data: ArrayBuffer | Buffer) {
if (closed) return
timeoutCount = 0
try {
const raw = data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
const block = new Uint8Array(raw)
// Decrypt block
const decrypted = decryptBlock(block)
// Parse batch
const transmissions = tParse(decrypted)
for (const raw of transmissions) {
dispatch(raw)
}
} catch (e: any) {
// Parse error — log to stderr
process.stderr.write("SMP client receive error: " + e.message + "\n")
}
}
function decryptBlock(block: Uint8Array): Uint8Array {
if (conn.rcvKey) {
const {decrypted, nextChainKey} = sbDecryptBlock(conn.rcvKey, block)
conn.rcvKey = nextChainKey
return decrypted
}
// No block encryption — strip padding
return blockUnpad(block)
}
function dispatch(raw: RawTransmission) {
// Parse response
let response: SMPResponse
try {
response = decodeResponse(new Decoder(raw.command))
} catch (e: any) {
process.stderr.write("dispatch parse error: " + e.message + " command=" + toHex(raw.command) + "\n")
// If we can correlate, reject the pending request
const key = toHex(raw.corrId)
const req = pending.get(key)
if (req) {
pending.delete(key)
clearTimeout(req.timer)
req.reject({type: "RESPONSE", error: e.message})
}
return
}
// Classify: ERR → PCEProtocolError
const err = protocolError(response)
// Correlate by corrId
const corrIdBytes = raw.corrId
if (corrIdBytes.length === 0) {
// Server push (no corrId) — deliver to event callback
onMessage(raw.entityId, response)
return
}
const key = toHex(corrIdBytes)
const req = pending.get(key)
if (req) {
pending.delete(key)
clearTimeout(req.timer)
if (err) {
req.reject({type: "PROTOCOL", error: err})
} else {
req.resolve(response)
}
} else {
// No pending request — might be a late response or server push with corrId
// Deliver as event
if (!err) onMessage(raw.entityId, response)
}
}
// Wire up WebSocket receive
conn.ws.onmessage = (event) => onBlock(event.data as ArrayBuffer)
conn.ws.onclose = () => {
if (!closed) {
closed = true
cleanup()
onDisconnected()
}
}
conn.ws.onerror = () => {}
// -- Ping
function startPing() {
if (pingInterval <= 0) return
pingTimer = setInterval(async () => {
try {
await client.sendCommand(null, new Uint8Array(0), encodePING())
} catch {
timeoutCount++
if (pingMaxCount > 0 && timeoutCount >= pingMaxCount) {
client.close()
}
}
}, pingInterval)
}
function cleanup() {
if (pingTimer) {
clearInterval(pingTimer)
pingTimer = null
}
// Reject all pending requests
for (const [, req] of pending) {
clearTimeout(req.timer)
req.reject({type: "NETWORK", error: "disconnected"})
}
pending.clear()
}
// -- Send
// mkTransmission (Client.hs:1349-1370)
// Encode, authenticate, register pending request. Returns encoded transmission + promise.
// nonce_ parameter: if provided, used as corrId (for proxy commands where nonce = corrId)
function mkTransmission(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array, nonce_?: Uint8Array): {auth: Uint8Array | null, tToSend: Uint8Array, promise: Promise<SMPResponse>} {
const nonce = nonce_ ?? crypto.getRandomValues(new Uint8Array(24))
const {tForAuth, tToSend} = encodeTransmissionForAuth(conn.sessionId, nonce, entityId, command)
const auth = authTransmission(serverPubKey, privKey, nonce, tForAuth)
const promise = new Promise<SMPResponse>((resolve, reject) => {
const key = toHex(nonce)
const timer = setTimeout(() => {
pending.delete(key)
timeoutCount++
reject({type: "TIMEOUT"} as SMPClientError)
}, timeout_)
pending.set(key, {resolve, reject, timer})
})
return {auth, tToSend, promise}
}
// Send a pre-encoded block (encrypt + write to WebSocket)
function sendBlock(block: Uint8Array): void {
if (conn.sndKey) {
const {encrypted, nextChainKey} = sbEncryptBlock(conn.sndKey, block, SMP_BLOCK_SIZE - 16)
conn.sndKey = nextChainKey
conn.ws.send(encrypted)
} else {
conn.ws.send(blockPad(block, SMP_BLOCK_SIZE))
}
}
// sendProtocolCommand (Client.hs:1300-1326) — single command
// nonce_: if provided, used as corrId (for proxy where nonce = corrId)
function sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array, nonce_?: Uint8Array): Promise<SMPResponse> {
if (closed) return Promise.reject({type: "NETWORK", error: "closed"} as SMPClientError)
const {auth, tToSend, promise} = mkTransmission(privKey, entityId, command, nonce_)
sendBlock(tEncodeBatch1(auth, tToSend))
return promise
}
// sendProtocolCommands (Client.hs:1262-1298) — batch multiple commands
function sendCommands(commands: Array<{privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array}>): Promise<SMPResponse>[] {
if (closed) return commands.map(() => Promise.reject({type: "NETWORK", error: "closed"} as SMPClientError))
// mkTransmission for each
const transmissions = commands.map(c => mkTransmission(c.privKey, c.entityId, c.command))
// Encode for batching: tEncodeForBatch each
const encoded = transmissions.map(t => tEncodeForBatch(t.auth, t.tToSend))
// Pack into blocks
const blocks = batchTransmissions(SMP_BLOCK_SIZE, encoded)
// Send each block
for (const block of blocks) sendBlock(block)
// Return all promises
return transmissions.map(t => t.promise)
}
// -- High-level commands
// okSMPCommand (Client.hs:1239-1243) — only accepts OK, not SOK
async function okCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<void> {
const resp = await sendCommand(privKey, entityId, command)
if (resp.type !== "OK") {
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
}
}
const client: SMPClient = {
sessionId: conn.sessionId,
smpVersion: conn.smpVersion,
serverPubKey,
sendCommand,
// createQueue (Client.hs:813-827)
async createQueue(authKeyPair, dhKey, subscribe) {
const command = encodeNEW(authKeyPair.publicKey, dhKey, null, subscribe)
// Auth with the X25519 private key from the keypair
const privKey: AuthKey = {type: "x25519", key: authKeyPair.privateKey}
const resp = await sendCommand(privKey, new Uint8Array(0), command)
if (resp.type !== "IDS") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
return resp.response
},
// subscribeSMPQueue (Client.hs:833-836)
async subscribeQueue(privKey, rcvId) {
const resp = await sendCommand(privKey, rcvId, encodeSUB())
// SUB can return MSG (queued message) — push to onMessage
if (resp.type === "MSG") {
onMessage(rcvId, resp)
return
}
if (resp.type !== "OK" && resp.type !== "SOK") {
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
}
},
// getSMPMessage (Client.hs:875-880)
async getMessage(privKey, rcvId) {
const resp = await sendCommand(privKey, rcvId, encodeGET())
if (resp.type === "OK") return null
if (resp.type === "MSG") {
onMessage(rcvId, resp)
return resp.response
}
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
},
// sendSMPMessage (Client.hs:1027-1031)
async sendMessage(privKey, sndId, notification, msg) {
await okCommand(privKey, sndId, encodeSEND(notification, msg))
},
// ackSMPMessage (Client.hs:1040-1045)
async ackMessage(privKey, rcvId, msgId) {
const resp = await sendCommand(privKey, rcvId, encodeACK(msgId))
// ACK can return MSG — push to onMessage
if (resp.type === "MSG") {
onMessage(rcvId, resp)
return
}
if (resp.type !== "OK") {
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
}
},
// secureSMPQueue (Client.hs:938-939)
async secureQueue(privKey, rcvId, senderKey) {
await okCommand(privKey, rcvId, encodeKEY(senderKey))
},
// secureSndSMPQueue (Client.hs:943-944)
// SKEY sends the public key derived from the private key
async secureSndQueue(privKey, sndId) {
// x25519KeyPairFromPrivate derives public from private
const pubKey = x25519KeyPairFromPrivate(privKey.key).publicKey
await okCommand(privKey, sndId, encodeSKEY(encodePubKeyX25519(pubKey)))
},
// getSMPQueueLink (Client.hs:976-980)
async getQueueLink(linkId) {
return sendCommand(null, linkId, encodeLGET())
},
// deleteSMPQueue (Client.hs:1058-1059)
async deleteQueue(privKey, rcvId) {
await okCommand(privKey, rcvId, encodeDEL())
},
// suspendSMPQueue (Client.hs:1051-1052)
async suspendQueue(privKey, rcvId) {
await okCommand(privKey, rcvId, encodeOFF())
},
// subscribeSMPQueues (Client.hs:840-845)
async subscribeQueues(queues) {
const commands = queues.map(q => ({privKey: q.privKey, entityId: q.rcvId, command: encodeSUB()}))
const promises = sendCommands(commands)
return Promise.all(promises.map(async (p, i) => {
const resp = await p
// processSUBResponse_ (Client.hs:857-862)
if (resp.type === "MSG") {
onMessage(queues[i].rcvId, resp)
return
}
if (resp.type !== "OK" && resp.type !== "SOK") {
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
}
}))
},
// deleteSMPQueues (Client.hs:1062-1065) via okSMPCommands (Client.hs:1245-1253)
async deleteQueues(queues) {
const commands = queues.map(q => ({privKey: q.privKey, entityId: q.rcvId, command: encodeDEL()}))
const promises = sendCommands(commands)
return Promise.all(promises.map(async (p) => {
const resp = await p
if (resp.type !== "OK") {
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
}
}))
},
// connectSMPProxiedRelay (Client.hs:1069-1093)
async connectProxiedRelay(relayHosts, relayPort, relayKeyHash, basicAuth) {
// Send PRXY to proxy server
const command = encodePRXY(relayHosts, relayPort, relayKeyHash, basicAuth)
const resp = await sendCommand(null, new Uint8Array(0), command)
if (resp.type !== "PKEY") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
const {sessionId: relaySessId, versionRange, signedKeyDer} = resp.response
// Check version compatibility
const version = Math.min(versionRange.max, conn.smpVersion)
if (version < versionRange.min) throw {type: "TRANSPORT", error: "incompatible relay version"} as SMPClientError
// Extract relay's X25519 DH key from signed key (same as connectSMP handshake)
const relayKey = extractSignedKey(signedKeyDer).dhKey
// TODO: full certificate chain validation against relayKeyHash
// For now we trust the proxy's PKEY response (proxy already validated the relay)
return {sessionId: relaySessId, version, basicAuth, relayKey}
},
// proxySMPCommand (Client.hs:1157-1206)
async proxySMPCommand(relay, privKey, entityId, command) {
// Prepare relay params — encode as if sending directly to relay
const relaySessionId = relay.sessionId
// Generate ephemeral X25519 keypair for this command
const cmdKp = generateX25519KeyPair()
const cmdSecret = dh(relay.relayKey, cmdKp.privateKey)
const nonce = crypto.getRandomValues(new Uint8Array(24))
// Encode transmission for relay (using relay's sessionId)
const {tForAuth, tToSend} = encodeTransmissionForAuth(relaySessionId, nonce, entityId, command)
// Authenticate against relay's key
const auth = privKey
? cbAuthenticator(relay.relayKey, privKey.key, nonce, tForAuth)
: null
// Batch into single block (for relay)
const batchBlock = tEncodeBatch1(auth, tToSend)
// Encrypt for relay: cbEncrypt(cmdSecret, nonce, batchBlock, paddedProxiedTLength)
const encTransmission = cbEncrypt(cmdSecret, nonce, batchBlock, paddedProxiedTLength)
// Send PFWD to proxy (entityId = relay sessionId from PKEY)
// IMPORTANT: nonce is also used as corrId for PFWD (Client.hs:1175,1188)
// The relay extracts it from FwdTransmission.fwdCorrId to decrypt
const cmdPubKeyDer = encodePubKeyX25519(cmdKp.publicKey)
const pfwdCommand = encodePFWD(relay.version, cmdPubKeyDer, encTransmission)
const pfwdResp = await sendCommand(null, relay.sessionId, pfwdCommand, nonce)
// Handle response
if (pfwdResp.type === "PRES") {
// Decrypt relay's response: cbDecrypt(cmdSecret, reverseNonce(nonce), encResponse)
const decrypted = cbDecrypt(cmdSecret, reverseNonce(nonce), pfwdResp.encResponse)
// Parse as relay's response
const transmissions = tParse(decrypted)
if (transmissions.length !== 1) throw {type: "TRANSPORT", error: "bad proxy response block"} as SMPClientError
const decoded = tDecodeClient(transmissions[0])
const relayResp = decoded.response
const err = protocolError(relayResp)
if (err) throw {type: "PROTOCOL", error: err} as SMPClientError
return relayResp
}
if (pfwdResp.type === "ERR") {
throw {type: "PROTOCOL", error: pfwdResp.error} as SMPClientError
}
throw {type: "UNEXPECTED", raw: pfwdResp.type} as SMPClientError
},
// proxySMPMessage — convenience for SEND via proxy
async proxySendMessage(relay, privKey, sndId, notification, msg) {
const command = encodeSEND(notification, msg)
const resp = await client.proxySMPCommand(relay, privKey, sndId, command)
if (resp.type !== "OK") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
},
close() {
if (closed) return
closed = true
cleanup()
conn.ws.close()
},
}
startPing()
return client
}
+130
View File
@@ -0,0 +1,130 @@
// Crypto primitives.
// Mirrors: Simplex.Messaging.Crypto
import {hkdf as nobleHkdf} from "@noble/hashes/hkdf"
import {sha512} from "@noble/hashes/sha512"
import {gcm} from "@noble/ciphers/aes.js"
import {cbEncrypt, cbDecrypt, cryptoBox, sbInit, sbDecryptChunk, sbAuth} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {dh} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
import {concatBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {pad, unPad} from "@simplex-chat/xftp-web/dist/crypto/padding.js"
// C.hkdf (Crypto.hs:1461-1464)
// HKDF-SHA512 extract + expand
export function hkdf(salt: Uint8Array, ikm: Uint8Array, info: string, n: number): Uint8Array {
return nobleHkdf(sha512, ikm, salt, info, n)
}
// -- SbChainKey block encryption (Crypto.hs:1449-1464)
export interface SbKeyNonce {
sbKey: Uint8Array // 32 bytes
nonce: Uint8Array // 24 bytes
}
// sbcInit (Crypto.hs:1452-1455)
// hkdf(sessionId, dhSecret, "SimpleXSbChainInit", 64) -> (sndChainKey, rcvChainKey)
export function sbcInit(sessionId: Uint8Array, dhSecret: Uint8Array): {sndKey: Uint8Array; rcvKey: Uint8Array} {
const derived = hkdf(sessionId, dhSecret, "SimpleXSbChainInit", 64)
return {sndKey: derived.slice(0, 32), rcvKey: derived.slice(32, 64)}
}
// sbcHkdf (Crypto.hs:1459-1464)
// hkdf("", chainKey, "SimpleXSbChain", 88) -> ((sbKey, nonce), nextChainKey)
export function sbcHkdf(chainKey: Uint8Array): {keyNonce: SbKeyNonce; nextChainKey: Uint8Array} {
const out = hkdf(new Uint8Array(0), chainKey, "SimpleXSbChain", 88)
return {
keyNonce: {sbKey: out.slice(32, 64), nonce: out.slice(64, 88)},
nextChainKey: out.slice(0, 32),
}
}
// sbEncrypt (Crypto.hs:1296-1301)
// pad + cryptoBox (tag prepended to ciphertext)
export function sbEncryptBlock(chainKey: Uint8Array, block: Uint8Array, paddedLen: number): {encrypted: Uint8Array; nextChainKey: Uint8Array} {
const {keyNonce: {sbKey, nonce}, nextChainKey} = sbcHkdf(chainKey)
return {encrypted: cbEncrypt(sbKey, nonce, block, paddedLen), nextChainKey}
}
// sbDecrypt (Crypto.hs:1330-1336)
// cryptoBoxOpen + unpad
export function sbDecryptBlock(chainKey: Uint8Array, block: Uint8Array): {decrypted: Uint8Array; nextChainKey: Uint8Array} {
const {keyNonce: {sbKey, nonce}, nextChainKey} = sbcHkdf(chainKey)
return {decrypted: cbDecrypt(sbKey, nonce, block), nextChainKey}
}
// -- AES-256-GCM authenticated encryption (Crypto.hs:1035-1061)
// Uses 16-byte IVs (GCM with GHASH path per NIST SP 800-38D for IVs != 96 bits)
export const AUTH_TAG_SIZE = 16
// encryptAEAD (Crypto.hs:1035-1039)
export function encryptAEAD(
key: Uint8Array, // 32 bytes
iv: Uint8Array, // 16 bytes
paddedLen: number,
ad: Uint8Array,
plaintext: Uint8Array,
): {authTag: Uint8Array; ciphertext: Uint8Array} {
const padded = pad(plaintext, paddedLen)
const cipher = gcm(key, iv, ad)
const encrypted = cipher.encrypt(padded)
return {
ciphertext: encrypted.subarray(0, encrypted.length - AUTH_TAG_SIZE),
authTag: encrypted.subarray(encrypted.length - AUTH_TAG_SIZE),
}
}
// decryptAEAD (Crypto.hs:1058-1061)
export function decryptAEAD(
key: Uint8Array,
iv: Uint8Array,
ad: Uint8Array,
ciphertext: Uint8Array,
authTag: Uint8Array,
): Uint8Array {
const cipher = gcm(key, iv, ad)
const encrypted = concatBytes(ciphertext, authTag)
const padded = cipher.decrypt(encrypted)
return unPad(padded)
}
// -- SHA-512 hash (Crypto.hs:1016)
export function sha512Hash(msg: Uint8Array): Uint8Array {
return sha512(msg)
}
// -- Command authentication (Crypto.hs:1366-1367)
// cbAuthenticate (Crypto.hs:1367)
// cryptoBox(dh(serverPubKey, entityPrivKey), nonce, sha512Hash(msg)) → 80 bytes (16 tag + 64 hash)
export function cbAuthenticator(serverPubKey: Uint8Array, entityPrivKey: Uint8Array, nonce: Uint8Array, msg: Uint8Array): Uint8Array {
const dhSecret = dh(serverPubKey, entityPrivKey)
return cryptoBox(dhSecret, nonce, sha512Hash(msg))
}
// -- reverseNonce (Crypto.hs:1409-1410)
export function reverseNonce(nonce: Uint8Array): Uint8Array {
const reversed = new Uint8Array(nonce.length)
for (let i = 0; i < nonce.length; i++) reversed[i] = nonce[nonce.length - 1 - i]
return reversed
}
// -- cbDecryptNoPad (Crypto.hs:1330-1331)
// Decrypt without unpadding. Used for proxy responses.
// Same as cbDecrypt but returns raw decrypted bytes without unPad.
export function cbDecryptNoPad(dhSecret: Uint8Array, nonce: Uint8Array, packet: Uint8Array): Uint8Array {
const tag = packet.subarray(0, 16)
const cipher = packet.subarray(16)
const state = sbInit(dhSecret, nonce)
const plaintext = sbDecryptChunk(state, cipher)
const computedTag = sbAuth(state)
// constant-time compare
let diff = 0
for (let i = 0; i < 16; i++) diff |= tag[i] ^ computedTag[i]
if (diff !== 0) throw new Error("cbDecryptNoPad: authentication failed")
return plaintext
}
+749
View File
@@ -0,0 +1,749 @@
// Double ratchet with X3DH key agreement and PQ KEM.
// Faithful transpilation of Simplex.Messaging.Crypto.Ratchet
//
// Every type, field, and function mirrors the Haskell source.
// Line references are to src/Simplex/Messaging/Crypto/Ratchet.hs
import {x448} from "@noble/curves/ed448.js"
import {hkdf, encryptAEAD, decryptAEAD, AUTH_TAG_SIZE} from "../crypto.js"
import {sntrup761Keypair, sntrup761Enc, sntrup761Dec} from "./sntrup761.js"
import type {KEMKeyPair} from "./sntrup761.js"
import {
Decoder, concatBytes,
encodeBytes, decodeBytes, decodeWord16, decodeWord32,
encodeLarge, decodeLarge,
encodeMaybe,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- Version constants (lines 134-155)
export const pqRatchetE2EEncryptVersion = 3
export const currentE2EEncryptVersion = 3
// -- X448 key operations
export interface X448KeyPair {
publicKey: Uint8Array // 56 bytes
privateKey: Uint8Array // 56 bytes
}
export function generateX448KeyPair(): X448KeyPair {
const privateKey = x448.utils.randomSecretKey()
const publicKey = x448.getPublicKey(privateKey)
return {publicKey, privateKey}
}
export function x448DH(publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array {
return x448.getSharedSecret(privateKey, publicKey)
}
// DER encoding for X448 public keys (RFC 8410, SubjectPublicKeyInfo)
// SEQUENCE { SEQUENCE { OID 1.3.101.111 } BIT STRING { 0x00 <56 bytes> } }
const X448_PUBKEY_DER_PREFIX = new Uint8Array([
0x30, 0x42, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6f, 0x03, 0x39, 0x00,
])
export function encodePubKeyX448(rawPubKey: Uint8Array): Uint8Array {
return concatBytes(X448_PUBKEY_DER_PREFIX, rawPubKey)
}
export function decodePubKeyX448(der: Uint8Array): Uint8Array {
if (der.length !== 68) throw new Error("decodePubKeyX448: invalid length " + der.length)
for (let i = 0; i < X448_PUBKEY_DER_PREFIX.length; i++) {
if (der[i] !== X448_PUBKEY_DER_PREFIX[i]) throw new Error("decodePubKeyX448: invalid DER prefix")
}
return der.subarray(12)
}
// -- KEM types (lines 567-577)
// KEMKeyPair imported from ./sntrup761.js
export interface RatchetKEMAccepted {
rcPQRr: Uint8Array // KEMPublicKey - received key (1158 bytes)
rcPQRss: Uint8Array // KEMSharedKey - computed shared secret (32 bytes)
rcPQRct: Uint8Array // KEMCiphertext - sent encaps (1039 bytes)
}
export interface RatchetKEM {
rcPQRs: KEMKeyPair
rcKEMs: RatchetKEMAccepted | null
}
// -- RatchetInitParams (lines 457-464)
export interface RatchetInitParams {
assocData: Uint8Array // Str (raw bytes)
ratchetKey: Uint8Array // RatchetKey (32 bytes)
sndHK: Uint8Array // HeaderKey (32 bytes)
rcvNextHK: Uint8Array // HeaderKey (32 bytes)
kemAccepted: RatchetKEMAccepted | null // Maybe RatchetKEMAccepted
}
// -- hkdf3 (lines 1174-1179)
function hkdf3(salt: Uint8Array, ikm: Uint8Array, info: string): [Uint8Array, Uint8Array, Uint8Array] {
const out = hkdf(salt, ikm, info, 96)
return [out.slice(0, 32), out.slice(32, 64), out.slice(64, 96)]
}
// -- pqX3dh (lines 499-508)
const X3DH_SALT = new Uint8Array(64)
function pqX3dh(
sk1: Uint8Array, rk1: Uint8Array,
dh1: Uint8Array, dh2: Uint8Array, dh3: Uint8Array,
kemAccepted: RatchetKEMAccepted | null,
): RatchetInitParams {
const assocData = concatBytes(sk1, rk1)
const pq = kemAccepted ? kemAccepted.rcPQRss : new Uint8Array(0)
const dhs = concatBytes(dh1, dh2, dh3, pq)
const [hk, nhk, sk] = hkdf3(X3DH_SALT, dhs, "SimpleXX3DH")
return {assocData, ratchetKey: sk, sndHK: hk, rcvNextHK: nhk, kemAccepted}
}
// -- pqX3dhSnd (lines 467-480)
// Used by joiner (Alice in PQDR spec, Bob in DR spec) to init SENDING ratchet.
export function pqX3dhSnd(
spk1: Uint8Array, spk2: Uint8Array, // our private keys
rk1: Uint8Array, rk2: Uint8Array, // their public keys (raw)
kemAccepted: RatchetKEMAccepted | null = null,
): RatchetInitParams {
const sk1Pub = x448.getPublicKey(spk1)
const dh1 = x448DH(rk1, spk2)
const dh2 = x448DH(rk2, spk1)
const dh3 = x448DH(rk2, spk2)
return pqX3dh(sk1Pub, rk1, dh1, dh2, dh3, kemAccepted)
}
// -- pqX3dhRcv (lines 483-497)
// Used by initiator (Bob in PQDR spec, Alice in DR spec) to init RECEIVING ratchet.
export function pqX3dhRcv(
rpk1: Uint8Array, rpk2: Uint8Array, // our private keys
sk1: Uint8Array, sk2: Uint8Array, // their public keys (raw)
kemAccepted: RatchetKEMAccepted | null = null,
): RatchetInitParams {
const rk1Pub = x448.getPublicKey(rpk1)
const dh1 = x448DH(sk2, rpk1)
const dh2 = x448DH(sk1, rpk2)
const dh3 = x448DH(sk2, rpk2)
return pqX3dh(sk1, rk1Pub, dh1, dh2, dh3, kemAccepted)
}
// -- rootKdf (lines 1159-1166)
export function rootKdf(
rk: Uint8Array, // RatchetKey (32 bytes)
peerPubKey: Uint8Array, // PublicKey a (raw, 56 bytes for X448)
ownPrivKey: Uint8Array, // PrivateKey a (raw, 56 bytes for X448)
kemSecret: Uint8Array | null, // Maybe KEMSharedKey
): {rk: Uint8Array; ck: Uint8Array; nhk: Uint8Array} {
const dhOut = x448DH(peerPubKey, ownPrivKey)
const ss = kemSecret ? concatBytes(dhOut, kemSecret) : dhOut
const [rk_, ck, nhk] = hkdf3(rk, ss, "SimpleXRootRatchet")
return {rk: rk_, ck, nhk}
}
// -- chainKdf (lines 1168-1172)
export function chainKdf(ck: Uint8Array): {ck: Uint8Array; mk: Uint8Array; iv: Uint8Array; ehIV: Uint8Array} {
const EMPTY = new Uint8Array(0)
const [ck_, mk, ivs] = hkdf3(EMPTY, ck, "SimpleXChainRatchet")
return {ck: ck_, mk, iv: ivs.slice(0, 16), ehIV: ivs.slice(16, 32)}
}
// -- Header padding (lines 716-719)
export function paddedHeaderLen(v: number, pqSupport: boolean): number {
if (pqSupport && v >= pqRatchetE2EEncryptVersion) return 2310
return 88
}
// -- SndRatchet (lines 554-559)
export interface SndRatchet {
rcDHRr: Uint8Array // peer's public key (raw, 56 bytes)
rcCKs: Uint8Array // sending chain key (32 bytes)
rcHKs: Uint8Array // sending header key (32 bytes)
}
// -- RcvRatchet (lines 561-565)
export interface RcvRatchet {
rcCKr: Uint8Array // receiving chain key (32 bytes)
rcHKr: Uint8Array // receiving header key (32 bytes)
}
// -- MessageKey (lines 608-609)
export interface MessageKey {
mk: Uint8Array // Key (32 bytes)
iv: Uint8Array // IV (16 bytes)
}
// -- RatchetVersions (lines 534-538)
export interface RatchetVersions {
current: number
maxSupported: number
}
// -- Ratchet (lines 512-532)
export interface Ratchet {
rcVersion: RatchetVersions
rcAD: Uint8Array // Str (associated data, raw bytes)
rcDHRs: Uint8Array // PrivateKey a (raw, 56 bytes)
rcKEM: RatchetKEM | null
rcSupportKEM: boolean // PQSupport
rcEnableKEM: boolean // PQEncryption
rcSndKEM: boolean // PQEncryption
rcRcvKEM: boolean // PQEncryption
rcRK: Uint8Array // RatchetKey (32 bytes)
rcSnd: SndRatchet | null
rcRcv: RcvRatchet | null
rcNs: number // Word32
rcNr: number // Word32
rcPN: number // Word32
rcNHKs: Uint8Array // HeaderKey (32 bytes)
rcNHKr: Uint8Array // HeaderKey (32 bytes)
}
// -- SkippedMsgKeys (lines 580-582)
export type SkippedMsgKeys = Map<string, Map<number, MessageKey>>
const MAX_SKIP = 512
function hexKey(k: Uint8Array): string {
return Array.from(k, b => b.toString(16).padStart(2, "0")).join("")
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
return bytes
}
// -- initSndRatchet (lines 643-666)
export function initSndRatchet(
rcVersion: RatchetVersions,
rcDHRr: Uint8Array, // peer's public key (raw)
rcDHRs: Uint8Array, // our private key (raw)
initParams: RatchetInitParams,
rcPQRs_: KEMKeyPair | null,
): Ratchet {
const {assocData, ratchetKey, sndHK, rcvNextHK, kemAccepted} = initParams
// state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
const kemSecret = kemAccepted ? kemAccepted.rcPQRss : null
const {rk: rcRK, ck: rcCKs, nhk: rcNHKs} = rootKdf(ratchetKey, rcDHRr, rcDHRs, kemSecret)
const pqOn = rcPQRs_ !== null
return {
rcVersion,
rcAD: assocData,
rcDHRs,
rcKEM: rcPQRs_ ? {rcPQRs: rcPQRs_, rcKEMs: kemAccepted} : null,
rcSupportKEM: pqOn,
rcEnableKEM: pqOn,
rcSndKEM: kemAccepted !== null,
rcRcvKEM: false,
rcRK,
rcSnd: {rcDHRr, rcCKs, rcHKs: sndHK},
rcRcv: null,
rcPN: 0,
rcNs: 0,
rcNr: 0,
rcNHKs,
rcNHKr: rcvNextHK,
}
}
// -- initRcvRatchet (lines 674-699)
export function initRcvRatchet(
rcVersion: RatchetVersions,
rcDHRs: Uint8Array, // our private key (raw)
initParams: RatchetInitParams,
rcPQRs_: KEMKeyPair | null,
pqSupport: boolean,
): Ratchet {
const {assocData, ratchetKey, sndHK, rcvNextHK, kemAccepted} = initParams
return {
rcVersion,
rcAD: assocData,
rcDHRs,
rcKEM: rcPQRs_ ? {rcPQRs: rcPQRs_, rcKEMs: kemAccepted} : null,
rcSupportKEM: pqSupport,
rcEnableKEM: pqSupport,
rcSndKEM: false,
rcRcvKEM: false,
rcRK: ratchetKey,
rcSnd: null,
rcRcv: null,
rcPN: 0,
rcNs: 0,
rcNr: 0,
rcNHKs: rcvNextHK,
rcNHKr: sndHK,
}
}
// -- RKEMParams (lines 188-190) - parsed KEM params from message header
export type RKEMParams =
| {type: "proposed", kemPk: Uint8Array} // RKParamsProposed KEMPublicKey
| {type: "accepted", kemCt: Uint8Array, kemPk: Uint8Array} // RKParamsAccepted KEMCiphertext KEMPublicKey
// -- MsgHeader (lines 703-711)
interface MsgHeader {
msgMaxVersion: number
msgDHRs: Uint8Array // PublicKey a (raw, 56 bytes)
msgKEM: RKEMParams | null
msgPN: number // Word32
msgNs: number // Word32
}
// -- encodeMsgHeader (lines 727-730)
function encodeMsgHeader(v: number, hdr: MsgHeader): Uint8Array {
const vBytes = new Uint8Array(2)
vBytes[0] = (hdr.msgMaxVersion >> 8) & 0xff
vBytes[1] = hdr.msgMaxVersion & 0xff
const dhDer = encodePubKeyX448(hdr.msgDHRs)
const pn = encodeWord32(hdr.msgPN)
const ns = encodeWord32(hdr.msgNs)
if (v >= pqRatchetE2EEncryptVersion) {
// smpEncode (msgMaxVersion, msgDHRs, msgKEM, msgPN, msgNs)
// msgKEM :: Maybe ARKEMParams
const kemBytes = hdr.msgKEM ? encodeRKEMParams(hdr.msgKEM) : new Uint8Array([0x30]) // Nothing
return concatBytes(vBytes, encodeBytes(dhDer), kemBytes, pn, ns)
}
// smpEncode (msgMaxVersion, msgDHRs, msgPN, msgNs)
return concatBytes(vBytes, encodeBytes(dhDer), pn, ns)
}
// Encode Maybe ARKEMParams: '1' + encoded params, or nothing (handled at call site with '0')
function encodeRKEMParams(params: RKEMParams): Uint8Array {
if (params.type === "proposed") {
// Just ('P', kemPk) - smpEncode ('P', k) where k is KEMPublicKey (Large)
return concatBytes(new Uint8Array([0x31, 0x50]), encodeLarge(params.kemPk))
}
// Just ('A', ct, kemPk) - smpEncode ('A', ct, k)
return concatBytes(new Uint8Array([0x31, 0x41]), encodeLarge(params.kemCt), encodeLarge(params.kemPk))
}
function encodeWord32(n: number): Uint8Array {
const buf = new Uint8Array(4)
buf[0] = (n >> 24) & 0xff; buf[1] = (n >> 16) & 0xff
buf[2] = (n >> 8) & 0xff; buf[3] = n & 0xff
return buf
}
// -- msgHeaderP (lines 733-740)
function decodeMsgHeader(v: number, data: Uint8Array): MsgHeader {
const d = new Decoder(data)
const msgMaxVersion = decodeWord16(d)
const dhDer = decodeBytes(d)
const msgDHRs = decodePubKeyX448(dhDer)
let msgKEM: RKEMParams | null = null
if (v >= pqRatchetE2EEncryptVersion) {
// Maybe ARKEMParams
const maybeByte = d.anyByte()
if (maybeByte === 0x31) {
// Just - parse ARKEMParams
const tag = d.anyByte()
if (tag === 0x50) { // 'P' - Proposed: KEMPublicKey (Large)
msgKEM = {type: "proposed", kemPk: decodeLarge(d)}
} else if (tag === 0x41) { // 'A' - Accepted: KEMCiphertext (Large) + KEMPublicKey (Large)
const kemCt = decodeLarge(d)
const kemPk = decodeLarge(d)
msgKEM = {type: "accepted", kemCt, kemPk}
} else {
throw new Error("decodeMsgHeader: unknown KEM tag " + tag)
}
}
// else '0' = Nothing, msgKEM stays null
}
const msgPN = decodeWord32(d)
const msgNs = decodeWord32(d)
return {msgMaxVersion, msgDHRs, msgKEM, msgPN, msgNs}
}
// -- EncMessageHeader (lines 742-756)
interface EncMessageHeader {
ehVersion: number // current ratchet version
ehIV: Uint8Array // IV (raw 16 bytes)
ehAuthTag: Uint8Array // AuthTag (raw 16 bytes)
ehBody: Uint8Array // encrypted header body
}
// smpEncode (lines 751-752)
function encodeEncMessageHeader(emh: EncMessageHeader): Uint8Array {
const vBytes = new Uint8Array(2)
vBytes[0] = (emh.ehVersion >> 8) & 0xff
vBytes[1] = emh.ehVersion & 0xff
// smpEncode (ehVersion, ehIV, ehAuthTag) <> encodeLarge ehVersion ehBody
const bodyEnc = emh.ehVersion >= pqRatchetE2EEncryptVersion
? encodeLarge(emh.ehBody)
: encodeBytes(emh.ehBody)
return concatBytes(vBytes, emh.ehIV, emh.ehAuthTag, bodyEnc)
}
// smpP (lines 753-756)
function decodeEncMessageHeader(data: Uint8Array): EncMessageHeader {
const d = new Decoder(data)
const ehVersion = decodeWord16(d)
const ehIV = d.take(16) // IV is raw 16 bytes
const ehAuthTag = d.take(16) // AuthTag is raw 16 bytes
// largeP: peek first byte, if < 32 then Large (2-byte len), else ByteString (1-byte len)
const firstByte = data[d.offset()]
const ehBody = firstByte < 32 ? decodeLarge(d) : decodeBytes(d)
return {ehVersion, ehIV, ehAuthTag, ehBody}
}
// -- EncRatchetMessage (lines 772-787)
interface EncRatchetMessage {
emHeader: Uint8Array // smpEncoded EncMessageHeader
emAuthTag: Uint8Array // AuthTag (raw 16 bytes)
emBody: Uint8Array // encrypted message body
}
// encodeEncRatchetMessage (lines 779-781)
function encodeEncRatchetMessage(v: number, msg: EncRatchetMessage): Uint8Array {
// encodeLarge v emHeader <> smpEncode (emAuthTag, Tail emBody)
const headerEnc = v >= pqRatchetE2EEncryptVersion
? encodeLarge(msg.emHeader)
: encodeBytes(msg.emHeader)
return concatBytes(headerEnc, msg.emAuthTag, msg.emBody)
}
// encRatchetMessageP (lines 783-787)
function decodeEncRatchetMessage(data: Uint8Array): EncRatchetMessage {
const d = new Decoder(data)
// largeP
const firstByte = data[d.offset()]
const emHeader = firstByte < 32 ? decodeLarge(d) : decodeBytes(d)
// smpEncode (emAuthTag, Tail emBody) → raw 16 bytes + rest
const emAuthTag = d.take(16)
const emBody = d.takeAll()
return {emHeader, emAuthTag, emBody}
}
// -- MsgEncryptKey (lines 962-968)
interface MsgEncryptKey {
msgRcVersion: number
msgKey: MessageKey
msgRcAD: Uint8Array
msgEncHeader: Uint8Array
}
// -- msgKEMParams (lines 956-958) - build KEM params from ratchet state for message header
function msgKEMParams(kem: RatchetKEM): RKEMParams {
const {rcPQRs, rcKEMs} = kem
if (!rcKEMs) {
return {type: "proposed", kemPk: rcPQRs.publicKey}
}
return {type: "accepted", kemCt: rcKEMs.rcPQRct, kemPk: rcPQRs.publicKey}
}
// -- pqEnableSupport (line 836-837)
function pqEnableSupport(v: number, sup: boolean, enc: boolean): boolean {
return sup || (v >= pqRatchetE2EEncryptVersion && enc)
}
// -- rcEncryptHeader + rcEncryptMsg (lines 902-975)
export interface EncryptResult {
ciphertext: Uint8Array
state: Ratchet
}
export function rcEncrypt(
rc: Ratchet,
plaintext: Uint8Array,
paddedMsgLen: number,
): EncryptResult {
if (!rc.rcSnd) throw new Error("rcEncrypt: no sending ratchet (CERatchetState)")
const snd = rc.rcSnd
const v = rc.rcVersion.current
// state.CKs, mk = KDF_CK(state.CKs)
const chain = chainKdf(snd.rcCKs)
// header
const headerPlain = encodeMsgHeader(v, {
msgMaxVersion: rc.rcVersion.maxSupported,
msgDHRs: x448.getPublicKey(rc.rcDHRs),
msgKEM: rc.rcKEM ? msgKEMParams(rc.rcKEM) : null,
msgPN: rc.rcPN,
msgNs: rc.rcNs,
})
// enc_header = HENCRYPT(state.HKs, header)
const phl = paddedHeaderLen(v, rc.rcSupportKEM)
const {authTag: ehAuthTag, ciphertext: ehBody} = encryptAEAD(snd.rcHKs, chain.ehIV, phl, rc.rcAD, headerPlain)
// smpEncode EncMessageHeader
const emHeader = encodeEncMessageHeader({ehVersion: v, ehBody, ehAuthTag, ehIV: chain.ehIV})
// ENCRYPT(mk, plaintext, CONCAT(AD, enc_header))
const bodyAD = concatBytes(rc.rcAD, emHeader)
const {authTag: emAuthTag, ciphertext: emBody} = encryptAEAD(chain.mk, chain.iv, paddedMsgLen, bodyAD, plaintext)
// encodeEncRatchetMessage
const ciphertext = encodeEncRatchetMessage(v, {emHeader, emBody, emAuthTag})
// Update state
const newState: Ratchet = {
...rc,
rcSnd: {...snd, rcCKs: chain.ck},
rcNs: rc.rcNs + 1,
}
return {ciphertext, state: newState}
}
// -- rcDecrypt (lines 990-1157)
export interface DecryptResult {
plaintext: Uint8Array
state: Ratchet
skippedKeys: SkippedMsgKeys
}
export function rcDecrypt(
rc: Ratchet,
skippedKeys: SkippedMsgKeys,
ciphertext: Uint8Array,
): DecryptResult {
const encMsg = decodeEncRatchetMessage(ciphertext)
const encHdr = decodeEncMessageHeader(encMsg.emHeader)
// TrySkippedMessageKeysHE
const skipped = tryDecryptSkipped(rc, skippedKeys, encHdr, encMsg)
if (skipped) return skipped
// DecryptHeader
let ratchetStep: "same" | "advance" = "advance"
let hdr: MsgHeader | null = null
if (rc.rcRcv) {
hdr = tryDecryptHeader(rc.rcRcv.rcHKr, rc.rcAD, encHdr)
if (hdr) ratchetStep = "same"
}
if (!hdr) {
hdr = tryDecryptHeader(rc.rcNHKr, rc.rcAD, encHdr)
if (!hdr) throw new Error("rcDecrypt: header decryption failed (CERatchetHeader)")
ratchetStep = "advance"
}
// Version upgrade
let state = rc
const {current, maxSupported} = rc.rcVersion
if (hdr.msgMaxVersion > current) {
state = {...state, rcVersion: {...state.rcVersion, current: Math.max(current, Math.min(hdr.msgMaxVersion, maxSupported))}}
}
let newSkipped = new Map(skippedKeys)
if (ratchetStep === "advance") {
// SkipMessageKeysHE(state, header.pn)
const skip1 = skipMessageKeys(state, newSkipped, hdr.msgPN)
state = skip1.state; newSkipped = skip1.skippedKeys
// DHRatchetPQ2HE(state, header) - ratchet step (lines 1043-1071)
const {kemSS, kemSS2, rcKEM: rcKEM_} = pqRatchetStep(state, hdr.msgKEM)
const newDHRs = generateX448KeyPair()
// state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
const kdf1 = rootKdf(state.rcRK, hdr.msgDHRs, state.rcDHRs, kemSS)
// state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs', state.DHRr) || state.PQRss)
const kdf2 = rootKdf(kdf1.rk, hdr.msgDHRs, newDHRs.privateKey, kemSS2)
const sndKEM = kemSS2 !== null
const rcvKEM = kemSS !== null
const rcEnableKEM_ = sndKEM || rcvKEM || rcKEM_ !== null
state = {
...state,
rcDHRs: newDHRs.privateKey,
rcKEM: rcKEM_,
rcSupportKEM: pqEnableSupport(state.rcVersion.current, state.rcSupportKEM, rcEnableKEM_),
rcEnableKEM: rcEnableKEM_,
rcSndKEM: sndKEM,
rcRcvKEM: rcvKEM,
rcRK: kdf2.rk,
rcSnd: {rcDHRr: hdr.msgDHRs, rcCKs: kdf2.ck, rcHKs: state.rcNHKs},
rcRcv: {rcCKr: kdf1.ck, rcHKr: state.rcNHKr},
rcPN: rc.rcNs,
rcNs: 0,
rcNr: 0,
rcNHKs: kdf2.nhk,
rcNHKr: kdf1.nhk,
}
}
// SkipMessageKeysHE(state, header.n)
const skip2 = skipMessageKeys(state, newSkipped, hdr.msgNs)
state = skip2.state; newSkipped = skip2.skippedKeys
if (!state.rcRcv) throw new Error("rcDecrypt: no receiving ratchet after skip")
// state.CKr, mk = KDF_CK(state.CKr)
const chain = chainKdf(state.rcRcv.rcCKr)
// DECRYPT(mk, cipher-text, CONCAT(AD, enc_header))
const bodyAD = concatBytes(state.rcAD, encMsg.emHeader)
const plaintext = decryptAEAD(chain.mk, chain.iv, bodyAD, encMsg.emBody, encMsg.emAuthTag)
// state.Nr += 1
state = {
...state,
rcRcv: {...state.rcRcv, rcCKr: chain.ck},
rcNr: state.rcNr + 1,
}
return {plaintext, state, skippedKeys: newSkipped}
}
// -- skipMessageKeys (lines 1105-1121)
function skipMessageKeys(
rc: Ratchet,
skippedKeys: SkippedMsgKeys,
untilN: number,
): {state: Ratchet; skippedKeys: SkippedMsgKeys} {
if (!rc.rcRcv) return {state: rc, skippedKeys}
const rcv = rc.rcRcv
const rcNr = rc.rcNr
if (rcNr > untilN + 1) throw new Error("rcDecrypt: earlier message (CERatchetEarlierMessage)")
if (rcNr === untilN + 1) throw new Error("rcDecrypt: duplicate message (CERatchetDuplicateMessage)")
if (rcNr + MAX_SKIP < untilN) throw new Error("rcDecrypt: too many skipped (CERatchetTooManySkipped)")
if (rcNr === untilN) return {state: rc, skippedKeys}
// advanceRcvRatchet
let ck = rcv.rcCKr
let nr = rcNr
const hkHex = hexKey(rcv.rcHKr)
const msgKeys = new Map(skippedKeys.get(hkHex) || new Map())
while (nr < untilN) {
const chain = chainKdf(ck)
msgKeys.set(nr, {mk: chain.mk, iv: chain.iv})
ck = chain.ck
nr++
}
const newSkipped = new Map(skippedKeys)
newSkipped.set(hkHex, msgKeys)
return {
state: {...rc, rcRcv: {...rcv, rcCKr: ck}, rcNr: nr},
skippedKeys: newSkipped,
}
}
// -- tryDecryptSkipped (lines 1122-1141)
function tryDecryptSkipped(
rc: Ratchet,
skippedKeys: SkippedMsgKeys,
encHdr: EncMessageHeader,
encMsg: EncRatchetMessage,
): DecryptResult | null {
for (const [hkHex, msgKeys] of skippedKeys) {
const hk = hexToBytes(hkHex)
const hdr = tryDecryptHeader(hk, rc.rcAD, encHdr)
if (hdr) {
const mk = msgKeys.get(hdr.msgNs)
if (mk) {
const bodyAD = concatBytes(rc.rcAD, encMsg.emHeader)
const plaintext = decryptAEAD(mk.mk, mk.iv, bodyAD, encMsg.emBody, encMsg.emAuthTag)
const newMsgKeys = new Map(msgKeys)
newMsgKeys.delete(hdr.msgNs)
const newSkipped = new Map(skippedKeys)
if (newMsgKeys.size === 0) newSkipped.delete(hkHex)
else newSkipped.set(hkHex, newMsgKeys)
return {plaintext, state: rc, skippedKeys: newSkipped}
}
// Header decrypted but msgNs not in skipped keys - check if same/advance ratchet
// For now, fall through to normal decrypt
}
}
return null
}
// -- pqRatchetStep (lines 1072-1104)
// Returns (kemSS for receive rootKdf, kemSS' for send rootKdf, new RatchetKEM state)
function pqRatchetStep(
rc: Ratchet,
msgKEM: RKEMParams | null,
): {kemSS: Uint8Array | null; kemSS2: Uint8Array | null; rcKEM: RatchetKEM | null} {
const pqEnc = rc.rcEnableKEM
const v = rc.rcVersion.current
if (!msgKEM) {
// Received message does not have KEM in header
if (!rc.rcKEM && pqEnc && v >= pqRatchetE2EEncryptVersion) {
// User enabled KEM but no KEM state yet - generate new keypair
const rcPQRs = sntrup761Keypair()
return {kemSS: null, kemSS2: null, rcKEM: {rcPQRs, rcKEMs: null}}
}
return {kemSS: null, kemSS2: null, rcKEM: null}
}
// Received message has KEM in header
if (pqEnc && v >= pqRatchetE2EEncryptVersion) {
// Get shared secret from received KEM params
const {ss, rcPQRr} = kemSharedSecret(rc.rcKEM, msgKEM)
// state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss)
const kemEncResult = sntrup761Enc(rcPQRr)
// state.PQRs = GENERATE_PQKEM()
const rcPQRs = sntrup761Keypair()
const kem: RatchetKEM = {
rcPQRs,
rcKEMs: {rcPQRr, rcPQRss: kemEncResult.sharedSecret, rcPQRct: kemEncResult.ciphertext},
}
return {kemSS: ss, kemSS2: kemEncResult.sharedSecret, rcKEM: kem}
}
// PQ not enabled but message has KEM - extract shared secret only (no new KEM state)
const {ss} = kemSharedSecret(rc.rcKEM, msgKEM)
return {kemSS: ss, kemSS2: null, rcKEM: null}
}
// Extract shared secret from received KEM params (lines 1097-1104)
function kemSharedSecret(
rcKEM: RatchetKEM | null,
params: RKEMParams,
): {ss: Uint8Array | null; rcPQRr: Uint8Array} {
if (params.type === "proposed") {
// RKParamsProposed k -> no shared secret yet, just received the public key
return {ss: null, rcPQRr: params.kemPk}
}
// RKParamsAccepted ct k -> decapsulate ct with our private KEM key
if (!rcKEM) throw new Error("pqRatchetStep: CERatchetKEMState - no KEM state for accepted params")
const ss = sntrup761Dec(params.kemCt, rcKEM.rcPQRs.secretKey)
return {ss, rcPQRr: params.kemPk}
}
// -- decryptHeader helper (lines 1151-1153)
function tryDecryptHeader(headerKey: Uint8Array, ad: Uint8Array, encHdr: EncMessageHeader): MsgHeader | null {
try {
const plainHeader = decryptAEAD(headerKey, encHdr.ehIV, ad, encHdr.ehBody, encHdr.ehAuthTag)
return decodeMsgHeader(encHdr.ehVersion, plainHeader)
} catch {
return null
}
}
+50
View File
@@ -0,0 +1,50 @@
// Short link key derivation and decryption.
// Mirrors: Simplex.Messaging.Crypto.ShortLink
import {hkdf} from "../crypto.js"
import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {Decoder, decodeBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
const emptySalt = new Uint8Array(0)
// contactShortLinkKdf (Crypto/ShortLink.hs:47-50)
// hkdf("", linkKey, "SimpleXContactLink", 56) -> (linkId[24], sbKey[32])
export function contactShortLinkKdf(linkKey: Uint8Array): {linkId: Uint8Array; sbKey: Uint8Array} {
const derived = hkdf(emptySalt, linkKey, "SimpleXContactLink", 56)
return {
linkId: derived.slice(0, 24),
sbKey: derived.slice(24, 56),
}
}
// invShortLinkKdf (Crypto/ShortLink.hs:52-53)
// hkdf("", linkKey, "SimpleXInvLink", 32) -> sbKey[32]
export function invShortLinkKdf(linkKey: Uint8Array): Uint8Array {
return hkdf(emptySalt, linkKey, "SimpleXInvLink", 32)
}
// decryptLinkData (Crypto/ShortLink.hs:100-125)
// Decrypts both EncDataBytes blobs, strips signature prefix, returns raw data.
// Signature verification is skipped for spike.
export function decryptLinkData(
sbKey: Uint8Array,
encFixedData: Uint8Array,
encUserData: Uint8Array
): {fixedData: Uint8Array; userData: Uint8Array} {
return {
fixedData: decryptSigned(sbKey, encFixedData),
userData: decryptSigned(sbKey, encUserData),
}
}
// EncDataBytes format: [nonce 24 bytes][ciphertext with prepended Poly1305 tag]
// After decrypt+unpad: [sig ByteString (1-byte len + 64 bytes)][data]
function decryptSigned(sbKey: Uint8Array, encData: Uint8Array): Uint8Array {
const nonce = encData.subarray(0, 24)
const ct = encData.subarray(24)
const plaintext = cbDecrypt(sbKey, nonce, ct)
// Skip signature: decodeBytes reads 1-byte length + that many bytes
const d = new Decoder(plaintext)
decodeBytes(d) // signature, discarded
return d.takeAll()
}
+89
View File
@@ -0,0 +1,89 @@
// SNTRUP761 post-quantum KEM.
// Mirrors: Simplex.Messaging.Crypto.SNTRUP761
//
// Uses WASM compiled from the same C source as the Haskell build
// (cbits/sntrup761.c by djb et al., public domain).
// SHA-512 from SUPERCOP/NaCl (djb, public domain).
// Key sizes (from sntrup761.h)
export const SNTRUP761_PUBLICKEY_SIZE = 1158
export const SNTRUP761_SECRETKEY_SIZE = 1763
export const SNTRUP761_CIPHERTEXT_SIZE = 1039
export const SNTRUP761_SIZE = 32 // shared secret
export interface KEMKeyPair {
publicKey: Uint8Array // 1158 bytes
secretKey: Uint8Array // 1763 bytes
}
export interface KEMEncResult {
ciphertext: Uint8Array // 1039 bytes
sharedSecret: Uint8Array // 32 bytes
}
// WASM module instance
let wasmModule: any = null
export async function initSntrup761(): Promise<void> {
if (wasmModule) return
const createSntrup761 = (await import("../../dist/wasm/sntrup761.mjs")).default
wasmModule = await createSntrup761()
}
function getModule(): any {
if (!wasmModule) throw new Error("sntrup761 WASM not initialized - call initSntrup761() first")
return wasmModule
}
export function sntrup761Keypair(): KEMKeyPair {
const m = getModule()
const pkPtr = m._malloc(SNTRUP761_PUBLICKEY_SIZE)
const skPtr = m._malloc(SNTRUP761_SECRETKEY_SIZE)
try {
m._sntrup761_wasm_keypair(pkPtr, skPtr)
const publicKey = new Uint8Array(m.HEAPU8.buffer, pkPtr, SNTRUP761_PUBLICKEY_SIZE).slice()
const secretKey = new Uint8Array(m.HEAPU8.buffer, skPtr, SNTRUP761_SECRETKEY_SIZE).slice()
return {publicKey, secretKey}
} finally {
m._free(pkPtr)
m._free(skPtr)
}
}
export function sntrup761Enc(publicKey: Uint8Array): KEMEncResult {
if (publicKey.length !== SNTRUP761_PUBLICKEY_SIZE) throw new Error("bad public key length")
const m = getModule()
const pkPtr = m._malloc(SNTRUP761_PUBLICKEY_SIZE)
const ctPtr = m._malloc(SNTRUP761_CIPHERTEXT_SIZE)
const ssPtr = m._malloc(SNTRUP761_SIZE)
try {
m.HEAPU8.set(publicKey, pkPtr)
m._sntrup761_wasm_enc(ctPtr, ssPtr, pkPtr)
const ciphertext = new Uint8Array(m.HEAPU8.buffer, ctPtr, SNTRUP761_CIPHERTEXT_SIZE).slice()
const sharedSecret = new Uint8Array(m.HEAPU8.buffer, ssPtr, SNTRUP761_SIZE).slice()
return {ciphertext, sharedSecret}
} finally {
m._free(pkPtr)
m._free(ctPtr)
m._free(ssPtr)
}
}
export function sntrup761Dec(ciphertext: Uint8Array, secretKey: Uint8Array): Uint8Array {
if (ciphertext.length !== SNTRUP761_CIPHERTEXT_SIZE) throw new Error("bad ciphertext length")
if (secretKey.length !== SNTRUP761_SECRETKEY_SIZE) throw new Error("bad secret key length")
const m = getModule()
const ctPtr = m._malloc(SNTRUP761_CIPHERTEXT_SIZE)
const skPtr = m._malloc(SNTRUP761_SECRETKEY_SIZE)
const ssPtr = m._malloc(SNTRUP761_SIZE)
try {
m.HEAPU8.set(ciphertext, ctPtr)
m.HEAPU8.set(secretKey, skPtr)
m._sntrup761_wasm_dec(ssPtr, ctPtr, skPtr)
return new Uint8Array(m.HEAPU8.buffer, ssPtr, SNTRUP761_SIZE).slice()
} finally {
m._free(ctPtr)
m._free(skPtr)
m._free(ssPtr)
}
}
+12
View File
@@ -0,0 +1,12 @@
// SMP protocol client for web/browser environments.
// Re-exports encoding primitives from xftp-web for convenience.
export {
Decoder,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge,
encodeWord16, decodeWord16,
encodeBool, decodeBool,
encodeMaybe, decodeMaybe,
encodeList, decodeList,
concatBytes
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
+571
View File
@@ -0,0 +1,571 @@
// SMP protocol commands and transmission format.
// Mirrors: Simplex.Messaging.Protocol + Simplex.Messaging.Client (auth)
import {
Decoder, concatBytes,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge,
encodeWord16, decodeWord16,
encodeBool, decodeBool,
encodeMaybe, decodeMaybe,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {sign} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
import {readTag, readSpace} from "@simplex-chat/xftp-web/dist/protocol/commands.js"
import {cbAuthenticator} from "./crypto.js"
// -- Auth key type for command authentication (Client.hs:1372-1391)
export type AuthKey =
| {type: "x25519", key: Uint8Array} // raw 32-byte private key → cbAuthenticator
| {type: "ed25519", key: Uint8Array} // raw 64-byte private key → sign
// -- Transmission encoding (Protocol.hs:2186-2198)
// encodeTransmission_ (Protocol.hs:2194-2198)
// smpEncode (corrId, entityId) <> encodeProtocol v command
// (command is pre-encoded bytes)
export function encodeTransmission(corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array): Uint8Array {
return concatBytes(encodeBytes(corrId), encodeBytes(entityId), command)
}
// encodeTransmissionForAuth (Protocol.hs:2186-2192)
// implySessId = true for v>=7 (always true for web client v19)
// tForAuth = sessionId <> encodeTransmission_(...)
// tToSend = encodeTransmission_(...)
export function encodeTransmissionForAuth(
sessionId: Uint8Array, corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array,
): {tForAuth: Uint8Array, tToSend: Uint8Array} {
const tToSend = encodeTransmission(corrId, entityId, command)
const tForAuth = concatBytes(encodeBytes(sessionId), tToSend)
return {tForAuth, tToSend}
}
// -- Command authentication (Client.hs:1372-1391)
// authTransmission: produce auth bytes for a transmission
// Returns null for unauthenticated commands, Uint8Array of auth bytes otherwise
export function authTransmission(
serverPubKey: Uint8Array, // server's X25519 public key from handshake
privKey: AuthKey | null, // null for unauthenticated commands (LGET, SEND without key)
nonce: Uint8Array, // 24-byte CorrId/nonce (same bytes)
tForAuth: Uint8Array, // transmission bytes to authenticate
): Uint8Array | null {
if (privKey === null) return null
switch (privKey.type) {
case "x25519":
// TAAuthenticator: cbAuthenticate(serverPubKey, entityPrivKey, nonce, tForAuth)
return cbAuthenticator(serverPubKey, privKey.key, nonce, tForAuth)
case "ed25519":
// TASignature: sign(entityPrivKey, tForAuth)
return sign(privKey.key, tForAuth)
}
}
// tEncodeAuth (Protocol.hs:507-516)
// For v16+ (serviceAuth=true): when auth is present, encode serviceSig as Nothing (0x30) after auth.
// When auth is absent: just empty ByteString.
export function tEncodeAuth(auth: Uint8Array | null): Uint8Array {
if (auth === null) return encodeBytes(new Uint8Array(0)) // empty ByteString: [0x00]
// serviceAuth=true for v16+: smpEncode (authBytes, serviceSig) where serviceSig = Nothing
return concatBytes(encodeBytes(auth), new Uint8Array([0x30])) // auth + Nothing
}
// tEncode (Protocol.hs:2171-2172)
export function tEncode(auth: Uint8Array | null, tToSend: Uint8Array): Uint8Array {
return concatBytes(tEncodeAuth(auth), tToSend)
}
// tEncodeBatch1 (Protocol.hs:2179-2180)
// Single-command batch: count=1 + Large(tEncode(...))
export function tEncodeBatch1(auth: Uint8Array | null, tToSend: Uint8Array): Uint8Array {
return concatBytes(new Uint8Array([1]), encodeLarge(tEncode(auth, tToSend)))
}
// tEncodeForBatch (Protocol.hs:2175-2176)
// Large(tEncode(...)) — for multi-command batches
export function tEncodeForBatch(auth: Uint8Array | null, tToSend: Uint8Array): Uint8Array {
return encodeLarge(tEncode(auth, tToSend))
}
// batchTransmissions (Protocol.hs:2151-2168)
// Pack multiple encoded transmissions into ≤blockSize blocks.
// Each input is an already-encoded Large-wrapped transmission.
// Returns array of blocks, each prefixed with count byte.
export function batchTransmissions(blockSize: number, transmissions: Uint8Array[]): Uint8Array[] {
const maxPayload = blockSize - 19 // 2 pad + 1 count + 16 auth tag
const blocks: Uint8Array[] = []
let currentParts: Uint8Array[] = []
let currentLen = 0
let count = 0
for (const t of transmissions) {
const tLen = t.length
if (tLen > maxPayload) throw new Error("batchTransmissions: transmission too large")
if (currentLen + tLen > maxPayload || count >= 255) {
if (count > 0) blocks.push(concatBytes(new Uint8Array([count]), ...currentParts))
currentParts = [t]
currentLen = tLen
count = 1
} else {
currentParts.push(t)
currentLen += tLen
count++
}
}
if (count > 0) blocks.push(concatBytes(new Uint8Array([count]), ...currentParts))
return blocks
}
// -- Transmission parsing (Protocol.hs:1629-1643, 2211-2267)
export interface RawTransmission {
corrId: Uint8Array
entityId: Uint8Array
command: Uint8Array
}
// transmissionP (Protocol.hs:1629-1642)
// Parse a single transmission from block bytes.
// implySessId=true, serviceAuth=false for web client.
export function transmissionP(data: Uint8Array): RawTransmission {
const d = new Decoder(data)
const auth = decodeBytes(d) // authenticator
// serviceAuth=true for v16+: if auth is non-empty, skip serviceSig (Maybe Signature)
if (auth.length > 0) {
decodeMaybe(decodeBytes, d) // skip serviceSig
}
const rest = d.takeAll() // authorized bytes
// re-parse authorized: corrId + entityId + command
const d2 = new Decoder(rest)
// implySessId=true: no sessionId in wire format
const corrId = decodeBytes(d2)
const entityId = decodeBytes(d2)
const command = d2.takeAll()
return {corrId, entityId, command}
}
// tParse (Protocol.hs:2211-2217)
// Parse a received block into individual transmissions.
// batch=true: count byte + N Large-wrapped transmissions
export function tParse(block: Uint8Array): RawTransmission[] {
const d = new Decoder(block)
const count = d.anyByte()
const transmissions: RawTransmission[] = []
for (let i = 0; i < count; i++) {
const data = decodeLarge(d)
transmissions.push(transmissionP(data))
}
return transmissions
}
// tDecodeClient (Protocol.hs:2256-2266)
// Parse command bytes into typed response.
export function tDecodeClient(raw: RawTransmission): {corrId: Uint8Array, entityId: Uint8Array, response: SMPResponse} {
const response = decodeResponse(new Decoder(raw.command))
return {corrId: raw.corrId, entityId: raw.entityId, response}
}
// -- SMP command tags
const SPACE = 0x20
function ascii(s: string): Uint8Array {
const buf = new Uint8Array(s.length)
for (let i = 0; i < s.length; i++) buf[i] = s.charCodeAt(i)
return buf
}
// -- LGET command (Protocol.hs:1709)
// No parameters. EntityId carries LinkId in transmission.
export function encodeLGET(): Uint8Array {
return ascii("LGET")
}
// -- LNK response (Protocol.hs:1834)
// LNK sId d -> e (LNK_, ' ', sId, d)
// where d = (EncFixedDataBytes, EncUserDataBytes), both Large-encoded
export interface LNKResponse {
senderId: Uint8Array
encFixedData: Uint8Array
encUserData: Uint8Array
}
export function decodeLNK(d: Decoder): LNKResponse {
const senderId = decodeBytes(d)
const encFixedData = decodeLarge(d)
const encUserData = decodeLarge(d)
return {senderId, encFixedData, encUserData}
}
// -- Response dispatch (same pattern as xftp-web decodeResponse)
export interface PKEYResponse {
sessionId: Uint8Array
versionRange: {min: number, max: number}
certChainDer: Uint8Array // Large-encoded DER certificate chain
signedKeyDer: Uint8Array // Large-encoded DER signed public key
}
export type SMPResponse =
| {type: "LNK", response: LNKResponse}
| {type: "IDS", response: IDSResponse}
| {type: "MSG", response: MSGResponse}
| {type: "OK"}
| {type: "SOK", serviceId: Uint8Array | null}
| {type: "PKEY", response: PKEYResponse}
| {type: "PRES", encResponse: Uint8Array}
| {type: "PONG"}
| {type: "END"}
| {type: "DELD"}
| {type: "ERR", error: string}
// protocolError check (Client.hs:710-712)
// Returns the error string if this is an ERR response, null otherwise
export function protocolError(resp: SMPResponse): string | null {
return resp.type === "ERR" ? resp.error : null
}
export function decodeResponse(d: Decoder): SMPResponse {
const tag = readTag(d)
switch (tag) {
case "LNK": {
readSpace(d)
return {type: "LNK", response: decodeLNK(d)}
}
case "IDS": {
readSpace(d)
return {type: "IDS", response: decodeIDS(d)}
}
case "MSG": {
readSpace(d)
return {type: "MSG", response: decodeMSG(d)}
}
case "OK": return {type: "OK"}
case "SOK": {
// SOK serviceId_ → e(SOK_, ' ', serviceId_)
readSpace(d)
const serviceId = d.remaining() > 0 ? decodeMaybe(decodeBytes, d) : null
return {type: "SOK", serviceId}
}
case "PKEY": {
// PKEY sessionId versionRange certChainPubKey (Protocol.hs:1894)
// PKEY_ -> PKEY <$> _smpP <*> smpP <*> smpP
// sessionId: ByteString, versionRange: (Word16, Word16)
// certChainPubKey: (NonEmpty Large, SignedObject) (Transport.hs:663-664)
// certChain = NonEmpty Large = 1-byte count + N × Large(2-byte len + DER)
// signedKey = Large(2-byte len + DER)
readSpace(d)
const sessionId = decodeBytes(d)
const min = decodeWord16(d)
const max = decodeWord16(d)
// certChain: NonEmpty Large (1-byte count + N × Large-encoded DER certs)
const certCount = d.anyByte()
const certChainDers: Uint8Array[] = []
for (let i = 0; i < certCount; i++) certChainDers.push(decodeLarge(d))
// signedKey: Large-encoded DER
const signedKeyDer = decodeLarge(d)
return {type: "PKEY", response: {sessionId, versionRange: {min, max}, certChainDer: certChainDers[0] ?? new Uint8Array(0), signedKeyDer}}
}
case "PRES": {
// PRES (EncResponse encBlock) (Protocol.hs:1896)
// PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP)
readSpace(d)
return {type: "PRES", encResponse: d.takeAll()}
}
case "PONG": return {type: "PONG"}
case "END": return {type: "END"}
case "DELD": return {type: "DELD"}
case "ERR": {
readSpace(d)
// Read the full error string (may be multi-word like "AUTH" or "QUOTA")
const errBytes = d.takeAll()
return {type: "ERR", error: new TextDecoder().decode(errBytes)}
}
default: throw new Error("unknown SMP response: " + tag)
}
}
// -- SMP command encoders (Protocol.hs:1679-1715)
// MsgFlags (Protocol.hs:884-892)
// Single byte: Bool encoding of notification flag
export function encodeMsgFlags(notification: boolean): Uint8Array {
return encodeBool(notification)
}
// SubscriptionMode (Protocol.hs:651-659)
// 'S' = SMSubscribe, 'C' = SMOnlyCreate
export function encodeSubMode(subscribe: boolean): Uint8Array {
return ascii(subscribe ? "S" : "C")
}
// NEW (Protocol.hs:1682-1689)
// For v19: e(NEW_, ' ', rKey, dhKey) <> e(auth_, subMode, queueReqData, ntfCreds)
// QueueReqData: QRMessaging Nothing = 'M' + Nothing(0x30)
export function encodeNEW(
rcvAuthKey: Uint8Array, // DER-encoded Ed25519 or X25519 public key
rcvDhKey: Uint8Array, // DER-encoded X25519 public key
basicAuth: Uint8Array | null, // Maybe BasicAuth (server auth, not a crypto key)
subscribe: boolean,
): Uint8Array {
// QRMessaging Nothing: Just('M', Nothing) = 0x31 0x4D 0x30
const queueReqData = new Uint8Array([0x31, 0x4D, 0x30])
return concatBytes(
ascii("NEW "),
encodeBytes(rcvAuthKey),
encodeBytes(rcvDhKey),
encodeMaybe(encodeBytes, basicAuth),
encodeSubMode(subscribe),
queueReqData,
new Uint8Array([0x30]), // ntfCreds = Nothing
)
}
// KEY (Protocol.hs:1692)
// KEY k -> e(KEY_, ' ', k)
export function encodeKEY(senderKey: Uint8Array): Uint8Array {
return concatBytes(ascii("KEY "), encodeBytes(senderKey))
}
// SKEY (Protocol.hs:1703)
// SKEY k -> e(SKEY_, ' ', k)
export function encodeSKEY(senderKey: Uint8Array): Uint8Array {
return concatBytes(ascii("SKEY "), encodeBytes(senderKey))
}
// SUB (Protocol.hs:1690)
export function encodeSUB(): Uint8Array {
return ascii("SUB")
}
// ACK (Protocol.hs:1699)
// ACK msgId -> e(ACK_, ' ', msgId)
export function encodeACK(msgId: Uint8Array): Uint8Array {
return concatBytes(ascii("ACK "), encodeBytes(msgId))
}
// SEND (Protocol.hs:1704)
// SEND flags msg -> e(SEND_, ' ', flags, ' ', Tail msg)
export function encodeSEND(notification: boolean, msgBody: Uint8Array): Uint8Array {
return concatBytes(
ascii("SEND "),
encodeMsgFlags(notification),
ascii(" "),
msgBody, // Tail - no length prefix
)
}
// OFF (Protocol.hs:1700)
export function encodeOFF(): Uint8Array {
return ascii("OFF")
}
// DEL (Protocol.hs:1701)
export function encodeDEL(): Uint8Array {
return ascii("DEL")
}
// GET (Protocol.hs:1698)
export function encodeGET(): Uint8Array {
return ascii("GET")
}
// QUE (Protocol.hs:1702)
export function encodeQUE(): Uint8Array {
return ascii("QUE")
}
// PING (Protocol.hs:1705)
export function encodePING(): Uint8Array {
return ascii("PING")
}
// -- Proxy commands (Protocol.hs:1710-1711)
// encodeProtocolServer (Protocol.hs:1264-1266)
// smpEncode ProtocolServer {host, port, keyHash} = smpEncode (host, port, keyHash)
// host :: NonEmpty TransportHost → smpEncodeList (1-byte count + encodeBytes(strEncode(host)) for each)
// port :: ServiceName = ByteString → encodeBytes
// keyHash :: KeyHash = ByteString → encodeBytes
export function encodeProtocolServer(hosts: string[], port: string, keyHash: Uint8Array): Uint8Array {
const encodedHosts = hosts.map(h => encodeBytes(ascii(h)))
const hostList = concatBytes(new Uint8Array([hosts.length]), ...encodedHosts)
return concatBytes(hostList, encodeBytes(ascii(port)), encodeBytes(keyHash))
}
// PRXY (Protocol.hs:1710)
// PRXY host auth_ -> e(PRXY_, ' ', host, auth_)
export function encodePRXY(hosts: string[], port: string, keyHash: Uint8Array, basicAuth: Uint8Array | null): Uint8Array {
return concatBytes(
ascii("PRXY "),
encodeProtocolServer(hosts, port, keyHash),
encodeMaybe(encodeBytes, basicAuth),
)
}
// PFWD (Protocol.hs:1711)
// PFWD fwdV pubKey (EncTransmission s) -> e(PFWD_, ' ', fwdV, pubKey, Tail s)
export function encodePFWD(version: number, pubKeyDer: Uint8Array, encTransmission: Uint8Array): Uint8Array {
return concatBytes(
ascii("PFWD "),
encodeWord16(version),
encodeBytes(pubKeyDer),
encTransmission, // Tail — no length prefix
)
}
// paddedProxiedTLength (Protocol.hs:306-307)
export const paddedProxiedTLength = 16226
// -- SMP response decoders
// IDS (Protocol.hs:1914-1921)
// For v19: e(IDS_, ' ', rcvId, sndId, srvDh) <> e(queueMode, linkId, serviceId, ntfCreds)
export interface IDSResponse {
rcvId: Uint8Array
sndId: Uint8Array
srvDhKey: Uint8Array
queueMode: string | null // 'M' = Messaging, 'C' = Contact
linkId: Uint8Array | null
}
export function decodeIDS(d: Decoder): IDSResponse {
const rcvId = decodeBytes(d)
const sndId = decodeBytes(d)
const srvDhKey = decodeBytes(d)
// v19: queueMode (Maybe QueueMode), linkId (Maybe ByteString), serviceId, ntfCreds
// QueueMode is encoded as Maybe Char ('M'/'C'), not Maybe ByteString
let queueMode: string | null = null
if (d.remaining() > 0) {
const qmByte = d.anyByte()
if (qmByte === 0x31) { // '1' = Just
queueMode = String.fromCharCode(d.anyByte())
}
// '0' = Nothing, queueMode stays null
}
let linkId: Uint8Array | null = null
if (d.remaining() > 0) {
linkId = decodeMaybe(decodeBytes, d)
}
// serviceId and ntfCreds - skip remaining
return {rcvId, sndId, srvDhKey, queueMode, linkId}
}
// MSG (Protocol.hs:1927-1928)
// MSG RcvMessage {msgId, msgBody = EncRcvMsgBody body} -> e(MSG_, ' ', msgId, Tail body)
export interface MSGResponse {
msgId: Uint8Array
msgBody: Uint8Array
}
export function decodeMSG(d: Decoder): MSGResponse {
const msgId = decodeBytes(d)
const msgBody = d.takeAll()
return {msgId, msgBody}
}
// -- Per-queue E2E encryption (Protocol.hs:1071-1114)
// Protocol.hs:316-320
export const e2eEncMessageLength = 16000
export const e2eEncConfirmationLength = 15904
// Protocol.hs:1078-1086
export interface PubHeader {
phVersion: number // VersionSMPC (Word16)
phE2ePubDhKey: Uint8Array | null // Maybe PublicKeyX25519 (DER-encoded ByteString)
}
export function encodePubHeader(h: PubHeader): Uint8Array {
return concatBytes(encodeWord16(h.phVersion), encodeMaybe(encodeBytes, h.phE2ePubDhKey))
}
export function decodePubHeader(d: Decoder): PubHeader {
return {phVersion: decodeWord16(d), phE2ePubDhKey: decodeMaybe(decodeBytes, d)}
}
// Protocol.hs:1097-1110
export type PrivHeader =
| {type: "PHConfirmation", key: Uint8Array} // 'K' + DER-encoded APublicAuthKey
| {type: "PHEmpty"} // '_'
export function encodePrivHeader(h: PrivHeader): Uint8Array {
switch (h.type) {
case "PHConfirmation": return concatBytes(new Uint8Array([0x4B]), encodeBytes(h.key)) // 'K' + encodeBytes
case "PHEmpty": return new Uint8Array([0x5F]) // '_'
}
}
export function decodePrivHeader(d: Decoder): PrivHeader {
const tag = d.anyByte()
switch (tag) {
case 0x4B: return {type: "PHConfirmation", key: decodeBytes(d)} // 'K'
case 0x5F: return {type: "PHEmpty"} // '_'
default: throw new Error("decodePrivHeader: unknown tag " + tag)
}
}
// Protocol.hs:1095, 1112-1114
export interface ClientMessage {
privHeader: PrivHeader
body: Uint8Array
}
// smpEncode (ClientMessage h msg) = smpEncode h <> msg
export function encodeClientMessage(msg: ClientMessage): Uint8Array {
return concatBytes(encodePrivHeader(msg.privHeader), msg.body)
}
export function decodeClientMessage(d: Decoder): ClientMessage {
const privHeader = decodePrivHeader(d)
const body = d.takeAll()
return {privHeader, body}
}
// Protocol.hs:1071-1093
export interface ClientMsgEnvelope {
cmHeader: PubHeader
cmNonce: Uint8Array // CbNonce: raw 24 bytes
cmEncBody: Uint8Array // encrypted body (Tail)
}
// smpEncode (cmHeader, cmNonce, Tail cmEncBody)
export function encodeClientMsgEnvelope(env: ClientMsgEnvelope): Uint8Array {
return concatBytes(encodePubHeader(env.cmHeader), env.cmNonce, env.cmEncBody)
}
export function decodeClientMsgEnvelope(d: Decoder): ClientMsgEnvelope {
const cmHeader = decodePubHeader(d)
const cmNonce = d.take(24) // CbNonce is raw 24 bytes
const cmEncBody = d.takeAll()
return {cmHeader, cmNonce, cmEncBody}
}
// -- Per-queue E2E encrypt/decrypt (Agent/Client.hs:2074-2102)
// agentCbEncrypt: encrypt a ClientMessage and wrap in ClientMsgEnvelope
export function agentCbEncrypt(
e2eDhSecret: Uint8Array, // X25519 DH shared secret (32 bytes)
smpClientVersion: number, // Word16
e2ePubKey: Uint8Array | null, // DER-encoded X25519 public key, null for normal messages
msg: Uint8Array, // smpEncode(ClientMessage)
): Uint8Array {
const cmNonce = crypto.getRandomValues(new Uint8Array(24))
const paddedLen = e2ePubKey !== null ? e2eEncConfirmationLength : e2eEncMessageLength
const cmEncBody = cbEncrypt(e2eDhSecret, cmNonce, msg, paddedLen)
const cmHeader: PubHeader = {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey}
return encodeClientMsgEnvelope({cmHeader, cmNonce, cmEncBody})
}
// agentCbDecrypt: decrypt a ClientMsgEnvelope
export function agentCbDecrypt(
dhSecret: Uint8Array, // X25519 DH shared secret (32 bytes)
data: Uint8Array, // raw ClientMsgEnvelope bytes
): {pubHeader: PubHeader, clientMessage: ClientMessage} {
const env = decodeClientMsgEnvelope(new Decoder(data))
const plaintext = cbDecrypt(dhSecret, env.cmNonce, env.cmEncBody)
const clientMessage = decodeClientMessage(new Decoder(plaintext))
return {pubHeader: env.cmHeader, clientMessage}
}
+77
View File
@@ -0,0 +1,77 @@
// SMP transport: handshake, block framing.
// Mirrors: Simplex.Messaging.Transport
import {
Decoder, concatBytes,
encodeWord16, decodeWord16,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge,
encodeBool,
encodeMaybe,
decodeNonEmpty
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- Version constants (Transport.hs:186-213)
export const SMP_BLOCK_SIZE = 16384
export const currentSMPVersion = 19
export const webClientSMPVersion = 19
// -- SMPServerHandshake (Transport.hs:631-640)
export interface SMPServerHandshake {
smpVersionRange: {min: number; max: number}
sessionId: Uint8Array
authPubKey: SMPAuthPubKey | null
webIdentityProof: Uint8Array | null // raw signature bytes (v19+)
}
export interface SMPAuthPubKey {
certChainDer: Uint8Array[] // DER-encoded certificate chain
signedKeyDer: Uint8Array // DER-encoded SignedExact PubKey
}
export function decodeSMPServerHandshake(d: Decoder): SMPServerHandshake {
const min = decodeWord16(d)
const max = decodeWord16(d)
const sessionId = decodeBytes(d)
// authPubKey: version-gated (v7+)
let authPubKey: SMPAuthPubKey | null = null
if (max >= 7 && d.remaining() > 0) {
const certChainDer = decodeNonEmpty(decodeLarge, d)
const signedKeyDer = decodeLarge(d)
authPubKey = {certChainDer, signedKeyDer}
}
// webIdentityProof: version-gated (v19+)
let webIdentityProof: Uint8Array | null = null
if (max >= webClientSMPVersion && d.remaining() > 0) {
webIdentityProof = decodeBytes(d)
}
return {smpVersionRange: {min, max}, sessionId, authPubKey, webIdentityProof}
}
// -- SMPClientHandshake (Transport.hs:592-604)
export interface SMPClientHandshake {
smpVersion: number
keyHash: Uint8Array
authPubKey: Uint8Array | null // X25519 public key, or null for no block encryption
proxyServer: boolean
clientService: null // not used in web client
}
export function encodeSMPClientHandshake(h: SMPClientHandshake): Uint8Array {
const parts: Uint8Array[] = [
encodeWord16(h.smpVersion),
encodeBytes(h.keyHash),
]
// authPubKey: encodeAuthEncryptCmds — empty for Nothing, encodeBytes for Just (v7+)
if (h.authPubKey !== null) {
parts.push(encodeBytes(h.authPubKey))
}
// proxyServer: Bool (v14+)
parts.push(encodeBool(h.proxyServer))
// clientService: Maybe (v16+) — Nothing = '0' (0x30)
parts.push(encodeMaybe(() => new Uint8Array(0), null))
return concatBytes(...parts)
}
+132
View File
@@ -0,0 +1,132 @@
// WebSocket transport for SMP protocol.
// Mirrors: Simplex.Messaging.Transport.WebSockets (client side)
import WebSocket from "ws"
import {randomBytes} from "crypto"
import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {base64urlEncode} from "@simplex-chat/xftp-web/dist/protocol/description.js"
import {blockPad, blockUnpad} from "@simplex-chat/xftp-web/dist/protocol/transmission.js"
import {verifyIdentityProof} from "@simplex-chat/xftp-web/dist/crypto/identity.js"
import {generateX25519KeyPair, dh, encodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
import {extractSignedKey} from "@simplex-chat/xftp-web/dist/protocol/handshake.js"
import {decodeSMPServerHandshake, encodeSMPClientHandshake, SMP_BLOCK_SIZE, currentSMPVersion} from "../transport.js"
import {sbcInit, sbEncryptBlock, sbDecryptBlock} from "../crypto.js"
export interface SMPConnection {
ws: WebSocket
sessionId: Uint8Array
smpVersion: number
// Block encryption state (null if no auth)
sndKey: Uint8Array | null
rcvKey: Uint8Array | null
// Server's raw X25519 public key — needed for command auth (cbAuthenticate)
serverPubKey: Uint8Array | null
}
export async function connectSMP(url: string, keyHash: Uint8Array, wsOptions?: object): Promise<SMPConnection> {
// Generate challenge and append to URL
const challenge = new Uint8Array(randomBytes(32))
const challengeUrl = url + (url.includes("?") ? "&" : "?") + "challenge=" + base64urlEncode(challenge).replace(/=+$/, "")
const ws = new WebSocket(challengeUrl, wsOptions)
ws.binaryType = "arraybuffer"
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = (e) => reject(e)
})
// Receive server handshake (first block)
const serverBlock = await receiveBlock(ws)
const serverHs = decodeSMPServerHandshake(new Decoder(blockUnpad(serverBlock)))
// Negotiate version
const version = Math.min(serverHs.smpVersionRange.max, currentSMPVersion)
if (version < 6) throw new Error("Incompatible server version")
// Verify server identity and extract DH key
let sndKey: Uint8Array | null = null
let rcvKey: Uint8Array | null = null
let clientAuthPubKey: Uint8Array | null = null
let serverPubKey: Uint8Array | null = null
if (serverHs.authPubKey) {
// Verify server identity if server supports web challenge (v19+)
if (serverHs.webIdentityProof) {
const ok = verifyIdentityProof({
certChainDer: serverHs.authPubKey.certChainDer,
signedKeyDer: serverHs.authPubKey.signedKeyDer,
sigBytes: serverHs.webIdentityProof,
challenge,
sessionId: serverHs.sessionId,
keyHash,
})
if (!ok) throw new Error("Server identity verification failed")
}
// DH key exchange for block encryption (v11+)
serverPubKey = extractSignedKey(serverHs.authPubKey.signedKeyDer).dhKey
const clientKp = generateX25519KeyPair()
clientAuthPubKey = encodePubKeyX25519(clientKp.publicKey)
const dhSecret = dh(serverPubKey, clientKp.privateKey)
// Client swaps snd/rcv vs server (Transport.hs:880)
const keys = sbcInit(serverHs.sessionId, dhSecret)
sndKey = keys.rcvKey
rcvKey = keys.sndKey
}
// Send client handshake
const clientHs = encodeSMPClientHandshake({
smpVersion: version,
keyHash,
authPubKey: clientAuthPubKey,
proxyServer: false,
clientService: null
})
sendBlock(ws, blockPad(clientHs, SMP_BLOCK_SIZE))
return {ws, sessionId: serverHs.sessionId, smpVersion: version, sndKey, rcvKey, serverPubKey}
}
export function receiveBlock(ws: WebSocket): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
ws.onmessage = (e) => {
const data = e.data
if (data instanceof ArrayBuffer) {
resolve(new Uint8Array(data))
} else if (data instanceof Buffer) {
resolve(new Uint8Array(data))
} else {
reject(new Error("Expected binary frame"))
}
}
ws.onerror = (e) => reject(e)
})
}
export function sendBlock(ws: WebSocket, data: Uint8Array): void {
if (data.length !== SMP_BLOCK_SIZE) throw new Error("Block must be " + SMP_BLOCK_SIZE + " bytes")
ws.send(data)
}
// Encrypted block send: pad to (blockSize - 16), encrypt (adds 16-byte tag)
export function sendEncryptedBlock(conn: SMPConnection, plaintext: Uint8Array): void {
if (!conn.sndKey) throw new Error("no block encryption keys")
const {encrypted, nextChainKey} = sbEncryptBlock(conn.sndKey, plaintext, SMP_BLOCK_SIZE - 16)
conn.sndKey = nextChainKey
ws_send(conn.ws, encrypted)
}
// Encrypted block receive: decrypt (removes 16-byte tag + unpad)
export async function receiveEncryptedBlock(conn: SMPConnection): Promise<Uint8Array> {
if (!conn.rcvKey) throw new Error("no block encryption keys")
const block = await receiveBlock(conn.ws)
const {decrypted, nextChainKey} = sbDecryptBlock(conn.rcvKey, block)
conn.rcvKey = nextChainKey
return decrypted
}
function ws_send(ws: WebSocket, data: Uint8Array): void {
if (data.length !== SMP_BLOCK_SIZE) throw new Error("Encrypted block must be " + SMP_BLOCK_SIZE + " bytes")
ws.send(data)
}
+257
View File
@@ -0,0 +1,257 @@
// SMP client REPL for cross-language testing.
// Holds one SMPClient, reads commands from stdin, writes results to stdout.
//
// Commands:
// CONNECT <url> <keyHashHex> [wsOptionsJson]
// NEW <rcvAuthKeyHex> <rcvDhKeyHex> <rcvPrivKeyHex>
// SUB <rcvIdHex> <rcvPrivKeyHex>
// SEND <sndIdHex> <sndPrivKeyHex|none> <notification 0|1> <bodyHex>
// ACK <rcvIdHex> <rcvPrivKeyHex> <msgIdHex>
// KEY <rcvIdHex> <rcvPrivKeyHex> <senderKeyHex>
// SKEY <sndIdHex> <sndPrivKeyHex>
// DEL <rcvIdHex> <rcvPrivKeyHex>
// OFF <rcvIdHex> <rcvPrivKeyHex>
// PING
// RECV [timeoutMs]
// CLOSE
import {createInterface} from "readline"
import {createSMPClient, type SMPClient} from "../dist/client.js"
import type {SMPResponse, AuthKey} from "../dist/protocol.js"
import {generateX25519KeyPair, dh, encodePubKeyX25519, decodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {Decoder, decodeBytes, decodeBool} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import type {ProxiedRelay} from "../dist/client.js"
// -- State
let client: SMPClient | null = null
let proxiedRelay: ProxiedRelay | null = null
// Per-queue DH shared secrets for decrypting received messages (keyed by rcvId hex)
const queueSecrets = new Map<string, Uint8Array>()
const messageQueue: Array<{entityId: Uint8Array, msg: SMPResponse}> = []
let messageWaiter: {resolve: (m: {entityId: Uint8Array, msg: SMPResponse}) => void, timer: ReturnType<typeof setTimeout>} | null = null
// -- Hex helpers
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
}
function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2)
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
return bytes
}
// -- Message delivery
function onMessage(entityId: Uint8Array, msg: SMPResponse): void {
if (messageWaiter) {
const w = messageWaiter
messageWaiter = null
clearTimeout(w.timer)
w.resolve({entityId, msg})
} else {
messageQueue.push({entityId, msg})
}
}
function waitForMessage(timeoutMs: number): Promise<{entityId: Uint8Array, msg: SMPResponse}> {
if (messageQueue.length > 0) {
return Promise.resolve(messageQueue.shift()!)
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
messageWaiter = null
reject(new Error("timeout"))
}, timeoutMs)
messageWaiter = {resolve, timer}
})
}
function makeAuthKey(hexKey: string): AuthKey {
return {type: "x25519", key: fromHex(hexKey)}
}
// -- Command parser
async function parseLine(line: string): Promise<string> {
const parts = line.split(" ")
const cmd = parts[0]
try {
switch (cmd) {
case "CONNECT": {
const url = parts[1]
const keyHash = fromHex(parts[2])
const wsOptions = parts[3] ? JSON.parse(parts[3]) : undefined
client = await createSMPClient(url, keyHash, onMessage, () => {
process.stderr.write("disconnected\n")
}, {wsOptions, timeout: 15000})
return "ok"
}
case "NEW": {
if (!client) return "error: not connected"
const rcvAuthKey = fromHex(parts[1])
const rcvPrivKey = fromHex(parts[2])
// Generate DH keypair for per-queue E2E
const dhKp = generateX25519KeyPair()
const dhPubDer = encodePubKeyX25519(dhKp.publicKey)
const resp = await client.createQueue(
{publicKey: rcvAuthKey, privateKey: rcvPrivKey},
dhPubDer,
true,
)
// Compute and store DH shared secret for decrypting received messages
const srvDhRaw = decodePubKeyX25519(resp.srvDhKey)
const dhShared = dh(srvDhRaw, dhKp.privateKey)
queueSecrets.set(toHex(resp.rcvId), dhShared)
return "ok: " + toHex(resp.rcvId) + " " + toHex(resp.sndId) + " " + toHex(resp.srvDhKey)
}
case "SUB": {
if (!client) return "error: not connected"
await client.subscribeQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
return "ok"
}
case "SEND": {
if (!client) return "error: not connected"
const sndId = fromHex(parts[1])
const privKey: AuthKey | null = parts[2] === "none" ? null : makeAuthKey(parts[2])
const notification = parts[3] === "1"
const body = fromHex(parts[4])
await client.sendMessage(privKey, sndId, notification, body)
return "ok"
}
case "ACK": {
if (!client) return "error: not connected"
await client.ackMessage(makeAuthKey(parts[2]), fromHex(parts[1]), fromHex(parts[3]))
return "ok"
}
case "KEY": {
if (!client) return "error: not connected"
await client.secureQueue(makeAuthKey(parts[2]), fromHex(parts[1]), fromHex(parts[3]))
return "ok"
}
case "SKEY": {
if (!client) return "error: not connected"
await client.secureSndQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
return "ok"
}
case "DEL": {
if (!client) return "error: not connected"
await client.deleteQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
return "ok"
}
case "OFF": {
if (!client) return "error: not connected"
await client.suspendQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
return "ok"
}
case "PING": {
if (!client) return "error: not connected"
// Optional auth key as second arg: PING <privKeyHex>
const pingKey: AuthKey | null = parts[1] ? makeAuthKey(parts[1]) : null
const resp = await client.sendCommand(pingKey, new Uint8Array(0), new TextEncoder().encode("PING"))
return resp.type === "PONG" ? "ok" : "error: unexpected " + resp.type
}
case "RECV": {
if (!client) return "error: not connected"
const timeoutMs = parts[1] ? parseInt(parts[1]) : 5000
const m = await waitForMessage(timeoutMs)
if (m.msg.type === "MSG") {
const {msgId, msgBody} = m.msg.response
// Decrypt per-queue E2E: cbDecrypt(dhShared, cbNonce(msgId), body)
const dhShared = queueSecrets.get(toHex(m.entityId))
if (dhShared) {
// decryptMsgV3: cbDecrypt then parse ClientRcvMsgBody (msgTs + msgFlags + space + Tail msgBody)
const decrypted = cbDecrypt(dhShared, msgId, msgBody)
const dd = new Decoder(decrypted)
dd.take(8) // skip msgTs (SystemTime = Int64 = 8 bytes)
dd.take(1) // skip msgFlags (Bool = 1 byte)
dd.take(1) // skip space (0x20)
const body = dd.takeAll()
return "ok: " + toHex(m.entityId) + " " + toHex(msgId) + " " + toHex(body)
}
// No DH secret (sender queue) — return raw
return "ok: " + toHex(m.entityId) + " " + toHex(msgId) + " " + toHex(msgBody)
}
return "ok: " + toHex(m.entityId) + " " + m.msg.type
}
// BSUB <rcvId1Hex>:<privKey1Hex> <rcvId2Hex>:<privKey2Hex> ...
case "BSUB": {
if (!client) return "error: not connected"
const queues = parts.slice(1).map(p => {
const [rcvIdHex, privKeyHex] = p.split(":")
return {rcvId: fromHex(rcvIdHex), privKey: makeAuthKey(privKeyHex)}
})
await client.subscribeQueues(queues)
return "ok"
}
// PRXY <host1,host2,...> <port> <keyHashHex> [basicAuthHex]
case "PRXY": {
if (!client) return "error: not connected"
const hosts = parts[1].split(",")
const port = parts[2]
const keyHash = fromHex(parts[3])
const auth = parts[4] ? fromHex(parts[4]) : null
proxiedRelay = await client.connectProxiedRelay(hosts, port, keyHash, auth)
return "ok: " + toHex(proxiedRelay.sessionId) + " " + proxiedRelay.version
}
// PSEND <sndIdHex> <sndPrivKeyHex|none> <notification 0|1> <bodyHex>
case "PSEND": {
if (!client || !proxiedRelay) return "error: not connected or no proxy session"
const sndId = fromHex(parts[1])
const privKey: AuthKey | null = parts[2] === "none" ? null : makeAuthKey(parts[2])
const notification = parts[3] === "1"
const body = fromHex(parts[4])
await client.proxySendMessage(proxiedRelay, privKey, sndId, notification, body)
return "ok"
}
case "CLOSE": {
if (client) client.close()
client = null
return "ok"
}
default:
return "error: unknown command: " + cmd
}
} catch (e: any) {
if (e.type) return "error: " + e.type + (e.error ? " " + e.error : "")
return "error: " + (e.message || String(e))
}
}
// -- Main
async function main() {
const rl = createInterface({input: process.stdin, terminal: false})
for await (const line of rl) {
const trimmed = line.trim()
if (!trimmed) continue
const response = await parseLine(trimmed)
process.stdout.write(response + "\n")
}
}
main().catch(e => {
process.stderr.write("FATAL: " + e.message + "\n")
process.exit(1)
})
+326
View File
@@ -0,0 +1,326 @@
// Double ratchet REPL for cross-language testing.
// Holds one ratchet state, reads commands from stdin, writes results to stdout.
//
// Init protocol:
// INIT_RCV <version> <pqSupport 0|1>
// → ok: <hex E2E params>
// COMPLETE <hex peer E2E params>
// → ok
// INIT_SND <version> <kemMode> <hex peer E2E params>
// → ok: <hex E2E params>
// kemMode: none | propose | accept
//
// Encrypt/decrypt operators (same syntax as Haskell DoubleRatchetTests):
// \#> <plaintext> encrypt, assert noSndKEM
// !#> <plaintext> encrypt, assert hasSndKEM
// \#>! <plaintext> encrypt PQEncOn, assert noSndKEM
// !#>! <plaintext> encrypt PQEncOn, assert hasSndKEM
// !#>\ <plaintext> encrypt PQEncOff, assert hasSndKEM
// \#>\ <plaintext> encrypt PQEncOff, assert noSndKEM
// <#\ <hex ct> <expected> decrypt, assert noRcvKEM
// <#! <hex ct> <expected> decrypt, assert hasRcvKEM
//
// Plain encrypt/decrypt (no assertions):
// E <plaintext> → ok: <hex ciphertext>
// D <hex ciphertext> → ok: <plaintext>
//
// Response format: ok: <data> or error: <message>
import {createInterface} from "readline"
import {
generateX448KeyPair, pqX3dhSnd, pqX3dhRcv,
encodePubKeyX448, decodePubKeyX448,
initSndRatchet, initRcvRatchet,
rcEncrypt, rcDecrypt,
rootKdf,
type Ratchet, type SkippedMsgKeys, type RatchetVersions,
type RatchetInitParams, type RatchetKEMAccepted,
} from "../dist/crypto/ratchet.js"
import {initSntrup761, sntrup761Keypair, sntrup761Enc, sntrup761Dec} from "../dist/crypto/sntrup761.js"
import type {KEMKeyPair} from "../dist/crypto/sntrup761.js"
import {
Decoder, decodeBytes, decodeLarge, encodeBytes, encodeWord16, concatBytes,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- State
let ratchet: Ratchet | null = null
let skippedKeys: SkippedMsgKeys = new Map()
const PADDED_MSG_LEN = 16000
// Intermediate state for RCV init (between INIT_RCV and COMPLETE)
let rcvInitState: {
privKey1: Uint8Array
privKey2: Uint8Array
kemKeyPair: KEMKeyPair | null
pqSupport: boolean
} | null = null
// -- Hex helpers
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
}
function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2)
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
return bytes
}
// -- E2E params helpers
// Parse E2ERatchetParams: version(Word16) + pk1(ByteString) + pk2(ByteString) + Maybe KEMParams
interface ParsedE2EParams {
version: number
pk1Raw: Uint8Array // raw X448 public key
pk2Raw: Uint8Array // raw X448 public key
kemPk: Uint8Array | null // KEM public key if proposed
kemCt: Uint8Array | null // KEM ciphertext if accepted
kemAcceptPk: Uint8Array | null // KEM public key in accepted
}
function parseE2EParams(data: Uint8Array): ParsedE2EParams {
const d = new Decoder(data)
const version = d.anyByte() * 256 + d.anyByte()
const pk1Raw = decodePubKeyX448(decodeBytes(d))
const pk2Raw = decodePubKeyX448(decodeBytes(d))
let kemPk: Uint8Array | null = null
let kemCt: Uint8Array | null = null
let kemAcceptPk: Uint8Array | null = null
if (version >= 3 && d.remaining() > 0) {
const maybeByte = d.anyByte()
if (maybeByte === 0x31) { // Just
const tag = d.anyByte()
if (tag === 0x50) { // 'P' Proposed
kemPk = decodeLarge(d)
} else if (tag === 0x41) { // 'A' Accepted
kemCt = decodeLarge(d)
kemAcceptPk = decodeLarge(d)
}
}
}
return {version, pk1Raw, pk2Raw, kemPk, kemCt, kemAcceptPk}
}
// Encode E2ERatchetParams for sending to peer
function encodeE2EParams(
version: number,
pk1Raw: Uint8Array, pk2Raw: Uint8Array,
kemPk: Uint8Array | null, // for proposed
kemCt: Uint8Array | null, // for accepted
kemAcceptPk: Uint8Array | null, // public key in accepted
): Uint8Array {
const vBytes = new Uint8Array(2)
vBytes[0] = (version >> 8) & 0xff
vBytes[1] = version & 0xff
const parts = [vBytes, encodeBytes(encodePubKeyX448(pk1Raw)), encodeBytes(encodePubKeyX448(pk2Raw))]
if (version >= 3) {
if (kemCt && kemAcceptPk) {
// Just Accepted
parts.push(new Uint8Array([0x31, 0x41])) // Just + 'A'
parts.push(new Uint8Array([(kemCt.length >> 8) & 0xff, kemCt.length & 0xff]))
parts.push(kemCt)
parts.push(new Uint8Array([(kemAcceptPk.length >> 8) & 0xff, kemAcceptPk.length & 0xff]))
parts.push(kemAcceptPk)
} else if (kemPk) {
// Just Proposed
parts.push(new Uint8Array([0x31, 0x50])) // Just + 'P'
parts.push(new Uint8Array([(kemPk.length >> 8) & 0xff, kemPk.length & 0xff]))
parts.push(kemPk)
} else {
// Nothing
parts.push(new Uint8Array([0x30]))
}
}
return concatBytes(...parts)
}
// -- Init handlers
function handleInitRcv(version: number, pqSupport: boolean): string {
const kp1 = generateX448KeyPair()
const kp2 = generateX448KeyPair()
let kemKeyPair: KEMKeyPair | null = null
let kemPk: Uint8Array | null = null
if (pqSupport) {
kemKeyPair = sntrup761Keypair()
kemPk = kemKeyPair.publicKey
}
rcvInitState = {privKey1: kp1.privateKey, privKey2: kp2.privateKey, kemKeyPair, pqSupport}
const params = encodeE2EParams(version, kp1.publicKey, kp2.publicKey, kemPk, null, null)
return "ok: " + toHex(params)
}
function handleComplete(peerParamsHex: string): string {
if (!rcvInitState) return "error: not in RCV init state"
const {privKey1, privKey2, kemKeyPair, pqSupport} = rcvInitState
const peerParams = parseE2EParams(fromHex(peerParamsHex))
// Build kemAccepted for X3DH if peer accepted our KEM proposal
let kemAccepted: RatchetKEMAccepted | null = null
if (peerParams.kemCt && peerParams.kemAcceptPk && kemKeyPair) {
const ss = sntrup761Dec(peerParams.kemCt, kemKeyPair.secretKey)
kemAccepted = {rcPQRr: peerParams.kemAcceptPk, rcPQRss: ss, rcPQRct: peerParams.kemCt}
}
// X3DH (receiver side)
const initParams = pqX3dhRcv(privKey1, privKey2, peerParams.pk1Raw, peerParams.pk2Raw, kemAccepted)
// Init receiving ratchet
const vs: RatchetVersions = {current: peerParams.version, maxSupported: peerParams.version}
ratchet = initRcvRatchet(vs, privKey2, initParams, kemKeyPair, pqSupport)
skippedKeys = new Map()
rcvInitState = null
return "ok"
}
function handleInitSnd(version: number, kemMode: string, peerParamsHex: string): string {
const peerParams = parseE2EParams(fromHex(peerParamsHex))
const kp1 = generateX448KeyPair()
const kp2 = generateX448KeyPair()
const kp3 = generateX448KeyPair() // fresh DH key for ratchet
// KEM handling
let kemAccepted: RatchetKEMAccepted | null = null
let ownKemKp: KEMKeyPair | null = null
let outKemPk: Uint8Array | null = null
let outKemCt: Uint8Array | null = null
let outKemAcceptPk: Uint8Array | null = null
if (kemMode === "accept" && peerParams.kemPk) {
// Accept peer's KEM proposal
const encResult = sntrup761Enc(peerParams.kemPk)
ownKemKp = sntrup761Keypair()
kemAccepted = {rcPQRr: peerParams.kemPk, rcPQRss: encResult.sharedSecret, rcPQRct: encResult.ciphertext}
outKemCt = encResult.ciphertext
outKemAcceptPk = ownKemKp.publicKey
} else if (kemMode === "propose") {
ownKemKp = sntrup761Keypair()
outKemPk = ownKemKp.publicKey
}
// X3DH (sender side)
const initParams = pqX3dhSnd(kp1.privateKey, kp2.privateKey, peerParams.pk1Raw, peerParams.pk2Raw, kemAccepted)
// Init sending ratchet
const vs: RatchetVersions = {current: version, maxSupported: version}
ratchet = initSndRatchet(vs, peerParams.pk2Raw, kp3.privateKey, initParams, ownKemKp)
skippedKeys = new Map()
const params = encodeE2EParams(version, kp1.publicKey, kp2.publicKey, outKemPk, outKemCt, outKemAcceptPk)
return "ok: " + toHex(params)
}
// -- Encrypt/decrypt handlers
function handleEncrypt(kemAssert: boolean | null, _pqPref: boolean | null, plaintext: string): string {
if (!ratchet) return "error: not initialized"
try {
const result = rcEncrypt(ratchet, new TextEncoder().encode(plaintext), PADDED_MSG_LEN)
ratchet = result.state
if (kemAssert === true && !ratchet.rcSndKEM) return "error: expected hasSndKEM"
if (kemAssert === false && ratchet.rcSndKEM) return "error: expected noSndKEM"
return "ok: " + toHex(result.ciphertext)
} catch (e: any) {
return "error: " + e.message
}
}
function handleDecrypt(kemAssert: boolean | null, hexCt: string, expectedPlaintext: string | null): string {
if (!ratchet) return "error: not initialized"
try {
const ct = fromHex(hexCt)
const result = rcDecrypt(ratchet, skippedKeys, ct)
ratchet = result.state
skippedKeys = result.skippedKeys
const plaintext = new TextDecoder().decode(result.plaintext)
if (kemAssert === true && !ratchet.rcRcvKEM) return "error: expected hasRcvKEM"
if (kemAssert === false && ratchet.rcRcvKEM) return "error: expected noRcvKEM"
if (expectedPlaintext !== null && plaintext !== expectedPlaintext)
return "error: expected '" + expectedPlaintext + "', got '" + plaintext + "'"
return "ok: " + plaintext
} catch (e: any) {
return "error: " + e.message
}
}
// -- Command parser
function parseLine(line: string): string {
// Init commands
if (line.startsWith("INIT_RCV ")) {
const parts = line.split(" ")
return handleInitRcv(parseInt(parts[1]), parts[2] === "1")
}
if (line.startsWith("COMPLETE ")) {
return handleComplete(line.substring(9).trim())
}
if (line.startsWith("INIT_SND ")) {
const parts = line.split(" ")
return handleInitSnd(parseInt(parts[1]), parts[2], parts[3])
}
// Query commands
if (line === "SNDKEM") {
if (!ratchet) return "error: not initialized"
return "ok: " + (ratchet.rcSndKEM ? "1" : "0")
}
if (line === "RCVKEM") {
if (!ratchet) return "error: not initialized"
return "ok: " + (ratchet.rcRcvKEM ? "1" : "0")
}
// Encrypt operators: \#> !#> \#>! !#>! !#>\ \#>\
const encMatch = line.match(/^([!\\])#(>[!\\]?)\s+(.+)$/)
if (encMatch) {
const [, kemChar, arrow, msg] = encMatch
const kemAssert = kemChar === "!" ? true : false
let pqPref: boolean | null = null
if (arrow === ">!") pqPref = true
else if (arrow === ">\\") pqPref = false
return handleEncrypt(kemAssert, pqPref, msg)
}
// Decrypt operators: <#\ <#!
const decMatch = line.match(/^<#([!\\])\s+(\S+)\s+(.+)$/)
if (decMatch) {
const [, kemChar, hexCt, expected] = decMatch
const kemAssert = kemChar === "!" ? true : false
return handleDecrypt(kemAssert, hexCt, expected)
}
// Plain encrypt (no assertion)
if (line.startsWith("E ")) {
return handleEncrypt(null, null, line.substring(2))
}
// Plain decrypt (no assertion, no expected)
if (line.startsWith("D ")) {
return handleDecrypt(null, line.substring(2), null)
}
return "error: unknown command: " + line
}
// -- Main
async function main() {
await initSntrup761()
const rl = createInterface({input: process.stdin, terminal: false})
for await (const line of rl) {
const trimmed = line.trim()
if (!trimmed) continue
const response = parseLine(trimmed)
process.stdout.write(response + "\n")
}
}
main().catch(e => {
process.stderr.write("FATAL: " + e.message + "\n")
process.exit(1)
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "dist-test",
"rootDir": "tests",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true
},
"include": ["tests/**/*.ts"]
}
+101 -88
View File
@@ -31,7 +31,6 @@ import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
import qualified Data.Text as T
import qualified Data.Text.IO as T
@@ -88,7 +87,7 @@ import UnliftIO.Concurrent (threadDelay)
import UnliftIO.Directory (canonicalizePath, doesFileExist, removeFile, renameFile)
import qualified UnliftIO.Exception as E
type M a = ReaderT XFTPEnv IO a
type M s a = ReaderT (XFTPEnv s) IO a
data XFTPTransportRequest = XFTPTransportRequest
{ thParams :: THandleParamsXFTP 'TServer,
@@ -112,19 +111,19 @@ corsPreflightHeaders =
("Access-Control-Max-Age", "86400")
]
runXFTPServer :: XFTPServerConfig -> IO ()
runXFTPServer :: FileStoreClass s => XFTPServerConfig s -> IO ()
runXFTPServer cfg = do
started <- newEmptyTMVarIO
runXFTPServerBlocking started cfg
runXFTPServerBlocking :: TMVar Bool -> XFTPServerConfig -> IO ()
runXFTPServerBlocking :: FileStoreClass s => TMVar Bool -> XFTPServerConfig s -> IO ()
runXFTPServerBlocking started cfg = newXFTPServerEnv cfg >>= runReaderT (xftpServer cfg started)
data Handshake
= HandshakeSent C.PrivateKeyX25519
| HandshakeAccepted (THandleParams XFTPVersion 'TServer)
xftpServer :: XFTPServerConfig -> TMVar Bool -> M ()
xftpServer :: forall s. FileStoreClass s => XFTPServerConfig s -> TMVar Bool -> M s ()
xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration, xftpServerVRange} started = do
mapM_ (expireServerFiles Nothing) fileExpiration
restoreServerStats
@@ -137,7 +136,7 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
)
`finally` stopServer
where
runServer :: M ()
runServer :: M s ()
runServer = do
srvCreds@(chain, pk) <- asks tlsServerCreds
httpCreds_ <- asks httpServerCreds
@@ -168,7 +167,7 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
Nothing -> pure ()
Just thParams -> processRequest req0 {thParams}
| otherwise -> liftIO . sendResponse $ H.responseNoBody N.ok200 (corsHeaders addCORS')
xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion 'TServer))
xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M s (Maybe (THandleParams XFTPVersion 'TServer))
xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, request, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
s <- atomically $ TM.lookup sessionId sessions
r <- runExceptT $ case s of
@@ -227,39 +226,40 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
liftIO . sendResponse $ H.responseNoBody N.ok200 (corsHeaders addCORS)
pure Nothing
Nothing -> throwE HANDSHAKE
sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion 'TServer))
sendError :: XFTPErrorType -> M s (Maybe (THandleParams XFTPVersion 'TServer))
sendError err = do
runExceptT (encodeXftp err) >>= \case
Right bs -> liftIO . sendResponse $ H.responseBuilder N.ok200 (corsHeaders addCORS) bs
Left _ -> logError $ "Error encoding handshake error: " <> tshow err
pure Nothing
encodeXftp :: Encoding a => a -> ExceptT XFTPErrorType (ReaderT XFTPEnv IO) Builder
encodeXftp :: Encoding a => a -> ExceptT XFTPErrorType (ReaderT (XFTPEnv s) IO) Builder
encodeXftp a = byteString <$> liftHS (C.pad (smpEncode a) xftpBlockSize)
liftHS = liftEitherWith (const HANDSHAKE)
stopServer :: M ()
stopServer :: M s ()
stopServer = do
withFileLog closeStoreLog
st <- asks fileStore
liftIO $ closeFileStore st
saveServerStats
logNote "Server stopped"
expireFilesThread_ :: XFTPServerConfig -> [M ()]
expireFilesThread_ :: XFTPServerConfig s -> [M s ()]
expireFilesThread_ XFTPServerConfig {fileExpiration = Just fileExp} = [expireFiles fileExp]
expireFilesThread_ _ = []
expireFiles :: ExpirationConfig -> M ()
expireFiles :: ExpirationConfig -> M s ()
expireFiles expCfg = do
let interval = checkInterval expCfg * 1000000
forever $ do
liftIO $ threadDelay' interval
expireServerFiles (Just 100000) expCfg
serverStatsThread_ :: XFTPServerConfig -> [M ()]
serverStatsThread_ :: XFTPServerConfig s -> [M s ()]
serverStatsThread_ XFTPServerConfig {logStatsInterval = Just interval, logStatsStartTime, serverStatsLogFile} =
[logServerStats logStatsStartTime interval serverStatsLogFile]
serverStatsThread_ _ = []
logServerStats :: Int64 -> Int64 -> FilePath -> M ()
logServerStats :: Int64 -> Int64 -> FilePath -> M s ()
logServerStats startAt logInterval statsFilePath = do
initialDelay <- (startAt -) . fromIntegral . (`div` 1000000_000000) . diffTimeToPicoseconds . utctDayTime <$> liftIO getCurrentTime
liftIO $ putStrLn $ "server stats log enabled: " <> statsFilePath
@@ -300,12 +300,12 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
]
liftIO $ threadDelay' interval
prometheusMetricsThread_ :: XFTPServerConfig -> [M ()]
prometheusMetricsThread_ :: XFTPServerConfig s -> [M s ()]
prometheusMetricsThread_ XFTPServerConfig {prometheusInterval = Just interval, prometheusMetricsFile} =
[savePrometheusMetrics interval prometheusMetricsFile]
prometheusMetricsThread_ _ = []
savePrometheusMetrics :: Int -> FilePath -> M ()
savePrometheusMetrics :: Int -> FilePath -> M s ()
savePrometheusMetrics saveInterval metricsFile = do
labelMyThread "savePrometheusMetrics"
liftIO $ putStrLn $ "Prometheus metrics saved every " <> show saveInterval <> " seconds to " <> metricsFile
@@ -324,11 +324,11 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
let fd = periodStatDataCounts $ _filesDownloaded d
pure FileServerMetrics {statsData = d, filesDownloadedPeriods = fd, rtsOptions}
controlPortThread_ :: XFTPServerConfig -> [M ()]
controlPortThread_ :: XFTPServerConfig s -> [M s ()]
controlPortThread_ XFTPServerConfig {controlPort = Just port} = [runCPServer port]
controlPortThread_ _ = []
runCPServer :: ServiceName -> M ()
runCPServer :: ServiceName -> M s ()
runCPServer port = do
cpStarted <- newEmptyTMVarIO
u <- askUnliftIO
@@ -336,7 +336,7 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
labelMyThread "control port server"
runLocalTCPServer cpStarted port $ runCPClient u
where
runCPClient :: UnliftIO (ReaderT XFTPEnv IO) -> Socket -> IO ()
runCPClient :: UnliftIO (ReaderT (XFTPEnv s) IO) -> Socket -> IO ()
runCPClient u sock = do
labelMyThread "control port client"
h <- socketToHandle sock ReadWriteMode
@@ -366,15 +366,15 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
XFTPServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} = cfg
CPStatsRTS -> E.tryAny getRTSStats >>= either (hPrint h) (hPrint h)
CPDelete fileId -> withUserRole $ unliftIO u $ do
fs <- asks store
fs <- asks fileStore
r <- runExceptT $ do
(fr, _) <- ExceptT $ atomically $ getFile fs SFRecipient fileId
(fr, _) <- ExceptT $ liftIO $ getFile fs SFRecipient fileId
ExceptT $ deleteServerFile_ fr
liftIO . hPutStrLn h $ either (\e -> "error: " <> show e) (\() -> "ok") r
CPBlock fileId info -> withUserRole $ unliftIO u $ do
fs <- asks store
fs <- asks fileStore
r <- runExceptT $ do
(fr, _) <- ExceptT $ atomically $ getFile fs SFRecipient fileId
(fr, _) <- ExceptT $ liftIO $ getFile fs SFRecipient fileId
ExceptT $ blockServerFile fr info
liftIO . hPutStrLn h $ either (\e -> "error: " <> show e) (\() -> "ok") r
CPHelp -> hPutStrLn h "commands: stats-rts, delete, help, quit"
@@ -395,7 +395,7 @@ data ServerFile = ServerFile
sbState :: LC.SbState
}
processRequest :: XFTPTransportRequest -> M ()
processRequest :: FileStoreClass s => XFTPTransportRequest -> M s ()
processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHead}, sendResponse, addCORS}
| B.length bodyHead /= xftpBlockSize = sendXFTPResponse ("", NoEntity, FRErr BLOCK) Nothing
| otherwise =
@@ -430,7 +430,7 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea
done
#ifdef slow_servers
randomDelay :: M ()
randomDelay :: M s ()
randomDelay = do
d <- asks $ responseDelay . config
when (d > 0) $ do
@@ -440,20 +440,20 @@ randomDelay = do
data VerificationResult = VRVerified XFTPRequest | VRFailed XFTPErrorType
verifyXFTPTransmission :: Maybe (THandleAuth 'TServer) -> SignedTransmission FileCmd -> M VerificationResult
verifyXFTPTransmission :: forall s. FileStoreClass s => Maybe (THandleAuth 'TServer) -> SignedTransmission FileCmd -> M s VerificationResult
verifyXFTPTransmission thAuth (tAuth, authorized, (corrId, fId, cmd)) =
case cmd of
FileCmd SFSender (FNEW file rcps auth') -> pure $ XFTPReqNew file rcps auth' `verifyWith` sndKey file
FileCmd SFRecipient PING -> pure $ VRVerified XFTPReqPing
FileCmd party _ -> verifyCmd party
where
verifyCmd :: SFileParty p -> M VerificationResult
verifyCmd :: SFileParty p -> M s VerificationResult
verifyCmd party = do
st <- asks store
atomically $ verify =<< getFile st party fId
st <- asks fileStore
liftIO $ verify =<< getFile st party fId
where
verify = \case
Right (fr, k) -> result <$> readTVar (fileStatus fr)
Right (fr, k) -> result <$> readTVarIO (fileStatus fr)
where
result = \case
EntityActive -> XFTPReqCmd fId fr cmd `verifyWith` k
@@ -464,7 +464,7 @@ verifyXFTPTransmission thAuth (tAuth, authorized, (corrId, fId, cmd)) =
-- TODO verify with DH authorization
req `verifyWith` k = if verifyCmdAuthorization thAuth tAuth authorized corrId k then VRVerified req else VRFailed AUTH
processXFTPRequest :: HTTP2Body -> XFTPRequest -> M (FileResponse, Maybe ServerFile)
processXFTPRequest :: forall s. FileStoreClass s => HTTP2Body -> XFTPRequest -> M s (FileResponse, Maybe ServerFile)
processXFTPRequest HTTP2Body {bodyPart} = \case
XFTPReqNew file rks auth -> noFile =<< ifM allowNew (createFile file rks) (pure $ FRErr AUTH)
where
@@ -483,9 +483,9 @@ processXFTPRequest HTTP2Body {bodyPart} = \case
XFTPReqPing -> noFile FRPong
where
noFile resp = pure (resp, Nothing)
createFile :: FileInfo -> NonEmpty RcvPublicAuthKey -> M FileResponse
createFile :: FileInfo -> NonEmpty RcvPublicAuthKey -> M s FileResponse
createFile file rks = do
st <- asks store
st <- asks fileStore
r <- runExceptT $ do
sizes <- asks $ allowedChunkSizes . config
unless (size file `elem` sizes) $ throwE SIZE
@@ -502,27 +502,27 @@ processXFTPRequest HTTP2Body {bodyPart} = \case
let rIds = L.map (\(FileRecipient rId _) -> rId) rcps
pure $ FRSndIds sId rIds
pure $ either FRErr id r
addFileRetry :: FileStore -> FileInfo -> Int -> RoundedFileTime -> M (Either XFTPErrorType XFTPFileId)
addFileRetry :: s -> FileInfo -> Int -> RoundedFileTime -> M s (Either XFTPErrorType XFTPFileId)
addFileRetry st file n ts =
retryAdd n $ \sId -> runExceptT $ do
ExceptT $ addFile st sId file ts EntityActive
pure sId
addRecipientRetry :: FileStore -> Int -> XFTPFileId -> RcvPublicAuthKey -> M (Either XFTPErrorType FileRecipient)
addRecipientRetry :: s -> Int -> XFTPFileId -> RcvPublicAuthKey -> M s (Either XFTPErrorType FileRecipient)
addRecipientRetry st n sId rpk =
retryAdd n $ \rId -> runExceptT $ do
let rcp = FileRecipient rId rpk
ExceptT $ addRecipient st sId rcp
pure rcp
retryAdd :: Int -> (XFTPFileId -> STM (Either XFTPErrorType a)) -> M (Either XFTPErrorType a)
retryAdd :: Int -> (XFTPFileId -> IO (Either XFTPErrorType a)) -> M s (Either XFTPErrorType a)
retryAdd 0 _ = pure $ Left INTERNAL
retryAdd n add = do
fId <- getFileId
atomically (add fId) >>= \case
liftIO (add fId) >>= \case
Left DUPLICATE_ -> retryAdd (n - 1) add
r -> pure r
addRecipients :: XFTPFileId -> NonEmpty RcvPublicAuthKey -> M FileResponse
addRecipients :: XFTPFileId -> NonEmpty RcvPublicAuthKey -> M s FileResponse
addRecipients sId rks = do
st <- asks store
st <- asks fileStore
r <- runExceptT $ do
rcps <- mapM (ExceptT . addRecipientRetry st 3 sId) rks
lift $ withFileLog $ \sl -> logAddRecipients sl sId rcps
@@ -531,7 +531,7 @@ processXFTPRequest HTTP2Body {bodyPart} = \case
let rIds = L.map (\(FileRecipient rId _) -> rId) rcps
pure $ FRRcvIds rIds
pure $ either FRErr id r
receiveServerFile :: FileRec -> M FileResponse
receiveServerFile :: FileRec -> M s FileResponse
receiveServerFile FileRec {senderId, fileInfo = FileInfo {size, digest}, filePath} = case bodyPart of
Nothing -> pure $ FRErr SIZE
-- TODO validate body size from request before downloading, once it's populated
@@ -549,7 +549,7 @@ processXFTPRequest HTTP2Body {bodyPart} = \case
| bs == 0 || bs > s -> pure $ FRErr SIZE
| otherwise -> drain (s - bs)
reserve = do
us <- asks $ usedStorage . store
us <- asks usedStorage
quota <- asks $ fromMaybe maxBound . fileSizeQuota . config
atomically . stateTVar us $
\used -> let used' = used + fromIntegral size in if used' <= quota then (True, used') else (False, used)
@@ -559,21 +559,28 @@ processXFTPRequest HTTP2Body {bodyPart} = \case
receiveChunk (XFTPRcvChunkSpec fPath size digest) >>= \case
Right () -> do
stats <- asks serverStats
withFileLog $ \sl -> logPutFile sl senderId fPath
atomically $ writeTVar filePath (Just fPath)
incFileStat filesUploaded
incFileStat filesCount
liftIO $ atomicModifyIORef'_ (filesSize stats) (+ fromIntegral size)
pure FROk
st <- asks fileStore
liftIO (setFilePath st senderId fPath) >>= \case
Right () -> do
withFileLog $ \sl -> logPutFile sl senderId fPath
incFileStat filesUploaded
incFileStat filesCount
liftIO $ atomicModifyIORef'_ (filesSize stats) (+ fromIntegral size)
pure FROk
Left _e -> do
us <- asks usedStorage
atomically $ modifyTVar' us $ subtract (fromIntegral size)
liftIO $ whenM (doesFileExist fPath) (removeFile fPath) `catch` logFileError
pure $ FRErr AUTH
Left e -> do
us <- asks $ usedStorage . store
us <- asks usedStorage
atomically $ modifyTVar' us $ subtract (fromIntegral size)
liftIO $ whenM (doesFileExist fPath) (removeFile fPath) `catch` logFileError
pure $ FRErr e
receiveChunk spec = do
t <- asks $ fileTimeout . config
liftIO $ fromMaybe (Left TIMEOUT) <$> timeout t (runExceptT $ receiveFile getBody spec)
sendServerFile :: FileRec -> RcvPublicDhKey -> M (FileResponse, Maybe ServerFile)
sendServerFile :: FileRec -> RcvPublicDhKey -> M s (FileResponse, Maybe ServerFile)
sendServerFile FileRec {senderId, filePath, fileInfo = FileInfo {size}} rDhKey = do
readTVarIO filePath >>= \case
Just path -> ifM (doesFileExist path) sendFile (pure (FRErr AUTH, Nothing))
@@ -592,38 +599,41 @@ processXFTPRequest HTTP2Body {bodyPart} = \case
_ -> pure (FRErr INTERNAL, Nothing)
_ -> pure (FRErr NO_FILE, Nothing)
deleteServerFile :: FileRec -> M FileResponse
deleteServerFile :: FileRec -> M s FileResponse
deleteServerFile fr = either FRErr (\() -> FROk) <$> deleteServerFile_ fr
logFileError :: SomeException -> IO ()
logFileError e = logError $ "Error deleting file: " <> tshow e
ackFileReception :: RecipientId -> FileRec -> M FileResponse
ackFileReception :: RecipientId -> FileRec -> M s FileResponse
ackFileReception rId fr = do
withFileLog (`logAckFile` rId)
st <- asks store
atomically $ deleteRecipient st rId fr
st <- asks fileStore
liftIO $ deleteRecipient st rId fr
incFileStat fileDownloadAcks
pure FROk
deleteServerFile_ :: FileRec -> M (Either XFTPErrorType ())
deleteServerFile_ :: FileStoreClass s => FileRec -> M s (Either XFTPErrorType ())
deleteServerFile_ fr@FileRec {senderId} = do
withFileLog (`logDeleteFile` senderId)
deleteOrBlockServerFile_ fr filesDeleted (`deleteFile` senderId)
-- this also deletes the file from storage, but doesn't include it in delete statistics
blockServerFile :: FileRec -> BlockingInfo -> M (Either XFTPErrorType ())
blockServerFile :: FileStoreClass s => FileRec -> BlockingInfo -> M s (Either XFTPErrorType ())
blockServerFile fr@FileRec {senderId} info = do
withFileLog $ \sl -> logBlockFile sl senderId info
deleteOrBlockServerFile_ fr filesBlocked $ \st -> blockFile st senderId info True
deleteOrBlockServerFile_ :: FileRec -> (FileServerStats -> IORef Int) -> (FileStore -> STM (Either XFTPErrorType ())) -> M (Either XFTPErrorType ())
deleteOrBlockServerFile_ :: FileStoreClass s => FileRec -> (FileServerStats -> IORef Int) -> (s -> IO (Either XFTPErrorType ())) -> M s (Either XFTPErrorType ())
deleteOrBlockServerFile_ FileRec {filePath, fileInfo} stat storeAction = runExceptT $ do
path <- readTVarIO filePath
stats <- asks serverStats
ExceptT $ first (\(_ :: SomeException) -> FILE_IO) <$> try (forM_ path $ \p -> whenM (doesFileExist p) (removeFile p >> deletedStats stats))
st <- asks store
void $ atomically $ storeAction st
st <- asks fileStore
ExceptT $ liftIO $ storeAction st
forM_ path $ \_ -> do
us <- asks usedStorage
atomically $ modifyTVar' us $ subtract (fromIntegral $ size fileInfo)
lift $ incFileStat stat
where
deletedStats stats = do
@@ -633,47 +643,50 @@ deleteOrBlockServerFile_ FileRec {filePath, fileInfo} stat storeAction = runExce
getFileTime :: IO RoundedFileTime
getFileTime = getRoundedSystemTime
expireServerFiles :: Maybe Int -> ExpirationConfig -> M ()
expireServerFiles :: FileStoreClass s => Maybe Int -> ExpirationConfig -> M s ()
expireServerFiles itemDelay expCfg = do
st <- asks store
usedStart <- readTVarIO $ usedStorage st
st <- asks fileStore
us <- asks usedStorage
usedStart <- readTVarIO us
old <- liftIO $ expireBeforeEpoch expCfg
files' <- readTVarIO (files st)
logNote $ "Expiration check: " <> tshow (M.size files') <> " files"
forM_ (M.keys files') $ \sId -> do
mapM_ threadDelay itemDelay
atomically (expiredFilePath st sId old)
>>= mapM_ (maybeRemove $ delete st sId)
usedEnd <- readTVarIO $ usedStorage st
filesCount <- liftIO $ getFileCount st
logNote $ "Expiration check: " <> tshow filesCount <> " files"
expireLoop st us old
usedEnd <- readTVarIO us
logNote $ "Used " <> mbs usedStart <> " -> " <> mbs usedEnd <> ", " <> mbs (usedStart - usedEnd) <> " reclaimed."
where
mbs bs = tshow (bs `div` 1048576) <> "mb"
maybeRemove del = maybe del (remove del)
remove del filePath =
ifM
(doesFileExist filePath)
((removeFile filePath >> del) `catch` \(e :: SomeException) -> logError $ "failed to remove expired file " <> tshow filePath <> ": " <> tshow e)
del
delete st sId = do
withFileLog (`logDeleteFile` sId)
void . atomically $ deleteFile st sId -- will not update usedStorage if sId isn't in store
incFileStat filesExpired
expireLoop st us old = do
expired <- liftIO $ expiredFiles st old 10000
forM_ expired $ \(sId, filePath_, fileSize) -> do
mapM_ threadDelay itemDelay
forM_ filePath_ $ \fp ->
whenM (doesFileExist fp) $
removeFile fp `catch` \(e :: SomeException) -> logError $ "failed to remove expired file " <> tshow fp <> ": " <> tshow e
forM_ filePath_ $ \_ ->
atomically $ modifyTVar' us $ subtract (fromIntegral fileSize)
incFileStat filesExpired
let sIds = map (\(sId, _, _) -> sId) expired
unless (null sIds) $ do
withFileLog $ \sl -> mapM_ (logDeleteFile sl) sIds
liftIO $ deleteFiles st sIds
expireLoop st us old
randomId :: Int -> M ByteString
randomId :: Int -> M s ByteString
randomId n = atomically . C.randomBytes n =<< asks random
getFileId :: M XFTPFileId
getFileId :: M s XFTPFileId
getFileId = fmap EntityId . randomId =<< asks (fileIdSize . config)
withFileLog :: (StoreLog 'WriteMode -> IO a) -> M ()
withFileLog :: (StoreLog 'WriteMode -> IO a) -> M s ()
withFileLog action = liftIO . mapM_ action =<< asks storeLog
incFileStat :: (FileServerStats -> IORef Int) -> M ()
incFileStat :: (FileServerStats -> IORef Int) -> M s ()
incFileStat statSel = do
stats <- asks serverStats
liftIO $ atomicModifyIORef'_ (statSel stats) (+ 1)
saveServerStats :: M ()
saveServerStats :: M s ()
saveServerStats =
asks (serverStatsBackupFile . config)
>>= mapM_ (\f -> asks serverStats >>= liftIO . getFileServerStatsData >>= liftIO . saveStats f)
@@ -683,7 +696,7 @@ saveServerStats =
B.writeFile f $ strEncode stats
logNote "server stats saved"
restoreServerStats :: M ()
restoreServerStats :: FileStoreClass s => M s ()
restoreServerStats = asks (serverStatsBackupFile . config) >>= mapM_ restoreStats
where
restoreStats f = whenM (doesFileExist f) $ do
@@ -691,9 +704,9 @@ restoreServerStats = asks (serverStatsBackupFile . config) >>= mapM_ restoreStat
liftIO (strDecode <$> B.readFile f) >>= \case
Right d@FileServerStatsData {_filesCount = statsFilesCount, _filesSize = statsFilesSize} -> do
s <- asks serverStats
FileStore {files, usedStorage} <- asks store
_filesCount <- M.size <$> readTVarIO files
_filesSize <- readTVarIO usedStorage
st <- asks fileStore
_filesCount <- liftIO $ getFileCount st
_filesSize <- readTVarIO =<< asks usedStorage
liftIO $ setFileServerStats s d {_filesCount, _filesSize}
renameFile f $ f <> ".bak"
logNote "server stats restored"
+150 -16
View File
@@ -1,21 +1,35 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeFamilies #-}
module Simplex.FileTransfer.Server.Env
( XFTPServerConfig (..),
XFTPStoreConfig (..),
XFTPEnv (..),
XFTPRequest (..),
XFTPStoreType,
FileStore (..),
AFStoreType (..),
fileStore,
fromFileStore,
defaultInactiveClientExpiration,
defFileExpirationHours,
defaultFileExpiration,
newXFTPServerEnv,
countUsedStorage,
readFileStoreType,
runWithStoreConfig,
checkFileStoreMode,
importToDatabase,
exportFromDatabase,
) where
import Control.Logger.Simple
@@ -23,7 +37,6 @@ import Control.Monad
import Crypto.Random
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.Map.Strict as M
import Data.Time.Clock (getCurrentTime)
import Data.Word (Word32)
import Data.X509.Validation (Fingerprint (..))
@@ -31,7 +44,21 @@ import Network.Socket
import qualified Network.TLS as T
import Simplex.FileTransfer.Protocol (FileCmd, FileInfo (..), XFTPFileId)
import Simplex.FileTransfer.Server.Stats
import Data.Either (fromRight)
import Data.Ini (Ini, lookupValue)
import qualified Data.Text as T
import Simplex.FileTransfer.Server.Store
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation)
import Data.Functor (($>))
import Simplex.Messaging.Server.CLI (settingIsOn)
import System.Exit (exitFailure)
#if defined(dbServerPostgres)
import Data.Maybe (isNothing)
import Simplex.FileTransfer.Server.Store.Postgres (PostgresFileStore, importFileStore, exportFileStore)
import Simplex.FileTransfer.Server.Store.Postgres.Config (PostgresFileStoreCfg (..), defaultXFTPDBOpts)
import Simplex.Messaging.Server.CLI (iniDBOptions)
import System.Directory (doesFileExist)
#endif
import Simplex.FileTransfer.Server.StoreLog
import Simplex.FileTransfer.Transport (VersionRangeXFTP)
import qualified Simplex.Messaging.Crypto as C
@@ -42,10 +69,11 @@ import Simplex.Messaging.Util (tshow)
import System.IO (IOMode (..))
import UnliftIO.STM
data XFTPServerConfig = XFTPServerConfig
data XFTPServerConfig s = XFTPServerConfig
{ xftpPort :: ServiceName,
controlPort :: Maybe ServiceName,
fileIdSize :: Int,
serverStoreCfg :: XFTPStoreConfig s,
storeLogFile :: Maybe FilePath,
filesPath :: FilePath,
-- | server storage quota
@@ -88,9 +116,10 @@ defaultInactiveClientExpiration =
checkInterval = 3600 -- seconds, 1 hours
}
data XFTPEnv = XFTPEnv
{ config :: XFTPServerConfig,
store :: FileStore,
data XFTPEnv s = XFTPEnv
{ config :: XFTPServerConfig s,
store :: FileStore s,
usedStorage :: TVar Int64,
storeLog :: Maybe (StoreLog 'WriteMode),
random :: TVar ChaChaDRG,
serverIdentity :: C.KeyHash,
@@ -99,6 +128,38 @@ data XFTPEnv = XFTPEnv
serverStats :: FileServerStats
}
fileStore :: XFTPEnv s -> s
fileStore = fromFileStore . store
{-# INLINE fileStore #-}
data XFTPStoreConfig s where
XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore
#if defined(dbServerPostgres)
XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore
#endif
type family XFTPStoreType (fs :: FSType) where
XFTPStoreType 'FSMemory = STMFileStore
#if defined(dbServerPostgres)
XFTPStoreType 'FSPostgres = PostgresFileStore
#endif
data FileStore s where
StoreMemory :: STMFileStore -> FileStore STMFileStore
#if defined(dbServerPostgres)
StoreDatabase :: PostgresFileStore -> FileStore PostgresFileStore
#endif
data AFStoreType = forall fs. AFSType (SFSType fs)
fromFileStore :: FileStore s -> s
fromFileStore = \case
StoreMemory s -> s
#if defined(dbServerPostgres)
StoreDatabase s -> s
#endif
{-# INLINE fromFileStore #-}
defFileExpirationHours :: Int64
defFileExpirationHours = 48
@@ -109,13 +170,22 @@ defaultFileExpiration =
checkInterval = 2 * 3600 -- seconds, 2 hours
}
newXFTPServerEnv :: XFTPServerConfig -> IO XFTPEnv
newXFTPServerEnv config@XFTPServerConfig {storeLogFile, fileSizeQuota, xftpCredentials, httpCredentials} = do
newXFTPServerEnv :: FileStoreClass s => XFTPServerConfig s -> IO (XFTPEnv s)
newXFTPServerEnv config@XFTPServerConfig {serverStoreCfg, fileSizeQuota, xftpCredentials, httpCredentials} = do
random <- C.newRandom
store <- newFileStore
storeLog <- mapM (`readWriteFileStore` store) storeLogFile
used <- countUsedStorage <$> readTVarIO (files store)
atomically $ writeTVar (usedStorage store) used
(store, storeLog) <- case serverStoreCfg of
XSCMemory storeLogPath -> do
st <- newFileStore ()
sl <- mapM (`readWriteFileStore` st) storeLogPath
atomically $ writeTVar (stmStoreLog st) sl
pure (StoreMemory st, sl)
#if defined(dbServerPostgres)
XSCDatabase dbCfg -> do
st <- newFileStore dbCfg
pure (StoreDatabase st, Nothing)
#endif
used <- getUsedStorage (fromFileStore store)
usedStorage <- newTVarIO used
forM_ fileSizeQuota $ \quota -> do
logNote $ "Total / available storage: " <> tshow quota <> " / " <> tshow (quota - used)
when (quota < used) $ logWarn "WARNING: storage quota is less than used storage, no files can be uploaded!"
@@ -123,12 +193,76 @@ newXFTPServerEnv config@XFTPServerConfig {storeLogFile, fileSizeQuota, xftpCrede
httpServerCreds <- mapM loadServerCredential httpCredentials
Fingerprint fp <- loadFingerprint xftpCredentials
serverStats <- newFileServerStats =<< getCurrentTime
pure XFTPEnv {config, store, storeLog, random, tlsServerCreds, httpServerCreds, serverIdentity = C.KeyHash fp, serverStats}
countUsedStorage :: M.Map k FileRec -> Int64
countUsedStorage = M.foldl' (\acc FileRec {fileInfo = FileInfo {size}} -> acc + fromIntegral size) 0
pure XFTPEnv {config, store, usedStorage, storeLog, random, tlsServerCreds, httpServerCreds, serverIdentity = C.KeyHash fp, serverStats}
data XFTPRequest
= XFTPReqNew FileInfo (NonEmpty RcvPublicAuthKey) (Maybe BasicAuth)
| XFTPReqCmd XFTPFileId FileRec FileCmd
| XFTPReqPing
readFileStoreType :: Ini -> Either String AFStoreType
readFileStoreType ini = case fromRight "memory" $ T.unpack <$> lookupValue "STORE_LOG" "store_files" ini of
"memory" -> Right $ AFSType SFSMemory
"database" -> Right $ AFSType SFSPostgres
other -> Left $ "Invalid store_files value: " <> other
-- | Dispatch store config from AFStoreType singleton and run the callback.
-- CPP guards for Postgres are handled here so Main.hs stays CPP-free.
runWithStoreConfig ::
AFStoreType ->
Ini ->
FilePath ->
MigrationConfirmation ->
(forall s. FileStoreClass s => XFTPStoreConfig s -> IO ()) ->
IO ()
runWithStoreConfig (AFSType SFSMemory) ini storeLogFilePath _confirmMigrations run =
run $ XSCMemory (enableStoreLog' $> storeLogFilePath)
where
enableStoreLog' = settingIsOn "STORE_LOG" "enable" ini
runWithStoreConfig (AFSType SFSPostgres) ini storeLogFilePath confirmMigrations run =
#if defined(dbServerPostgres)
run $ XSCDatabase dbCfg
where
enableDbStoreLog' = settingIsOn "STORE_LOG" "db_store_log" ini
dbStoreLogPath = enableDbStoreLog' $> storeLogFilePath
dbCfg = PostgresFileStoreCfg {dbOpts = iniDBOptions ini defaultXFTPDBOpts, dbStoreLogPath, confirmMigrations}
#else
error "server binary is compiled without support for PostgreSQL database"
#endif
-- | Validate startup config when store_files=database.
checkFileStoreMode :: Ini -> AFStoreType -> FilePath -> IO ()
checkFileStoreMode ini (AFSType SFSPostgres) storeLogFilePath = do
#if defined(dbServerPostgres)
storeLogExists <- doesFileExist storeLogFilePath
let dbStoreLogOn = settingIsOn "STORE_LOG" "db_store_log" ini
when (storeLogExists && isNothing dbStoreLogOn) $ do
putStrLn $ "Error: store log file " <> storeLogFilePath <> " exists but store_files is `database`."
putStrLn "Use `file-server database import` to migrate, or set `db_store_log: on`."
exitFailure
#else
putStrLn "Error: server binary is compiled without support for PostgreSQL database."
putStrLn "Please re-compile with `cabal build -fserver_postgres`."
exitFailure
#endif
checkFileStoreMode _ (AFSType SFSMemory) _ = pure ()
-- | Import StoreLog to PostgreSQL database.
importToDatabase :: FilePath -> Ini -> MigrationConfirmation -> IO ()
#if defined(dbServerPostgres)
importToDatabase storeLogFilePath ini _confirmMigrations = do
let dbCfg = PostgresFileStoreCfg {dbOpts = iniDBOptions ini defaultXFTPDBOpts, dbStoreLogPath = Nothing, confirmMigrations = _confirmMigrations}
importFileStore storeLogFilePath dbCfg
#else
importToDatabase _ _ _ = error "Error: server binary is compiled without support for PostgreSQL database.\nPlease re-compile with `cabal build -fserver_postgres`."
#endif
-- | Export PostgreSQL database to StoreLog.
exportFromDatabase :: FilePath -> Ini -> MigrationConfirmation -> IO ()
#if defined(dbServerPostgres)
exportFromDatabase storeLogFilePath ini _confirmMigrations = do
let dbCfg = PostgresFileStoreCfg {dbOpts = iniDBOptions ini defaultXFTPDBOpts, dbStoreLogPath = Nothing, confirmMigrations = _confirmMigrations}
exportFileStore storeLogFilePath dbCfg
#else
exportFromDatabase _ _ _ = error "Error: server binary is compiled without support for PostgreSQL database.\nPlease re-compile with `cabal build -fserver_postgres`."
#endif
+116 -54
View File
@@ -5,6 +5,7 @@
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.FileTransfer.Server.Main
@@ -12,7 +13,7 @@ module Simplex.FileTransfer.Server.Main
xftpServerCLI_,
) where
import Control.Monad (when)
import Control.Monad (unless, when)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Ini (lookupValue, readIniFile)
@@ -28,11 +29,12 @@ import Options.Applicative
import Simplex.FileTransfer.Chunks
import Simplex.FileTransfer.Description (FileSize (..))
import Simplex.FileTransfer.Server (runXFTPServer)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defFileExpirationHours, defaultFileExpiration, defaultInactiveClientExpiration)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), XFTPStoreConfig, AFStoreType (..), defFileExpirationHours, defaultFileExpiration, defaultInactiveClientExpiration, readFileStoreType, runWithStoreConfig, checkFileStoreMode, importToDatabase, exportFromDatabase)
import Simplex.FileTransfer.Transport (alpnSupportedXFTPhandshakes, supportedFileServerVRange)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), pattern XFTPServer)
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..))
import Simplex.Messaging.Server.CLI
import Simplex.Messaging.Server.Expiration
import Simplex.Messaging.Server.Information (ServerPublicInfo (..))
@@ -51,7 +53,7 @@ xftpServerCLI :: FilePath -> FilePath -> IO ()
xftpServerCLI = xftpServerCLI_ (\_ _ _ _ -> pure ()) (\_ -> pure ())
xftpServerCLI_ ::
(XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()) ->
(forall s. XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()) ->
(EmbeddedWebParams -> IO ()) ->
FilePath ->
FilePath ->
@@ -66,9 +68,13 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
doesFileExist iniFile >>= \case
True -> genOnline cfgPath certOpts
_ -> exitError $ "Error: server is not initialized (" <> iniFile <> " does not exist).\nRun `" <> executableName <> " init`."
Start ->
Start opts ->
doesFileExist iniFile >>= \case
True -> readIniFile iniFile >>= either exitError runServer
True -> readIniFile iniFile >>= either exitError (runServer opts)
_ -> exitError $ "Error: server is not initialized (" <> iniFile <> " does not exist).\nRun `" <> executableName <> " init`."
Database cmd ->
doesFileExist iniFile >>= \case
True -> readIniFile iniFile >>= either exitError (runDatabaseCmd cmd)
_ -> exitError $ "Error: server is not initialized (" <> iniFile <> " does not exist).\nRun `" <> executableName <> " init`."
Delete -> do
confirmOrExit
@@ -84,6 +90,21 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
executableName = "file-server"
storeLogFilePath = combine logPath "file-server-store.log"
defaultStaticPath = combine logPath "www"
runDatabaseCmd cmd ini = case cmd of
SCImport -> do
storeLogExists <- doesFileExist storeLogFilePath
unless storeLogExists $ exitError $ "Error: store log file " <> storeLogFilePath <> " does not exist."
confirmOrExit
("Import store log " <> storeLogFilePath <> " to PostgreSQL database?")
"Import cancelled."
importToDatabase storeLogFilePath ini MCYesUp
SCExport -> do
storeLogExists <- doesFileExist storeLogFilePath
when storeLogExists $ exitError $ "Error: store log file " <> storeLogFilePath <> " already exists."
confirmOrExit
("Export PostgreSQL database to store log " <> storeLogFilePath <> "?")
"Export cancelled."
exportFromDatabase storeLogFilePath ini MCConsole
initializeServer InitOptions {enableStoreLog, signAlgorithm, ip, fqdn, filesPath, fileSizeQuota, webStaticPath = webStaticPath_} = do
clearDirIfExists cfgPath
clearDirIfExists logPath
@@ -104,20 +125,20 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
\# available to the end users of the server.\n\
\# LICENSE: https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE\n\
\# Include correct source code URI in case the server source code is modified in any way.\n\
\# source_code: https://github.com/simplex-chat/simplexmq\n\
\# source_code = https://github.com/simplex-chat/simplexmq\n\
\\n\
\# Declaring all below information is optional, any of these fields can be omitted.\n\
\# server_country: ISO-3166 2-letter code\n\
\# operator: entity (organization or person name)\n\
\# operator_country: ISO-3166 2-letter code\n\
\# website:\n\
\# admin_simplex: SimpleX address\n\
\# admin_email:\n\
\# complaints_simplex: SimpleX address\n\
\# complaints_email:\n\
\# hosting: entity (organization or person name)\n\
\# hosting_country: ISO-3166 2-letter code\n\
\# hosting_type: virtual\n\
\# server_country = ISO-3166 2-letter code\n\
\# operator = entity (organization or person name)\n\
\# operator_country = ISO-3166 2-letter code\n\
\# website =\n\
\# admin_simplex = SimpleX address\n\
\# admin_email =\n\
\# complaints_simplex = SimpleX address\n\
\# complaints_email =\n\
\# hosting = entity (organization or person name)\n\
\# hosting_country = ISO-3166 2-letter code\n\
\# hosting_type = virtual\n\
\\n\
\[STORE_LOG]\n\
\# The server uses STM memory for persistence,\n\
@@ -125,55 +146,63 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
\# This option enables saving memory to append only log,\n\
\# and restoring it when the server is started.\n\
\# Log is compacted on start (deleted objects are removed).\n"
<> ("enable: " <> onOff enableStoreLog <> "\n\n")
<> ("enable = " <> onOff enableStoreLog <> "\n\n")
<> "# File storage mode: `memory` or `database` (PostgreSQL).\n\
\store_files = memory\n\n\
\# Database connection settings for PostgreSQL database (`store_files = database`).\n\
\# db_connection = postgresql://xftp@/xftp_server_store\n\
\# db_schema = xftp_server\n\
\# db_pool_size = 10\n\n\
\# Write database changes to store log file\n\
\# db_store_log = off\n\n"
<> "# Expire files after the specified number of hours.\n"
<> ("expire_files_hours: " <> tshow defFileExpirationHours <> "\n\n")
<> "log_stats: off\n\
<> ("expire_files_hours = " <> tshow defFileExpirationHours <> "\n\n")
<> "log_stats = off\n\
\\n\
\# Log interval for real-time Prometheus metrics\n\
\# prometheus_interval: 60\n\
\# prometheus_interval = 60\n\
\\n\
\[AUTH]\n\
\# Set new_files option to off to completely prohibit uploading new files.\n\
\# This can be useful when you want to decommission the server, but still allow downloading the existing files.\n\
\new_files: on\n\
\new_files = on\n\
\\n\
\# Use create_password option to enable basic auth to upload new files.\n\
\# The password should be used as part of server address in client configuration:\n\
\# xftp://fingerprint:password@host1,host2\n\
\# The password will not be shared with file recipients, you must share it only\n\
\# with the users who you want to allow uploading files to your server.\n\
\# create_password: password to upload files (any printable ASCII characters without whitespace, '@', ':' and '/')\n\
\# create_password = password to upload files (any printable ASCII characters without whitespace, '@', ':' and '/')\n\
\\n\
\# control_port_admin_password:\n\
\# control_port_user_password:\n\
\# control_port_admin_password =\n\
\# control_port_user_password =\n\
\\n\
\[TRANSPORT]\n\
\# host is only used to print server address on start\n"
<> ("host: " <> T.pack host <> "\n")
<> ("port: " <> T.pack defaultServerPort <> "\n")
<> "log_tls_errors: off\n\
\# control_port: 5226\n\
<> ("host = " <> T.pack host <> "\n")
<> ("port = " <> T.pack defaultServerPort <> "\n")
<> "log_tls_errors = off\n\
\# control_port = 5226\n\
\\n\
\[FILES]\n"
<> ("path: " <> T.pack filesPath <> "\n")
<> ("storage_quota: " <> safeDecodeUtf8 (strEncode fileSizeQuota) <> "\n")
<> ("path = " <> T.pack filesPath <> "\n")
<> ("storage_quota = " <> safeDecodeUtf8 (strEncode fileSizeQuota) <> "\n")
<> "\n\
\[INACTIVE_CLIENTS]\n\
\# TTL and interval to check inactive clients\n\
\disconnect: off\n"
<> ("# ttl: " <> tshow (ttl defaultInactiveClientExpiration) <> "\n")
<> ("# check_interval: " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n")
\disconnect = off\n"
<> ("# ttl = " <> tshow (ttl defaultInactiveClientExpiration) <> "\n")
<> ("# check_interval = " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n")
<> "\n\
\[WEB]\n\
\# Set path to generate static mini-site for server information\n"
<> ("static_path: " <> T.pack (fromMaybe defaultStaticPath webStaticPath_) <> "\n\n")
<> ("static_path = " <> T.pack (fromMaybe defaultStaticPath webStaticPath_) <> "\n\n")
<> "# Run an embedded HTTP server on this port.\n\
\# http: 8000\n\n\
\# http = 8000\n\n\
\# TLS credentials for HTTPS web server on the same port as XFTP.\n\
\# cert: " <> T.pack (cfgPath `combine` "web.crt") <> "\n\
\# key: " <> T.pack (cfgPath `combine` "web.key") <> "\n"
runServer ini = do
\# cert = " <> T.pack (cfgPath `combine` "web.crt") <> "\n\
\# key = " <> T.pack (cfgPath `combine` "web.key") <> "\n"
runServer StartOptions {confirmMigrations} ini = do
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
fp <- checkSavedFingerprint cfgPath defaultX509Config
@@ -183,18 +212,24 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
printServiceInfo serverVersion srv
let information = serverPublicInfo ini
printSourceCode (sourceCode <$> information)
printXFTPConfig serverConfig
case webStaticPath' of
Just path -> do
let onionHost =
either (const Nothing) (find isOnion) $
strDecode @(L.NonEmpty TransportHost) . encodeUtf8 =<< lookupValue "TRANSPORT" "host" ini
webHttpPort = eitherToMaybe (lookupValue "WEB" "http" ini) >>= readMaybe . T.unpack
generateSite serverConfig information onionHost path
when (isJust webHttpPort || isJust webHttpsParams') $
serveStaticFiles EmbeddedWebParams {webStaticPath = path, webHttpPort, webHttpsParams = webHttpsParams'}
Nothing -> pure ()
runXFTPServer serverConfig
case readFileStoreType ini of
Left err -> error err
Right fsType -> do
checkFileStoreMode ini fsType storeLogFilePath
runWithStoreConfig fsType ini storeLogFilePath confirmMigrations $ \storeCfg -> do
let cfg = serverConfig storeCfg
printXFTPConfig cfg
case webStaticPath' of
Just path -> do
let onionHost =
either (const Nothing) (find isOnion) $
strDecode @(L.NonEmpty TransportHost) . encodeUtf8 =<< lookupValue "TRANSPORT" "host" ini
webHttpPort = eitherToMaybe (lookupValue "WEB" "http" ini) >>= readMaybe . T.unpack
generateSite cfg information onionHost path
when (isJust webHttpPort || isJust webHttpsParams') $
serveStaticFiles EmbeddedWebParams {webStaticPath = path, webHttpPort, webHttpsParams = webHttpsParams'}
Nothing -> pure ()
runXFTPServer cfg
where
isOnion = \case THOnionHost _ -> True; _ -> False
enableStoreLog = settingIsOn "STORE_LOG" "enable" ini
@@ -236,11 +271,13 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
webStaticPath' = eitherToMaybe $ T.unpack <$> lookupValue "WEB" "static_path" ini
serverConfig =
serverConfig :: XFTPStoreConfig s -> XFTPServerConfig s
serverConfig serverStoreCfg =
XFTPServerConfig
{ xftpPort = T.unpack $ strictIni "TRANSPORT" "port" ini,
controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini,
fileIdSize = 16,
serverStoreCfg,
storeLogFile = enableStoreLog $> storeLogFilePath,
filesPath = T.unpack $ strictIni "FILES" "path" ini,
fileSizeQuota = either error unFileSize <$> strDecodeIni "FILES" "storage_quota" ini,
@@ -289,9 +326,16 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do
data CliCommand
= Init InitOptions
| OnlineCert CertOptions
| Start
| Start StartOptions
| Database StoreCmd
| Delete
data StoreCmd = SCImport | SCExport
newtype StartOptions = StartOptions
{ confirmMigrations :: MigrationConfirmation
}
data InitOptions = InitOptions
{ enableStoreLog :: Bool,
signAlgorithm :: SignAlgorithm,
@@ -308,7 +352,8 @@ cliCommandP cfgPath logPath iniFile =
hsubparser
( command "init" (info (Init <$> initP) (progDesc $ "Initialize server - creates " <> cfgPath <> " and " <> logPath <> " directories and configuration files"))
<> command "cert" (info (OnlineCert <$> certOptionsP) (progDesc $ "Generate new online TLS server credentials (configuration: " <> iniFile <> ")"))
<> command "start" (info (pure Start) (progDesc $ "Start server (configuration: " <> iniFile <> ")"))
<> command "start" (info (Start <$> startOptsP) (progDesc $ "Start server (configuration: " <> iniFile <> ")"))
<> command "database" (info (Database <$> storeCmdP) (progDesc "Import/export file store to/from PostgreSQL database"))
<> command "delete" (info (pure Delete) (progDesc "Delete configuration and log files"))
)
where
@@ -375,3 +420,20 @@ cliCommandP cfgPath logPath iniFile =
<> metavar "PATH"
)
pure InitOptions {enableStoreLog, signAlgorithm, ip, fqdn, filesPath, fileSizeQuota, webStaticPath}
startOptsP :: Parser StartOptions
startOptsP = do
confirmMigrations <-
option
parseConfirmMigrations
( long "confirm-migrations"
<> metavar "CONFIRM_MIGRATIONS"
<> help "Confirm PostgreSQL database migration: up, down (default is manual confirmation)"
<> value MCConsole
)
pure StartOptions {confirmMigrations}
storeCmdP :: Parser StoreCmd
storeCmdP =
hsubparser
( command "import" (info (pure SCImport) (progDesc "Import store log file into PostgreSQL database"))
<> command "export" (info (pure SCExport) (progDesc "Export PostgreSQL database to store log file"))
)
+134 -101
View File
@@ -1,51 +1,53 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeFamilies #-}
module Simplex.FileTransfer.Server.Store
( FileStore (..),
( FSType (..),
SFSType (..),
FileStoreClass (..),
FileRec (..),
FileRecipient (..),
STMFileStore (..),
RoundedFileTime,
newFileStore,
addFile,
setFilePath,
addRecipient,
deleteFile,
blockFile,
deleteRecipient,
expiredFilePath,
getFile,
ackFile,
fileTimePrecision,
)
where
import Data.Kind (Type)
import Control.Concurrent.STM
import Control.Monad
import Control.Monad (forM, void)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Int (Int64)
import qualified Data.Map.Strict as M
import Data.Maybe (catMaybes)
import Data.Set (Set)
import qualified Data.Set as S
import Data.Word (Word32)
import Simplex.FileTransfer.Protocol (FileInfo (..), SFileParty (..), XFTPFileId)
import Simplex.FileTransfer.Transport (XFTPErrorType (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol (BlockingInfo, RcvPublicAuthKey, RecipientId, SenderId)
import Simplex.Messaging.Server.QueueStore (ServerEntityStatus (..))
import Simplex.Messaging.Server.StoreLog (StoreLog, closeStoreLog)
import System.IO (IOMode (..))
import Simplex.Messaging.SystemTime
import Simplex.Messaging.TMap (TMap)
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Util (ifM, ($>>=))
import Simplex.Messaging.Util (ifM)
data FileStore = FileStore
{ files :: TMap SenderId FileRec,
recipients :: TMap RecipientId (SenderId, RcvPublicAuthKey),
usedStorage :: TVar Int64
}
data FSType = FSMemory | FSPostgres
data SFSType :: FSType -> Type where
SFSMemory :: SFSType 'FSMemory
SFSPostgres :: SFSType 'FSPostgres
data FileRec = FileRec
{ senderId :: SenderId,
@@ -59,28 +61,126 @@ data FileRec = FileRec
type RoundedFileTime = RoundedSystemTime 3600
fileTimePrecision :: Int64
fileTimePrecision = 3600 -- truncate creation time to 1 hour
fileTimePrecision = 3600
data FileRecipient = FileRecipient RecipientId RcvPublicAuthKey
data FileRecipient = FileRecipient RecipientId C.APublicAuthKey
deriving (Show)
instance StrEncoding FileRecipient where
strEncode (FileRecipient rId rKey) = strEncode rId <> ":" <> strEncode rKey
strP = FileRecipient <$> strP <* A.char ':' <*> strP
newFileStore :: IO FileStore
newFileStore = do
files <- TM.emptyIO
recipients <- TM.emptyIO
usedStorage <- newTVarIO 0
pure FileStore {files, recipients, usedStorage}
class FileStoreClass s where
type FileStoreConfig s
newFileStore :: FileStoreConfig s -> IO s
closeFileStore :: s -> IO ()
addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ())
setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ())
addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ())
deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ())
deleteFiles :: s -> [SenderId] -> IO ()
deleteFiles s = mapM_ (void . deleteFile s)
blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ())
deleteRecipient :: s -> RecipientId -> FileRec -> IO ()
getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey))
ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ())
expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
getUsedStorage :: s -> IO Int64
getFileCount :: s -> IO Int
addFile :: FileStore -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> STM (Either XFTPErrorType ())
addFile FileStore {files} sId fileInfo createdAt status =
ifM (TM.member sId files) (pure $ Left DUPLICATE_) $ do
f <- newFileRec sId fileInfo createdAt status
TM.insert sId f files
pure $ Right ()
-- STM in-memory store
data STMFileStore = STMFileStore
{ files :: TMap SenderId FileRec,
recipients :: TMap RecipientId (SenderId, RcvPublicAuthKey),
stmStoreLog :: TVar (Maybe (StoreLog 'WriteMode))
}
instance FileStoreClass STMFileStore where
type FileStoreConfig STMFileStore = ()
newFileStore () = do
files <- TM.emptyIO
recipients <- TM.emptyIO
stmStoreLog <- newTVarIO Nothing
pure STMFileStore {files, recipients, stmStoreLog}
closeFileStore STMFileStore {stmStoreLog} = readTVarIO stmStoreLog >>= mapM_ closeStoreLog
addFile STMFileStore {files} sId fileInfo createdAt status = atomically $
ifM (TM.member sId files) (pure $ Left DUPLICATE_) $ do
f <- newFileRec sId fileInfo createdAt status
TM.insert sId f files
pure $ Right ()
setFilePath st sId fPath = atomically $
withFile st sId $ \FileRec {filePath, fileStatus} -> do
readTVar filePath >>= \case
Just _ -> pure $ Left AUTH
Nothing ->
readTVar fileStatus >>= \case
EntityActive -> do
writeTVar filePath (Just fPath)
pure $ Right ()
_ -> pure $ Left AUTH
addRecipient st@STMFileStore {recipients} senderId (FileRecipient rId rKey) = atomically $
withFile st senderId $ \FileRec {recipientIds} -> do
rIds <- readTVar recipientIds
mem <- TM.member rId recipients
if rId `S.member` rIds || mem
then pure $ Left DUPLICATE_
else do
writeTVar recipientIds $! S.insert rId rIds
TM.insert rId (senderId, rKey) recipients
pure $ Right ()
deleteFile STMFileStore {files, recipients} senderId = atomically $ do
TM.lookupDelete senderId files >>= \case
Just FileRec {recipientIds} -> do
readTVar recipientIds >>= mapM_ (`TM.delete` recipients)
pure $ Right ()
_ -> pure $ Left AUTH
blockFile st senderId info _deleted = atomically $
withFile st senderId $ \FileRec {fileStatus} -> do
writeTVar fileStatus $! EntityBlocked info
pure $ Right ()
deleteRecipient STMFileStore {recipients} rId FileRec {recipientIds} = atomically $ do
TM.delete rId recipients
modifyTVar' recipientIds $ S.delete rId
getFile st party fId = atomically $ case party of
SFSender -> withFile st fId $ pure . Right . (\f -> (f, sndKey $ fileInfo f))
SFRecipient ->
TM.lookup fId (recipients st) >>= \case
Just (sId, rKey) -> withFile st sId $ pure . Right . (,rKey)
_ -> pure $ Left AUTH
ackFile st@STMFileStore {recipients} recipientId = atomically $ do
TM.lookupDelete recipientId recipients >>= \case
Just (sId, _) ->
withFile st sId $ \FileRec {recipientIds} -> do
modifyTVar' recipientIds $ S.delete recipientId
pure $ Right ()
_ -> pure $ Left AUTH
expiredFiles STMFileStore {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
getUsedStorage STMFileStore {files} =
M.foldl' (\acc FileRec {fileInfo = FileInfo {size}} -> acc + fromIntegral size) 0 <$> readTVarIO files
getFileCount STMFileStore {files} = M.size <$> readTVarIO files
-- Internal STM helpers
newFileRec :: SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> STM FileRec
newFileRec senderId fileInfo createdAt status = do
@@ -89,75 +189,8 @@ newFileRec senderId fileInfo createdAt status = do
fileStatus <- newTVar status
pure FileRec {senderId, fileInfo, filePath, recipientIds, createdAt, fileStatus}
setFilePath :: FileStore -> SenderId -> FilePath -> STM (Either XFTPErrorType ())
setFilePath st sId fPath =
withFile st sId $ \FileRec {fileInfo, filePath} -> do
writeTVar filePath (Just fPath)
modifyTVar' (usedStorage st) (+ fromIntegral (size fileInfo))
pure $ Right ()
addRecipient :: FileStore -> SenderId -> FileRecipient -> STM (Either XFTPErrorType ())
addRecipient st@FileStore {recipients} senderId (FileRecipient rId rKey) =
withFile st senderId $ \FileRec {recipientIds} -> do
rIds <- readTVar recipientIds
mem <- TM.member rId recipients
if rId `S.member` rIds || mem
then pure $ Left DUPLICATE_
else do
writeTVar recipientIds $! S.insert rId rIds
TM.insert rId (senderId, rKey) recipients
pure $ Right ()
-- this function must be called after the file is deleted from the file system
deleteFile :: FileStore -> SenderId -> STM (Either XFTPErrorType ())
deleteFile FileStore {files, recipients, usedStorage} senderId = do
TM.lookupDelete senderId files >>= \case
Just FileRec {fileInfo, recipientIds} -> do
readTVar recipientIds >>= mapM_ (`TM.delete` recipients)
modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)
pure $ Right ()
_ -> pure $ Left AUTH
-- this function must be called after the file is deleted from the file system
blockFile :: FileStore -> SenderId -> BlockingInfo -> Bool -> STM (Either XFTPErrorType ())
blockFile st@FileStore {usedStorage} senderId info deleted =
withFile st senderId $ \FileRec {fileInfo, fileStatus} -> do
when deleted $ modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)
writeTVar fileStatus $! EntityBlocked info
pure $ Right ()
deleteRecipient :: FileStore -> RecipientId -> FileRec -> STM ()
deleteRecipient FileStore {recipients} rId FileRec {recipientIds} = do
TM.delete rId recipients
modifyTVar' recipientIds $ S.delete rId
getFile :: FileStore -> SFileParty p -> XFTPFileId -> STM (Either XFTPErrorType (FileRec, C.APublicAuthKey))
getFile st party fId = case party of
SFSender -> withFile st fId $ pure . Right . (\f -> (f, sndKey $ fileInfo f))
SFRecipient ->
TM.lookup fId (recipients st) >>= \case
Just (sId, rKey) -> withFile st sId $ pure . Right . (,rKey)
_ -> pure $ Left AUTH
expiredFilePath :: FileStore -> XFTPFileId -> Int64 -> STM (Maybe (Maybe FilePath))
expiredFilePath FileStore {files} sId old =
TM.lookup sId files
$>>= \FileRec {filePath, createdAt = RoundedSystemTime createdAt} ->
if createdAt + fileTimePrecision < old
then Just <$> readTVar filePath
else pure Nothing
ackFile :: FileStore -> RecipientId -> STM (Either XFTPErrorType ())
ackFile st@FileStore {recipients} recipientId = do
TM.lookupDelete recipientId recipients >>= \case
Just (sId, _) ->
withFile st sId $ \FileRec {recipientIds} -> do
modifyTVar' recipientIds $ S.delete recipientId
pure $ Right ()
_ -> pure $ Left AUTH
withFile :: FileStore -> SenderId -> (FileRec -> STM (Either XFTPErrorType a)) -> STM (Either XFTPErrorType a)
withFile FileStore {files} sId a =
withFile :: STMFileStore -> SenderId -> (FileRec -> STM (Either XFTPErrorType a)) -> STM (Either XFTPErrorType a)
withFile STMFileStore {files} sId a =
TM.lookup sId files >>= \case
Just f -> a f
_ -> pure $ Left AUTH
@@ -0,0 +1,370 @@
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
module Simplex.FileTransfer.Server.Store.Postgres
( PostgresFileStore (..),
importFileStore,
exportFileStore,
)
where
import qualified Control.Exception as E
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Control.Monad.Trans.Except (throwE)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Builder (Builder)
import qualified Data.ByteString.Builder as BB
import qualified Data.ByteString.Lazy as LB
import Data.Functor (($>))
import Data.Int (Int32, Int64)
import Data.List (intersperse)
import qualified Data.List.NonEmpty as L
import qualified Data.Map.Strict as M
import qualified Data.Set as S
import Data.Text (Text)
import Data.Word (Word32)
import Database.PostgreSQL.Simple (Binary (..), In (..), Only (..), SqlError, (:.) (..))
import qualified Database.PostgreSQL.Simple as DB
import qualified Database.PostgreSQL.Simple.Copy as DB
import Database.PostgreSQL.Simple.Errors (ConstraintViolation (..), constraintViolation)
import Database.PostgreSQL.Simple.ToField (Action (..), ToField (..))
import GHC.IO (catchAny)
import Simplex.FileTransfer.Protocol (FileInfo (..), SFileParty (..))
import Simplex.FileTransfer.Server.Store
import Simplex.FileTransfer.Server.Store.Postgres.Config
import Simplex.FileTransfer.Server.Store.Postgres.Migrations (xftpServerMigrations)
import Simplex.FileTransfer.Server.StoreLog
import Simplex.FileTransfer.Transport (XFTPErrorType (..))
import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore)
import Simplex.Messaging.Agent.Store.Postgres.Common (DBStore, withTransaction)
import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..))
import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Protocol (RcvPublicAuthKey, RecipientId, SenderId)
import Simplex.Messaging.Transport (EntityId (..))
import Simplex.Messaging.Server.QueueStore (ServerEntityStatus (..))
import Simplex.Messaging.Server.QueueStore.Postgres ()
import Simplex.Messaging.Server.StoreLog (openWriteStoreLog)
import Simplex.Messaging.Util (firstRow, tshow)
import System.Directory (renameFile)
import System.Exit (exitFailure)
import System.IO (IOMode (..), hFlush, stdout)
import UnliftIO.STM
data PostgresFileStore = PostgresFileStore
{ dbStore :: DBStore,
dbStoreLog :: Maybe (StoreLog 'WriteMode)
}
instance FileStoreClass PostgresFileStore where
type FileStoreConfig PostgresFileStore = PostgresFileStoreCfg
newFileStore PostgresFileStoreCfg {dbOpts, dbStoreLogPath, confirmMigrations} = do
dbStore <- either err pure =<< createDBStore dbOpts xftpServerMigrations (MigrationConfig confirmMigrations Nothing)
dbStoreLog <- mapM (openWriteStoreLog True) dbStoreLogPath
pure PostgresFileStore {dbStore, dbStoreLog}
where
err e = do
logError $ "STORE: newFileStore, error opening PostgreSQL database, " <> tshow e
exitFailure
closeFileStore PostgresFileStore {dbStore, dbStoreLog} = do
closeDBStore dbStore
mapM_ closeStoreLog dbStoreLog
addFile st sId fileInfo@FileInfo {sndKey, size, digest} createdAt status =
E.uninterruptibleMask_ $ runExceptT $ do
void $ withDB "addFile" st $ \db ->
E.try
( DB.execute
db
"INSERT INTO files (sender_id, file_size, file_digest, sender_key, created_at, status) VALUES (?,?,?,?,?,?)"
(sId, (fromIntegral size :: Int32), Binary digest, Binary (C.encodePubKey sndKey), createdAt, status)
)
>>= either handleDuplicate (pure . Right)
withLog "addFile" st $ \s -> logAddFile s sId fileInfo createdAt status
setFilePath st sId fPath = E.uninterruptibleMask_ $ runExceptT $ do
assertUpdated $ withDB' "setFilePath" st $ \db ->
DB.execute db "UPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL AND status = 'active'" (fPath, sId)
withLog "setFilePath" st $ \s -> logPutFile s sId fPath
addRecipient st senderId (FileRecipient rId rKey) = E.uninterruptibleMask_ $ runExceptT $ do
void $ withDB "addRecipient" st $ \db ->
E.try
( DB.execute
db
"INSERT INTO recipients (recipient_id, sender_id, recipient_key) VALUES (?,?,?)"
(rId, senderId, Binary (C.encodePubKey rKey))
)
>>= either handleDuplicate (pure . Right)
withLog "addRecipient" st $ \s -> logAddRecipients s senderId (pure $ FileRecipient rId rKey)
deleteFile st sId = E.uninterruptibleMask_ $ runExceptT $ do
assertUpdated $ withDB' "deleteFile" st $ \db ->
DB.execute db "DELETE FROM files WHERE sender_id = ?" (Only sId)
withLog "deleteFile" st $ \s -> logDeleteFile s sId
deleteFiles st sIds = E.uninterruptibleMask_ $ do
withTransaction (dbStore st) $ \db ->
DB.execute db "DELETE FROM files WHERE sender_id IN ?" (Only (In sIds))
withLog "deleteFiles" st $ \s -> mapM_ (logDeleteFile s) sIds
blockFile st sId info _deleted = E.uninterruptibleMask_ $ runExceptT $ do
assertUpdated $ withDB' "blockFile" st $ \db ->
DB.execute db "UPDATE files SET status = ? WHERE sender_id = ?" (EntityBlocked info, sId)
withLog "blockFile" st $ \s -> logBlockFile s sId info
deleteRecipient st rId _fr =
void $ runExceptT $ withDB' "deleteRecipient" st $ \db ->
DB.execute db "DELETE FROM recipients WHERE recipient_id = ?" (Only rId)
getFile st party fId = runExceptT $ case party of
SFSender -> do
row <- loadFileRow "SELECT sender_id, file_size, file_digest, sender_key, file_path, created_at, status FROM files WHERE sender_id = ?"
fr <- ExceptT $ rowToFileRec row
pure (fr, sndKey (fileInfo fr))
SFRecipient -> do
row :. Only rcpKeyBs <-
loadFileRow
"SELECT f.sender_id, f.file_size, f.file_digest, f.sender_key, f.file_path, f.created_at, f.status, r.recipient_key FROM files f JOIN recipients r ON r.sender_id = f.sender_id WHERE r.recipient_id = ?"
fr <- ExceptT $ rowToFileRec row
rcpKey <- either (const $ throwE INTERNAL) pure $ C.decodePubKey rcpKeyBs
pure (fr, rcpKey)
where
loadFileRow :: DB.FromRow r => DB.Query -> ExceptT XFTPErrorType IO r
loadFileRow q =
withDB "getFile" st $ \db ->
firstRow id AUTH $ DB.query db q (Only fId)
ackFile st rId = E.uninterruptibleMask_ $ runExceptT $ do
assertUpdated $ withDB' "ackFile" st $ \db ->
DB.execute db "DELETE FROM recipients WHERE recipient_id = ?" (Only rId)
withLog "ackFile" st $ \s -> logAckFile s rId
expiredFiles st old limit =
fmap toResult $ withTransaction (dbStore st) $ \db ->
DB.query
db
"SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? ORDER BY created_at LIMIT ?"
(fileTimePrecision, old, limit)
where
toResult :: [(SenderId, Maybe FilePath, Int32)] -> [(SenderId, Maybe FilePath, Word32)]
toResult = map (\(sId, path, size) -> (sId, path, fromIntegral size))
getUsedStorage st =
withTransaction (dbStore st) $ \db -> do
[Only total] <- DB.query_ db "SELECT COALESCE(SUM(file_size::BIGINT), 0)::BIGINT FROM files"
pure total
getFileCount st =
withTransaction (dbStore st) $ \db -> do
[Only count] <- DB.query_ db "SELECT COUNT(*) FROM files"
pure (fromIntegral (count :: Int64))
-- Internal helpers
mkFileRec :: SenderId -> FileInfo -> Maybe FilePath -> RoundedFileTime -> ServerEntityStatus -> IO FileRec
mkFileRec senderId fileInfo path createdAt status = do
filePath <- newTVarIO path
recipientIds <- newTVarIO S.empty
fileStatus <- newTVarIO status
pure FileRec {senderId, fileInfo, filePath, recipientIds, createdAt, fileStatus}
type FileRecRow = (SenderId, Int32, ByteString, ByteString, Maybe FilePath, RoundedFileTime, ServerEntityStatus)
rowToFileRec :: FileRecRow -> IO (Either XFTPErrorType FileRec)
rowToFileRec (sId, size, digest, sndKeyBs, path, createdAt, status) =
case C.decodePubKey sndKeyBs of
Right sndKey -> do
let fileInfo = FileInfo {sndKey, size = fromIntegral size, digest}
Right <$> mkFileRec sId fileInfo path createdAt status
Left _ -> pure $ Left INTERNAL
-- DB helpers
withDB :: forall a. 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
withDB' :: Text -> PostgresFileStore -> (DB.Connection -> IO a) -> ExceptT XFTPErrorType IO a
withDB' op st action = withDB op st $ fmap Right . action
assertUpdated :: ExceptT XFTPErrorType IO Int64 -> ExceptT XFTPErrorType IO ()
assertUpdated = (>>= \n -> when (n == 0) (throwE AUTH))
handleDuplicate :: SqlError -> IO (Either XFTPErrorType a)
handleDuplicate e = case constraintViolation e of
Just (UniqueViolation _) -> pure $ Left DUPLICATE_
Just (ForeignKeyViolation _ _) -> pure $ Left AUTH
_ -> E.throwIO e
withLog :: MonadIO m => Text -> PostgresFileStore -> (StoreLog 'WriteMode -> IO ()) -> m ()
withLog op PostgresFileStore {dbStoreLog} action =
forM_ dbStoreLog $ \sl -> liftIO $ action sl `catchAny` \e ->
logWarn $ "STORE: " <> op <> ", withLog, " <> tshow e
-- Import: StoreLog -> PostgreSQL
importFileStore :: FilePath -> PostgresFileStoreCfg -> IO ()
importFileStore storeLogFilePath dbCfg = do
putStrLn $ "Reading store log: " <> storeLogFilePath
stmStore <- newFileStore () :: IO STMFileStore
sl <- readWriteFileStore storeLogFilePath stmStore
closeStoreLog sl
allFiles <- readTVarIO (files stmStore)
allRcps <- readTVarIO (recipients stmStore)
let fileCount = M.size allFiles
rcpCount = M.size allRcps
putStrLn $ "Loaded " <> show fileCount <> " files, " <> show rcpCount <> " recipients."
let dbCfg' = dbCfg {dbOpts = (dbOpts dbCfg) {createSchema = True}, confirmMigrations = MCYesUp}
pgStore <- newFileStore dbCfg' :: IO PostgresFileStore
existingCount <- getFileCount pgStore
when (existingCount > 0) $ do
putStrLn $ "WARNING: database already contains " <> show existingCount <> " files. Import will fail on duplicate keys."
putStrLn "Drop the existing schema first or use a fresh database."
exitFailure
putStrLn "Importing files..."
fCnt <- withTransaction (dbStore pgStore) $ \db -> do
DB.copy_
db
"COPY files (sender_id, file_size, file_digest, sender_key, file_path, created_at, status) FROM STDIN WITH (FORMAT csv)"
iforM_ (M.toList allFiles) $ \i (sId, fr) -> do
DB.putCopyData db =<< fileRecToCSV sId fr
when (i > 0 && i `mod` 10000 == 0) $ putStr (" " <> show i <> " files\r") >> hFlush stdout
DB.putCopyEnd db
[Only cnt] <- DB.query_ db "SELECT COUNT(*) FROM files"
pure (cnt :: Int64)
putStrLn $ "Imported " <> show fCnt <> " files."
putStrLn "Importing recipients..."
rCnt <- withTransaction (dbStore pgStore) $ \db -> do
DB.copy_
db
"COPY recipients (recipient_id, sender_id, recipient_key) FROM STDIN WITH (FORMAT csv)"
iforM_ (M.toList allRcps) $ \i (rId, (sId, rKey)) -> do
DB.putCopyData db $ recipientToCSV rId sId rKey
when (i > 0 && i `mod` 10000 == 0) $ putStr (" " <> show i <> " recipients\r") >> hFlush stdout
DB.putCopyEnd db
[Only cnt] <- DB.query_ db "SELECT COUNT(*) FROM recipients"
pure (cnt :: Int64)
putStrLn $ "Imported " <> show rCnt <> " recipients."
when (fromIntegral fileCount /= fCnt) $
putStrLn $ "WARNING: expected " <> show fileCount <> " files, got " <> show fCnt
when (fromIntegral rcpCount /= rCnt) $
putStrLn $ "WARNING: expected " <> show rcpCount <> " recipients, got " <> show rCnt
closeFileStore pgStore
renameFile storeLogFilePath (storeLogFilePath <> ".bak")
putStrLn $ "Store log renamed to " <> storeLogFilePath <> ".bak"
-- Export: PostgreSQL -> StoreLog
exportFileStore :: FilePath -> PostgresFileStoreCfg -> IO ()
exportFileStore storeLogFilePath dbCfg = do
pgStore <- newFileStore dbCfg :: IO PostgresFileStore
sl <- openWriteStoreLog False storeLogFilePath
-- Fold 1: stream files, write FNEW + FPUT per file
putStrLn "Exporting files..."
!fCnt <- withTransaction (dbStore pgStore) $ \db ->
DB.fold_
db
"SELECT sender_id, file_size, file_digest, sender_key, file_path, created_at, status FROM files ORDER BY created_at"
(0 :: Int)
( \(!fc) (sId, size :: Int32, digest :: ByteString, sndKeyBs :: ByteString, path :: Maybe String, createdAt, status) ->
case C.decodePubKey sndKeyBs of
Right sndKey -> do
let fileInfo = FileInfo {sndKey, size = fromIntegral size, digest}
logAddFile sl sId fileInfo createdAt status
forM_ path $ logPutFile sl sId
pure (fc + 1)
Left _ -> do
putStrLn $ "WARNING: invalid sender key for " <> show sId
pure fc
)
-- Fold 2: stream recipients ordered by sender_id, flush FADD on sender change
putStrLn "Exporting recipients..."
!rCnt <- withTransaction (dbStore pgStore) $ \db ->
DB.fold_
db
"SELECT sender_id, recipient_id, recipient_key FROM recipients ORDER BY sender_id"
(Nothing :: Maybe SenderId, [] :: [FileRecipient], 0 :: Int)
( \(!prevSId, !buf, !rc) (sId, rId, rKeyBs :: ByteString) ->
case C.decodePubKey rKeyBs of
Right rKey -> do
let rcp = FileRecipient rId rKey
case prevSId of
Just prev | prev /= sId -> do
forM_ (L.nonEmpty buf) $ logAddRecipients sl prev
pure (Just sId, [rcp], rc + length buf)
_ -> pure (Just sId, rcp : buf, rc)
Left _ -> putStrLn ("WARNING: invalid recipient key for " <> show rId) $> (prevSId, buf, rc)
)
>>= \(lastSId, buf, rc) -> do
forM_ lastSId $ \sId -> forM_ (L.nonEmpty buf) $ logAddRecipients sl sId
pure (rc + length buf)
closeStoreLog sl
closeFileStore pgStore
putStrLn $ "Exported " <> show fCnt <> " files, " <> show rCnt <> " recipients to " <> storeLogFilePath
-- CSV helpers for COPY protocol
iforM_ :: Monad m => [a] -> (Int -> a -> m ()) -> m ()
iforM_ xs f = zipWithM_ f [0 ..] xs
fileRecToCSV :: SenderId -> FileRec -> IO ByteString
fileRecToCSV sId FileRec {fileInfo = FileInfo {sndKey, size, digest}, filePath, createdAt, fileStatus} = do
path <- readTVarIO filePath
status <- readTVarIO fileStatus
pure $ LB.toStrict $ BB.toLazyByteString $ mconcat (BB.char7 ',' `intersperse` fields path status) <> BB.char7 '\n'
where
fields path status =
[ renderField (toField (Binary (unEntityId sId))),
renderField (toField (fromIntegral size :: Int32)),
renderField (toField (Binary digest)),
renderField (toField (Binary (C.encodePubKey sndKey))),
nullable (toField <$> path),
renderField (toField createdAt),
quotedField (toField status)
]
recipientToCSV :: RecipientId -> SenderId -> RcvPublicAuthKey -> ByteString
recipientToCSV rId sId rKey =
LB.toStrict $ BB.toLazyByteString $ mconcat (BB.char7 ',' `intersperse` fields) <> BB.char7 '\n'
where
fields =
[ renderField (toField (Binary (unEntityId rId))),
renderField (toField (Binary (unEntityId sId))),
renderField (toField (Binary (C.encodePubKey rKey)))
]
renderField :: Action -> Builder
renderField = \case
Plain bld -> bld
Escape s -> BB.byteString s
EscapeByteA s -> BB.string7 "\\x" <> BB.byteStringHex s
EscapeIdentifier s -> BB.byteString s
Many as -> mconcat (map renderField as)
nullable :: Maybe Action -> Builder
nullable = maybe mempty renderField
quotedField :: Action -> Builder
quotedField a = BB.char7 '"' <> escapeQuotes (renderField a) <> BB.char7 '"'
where
escapeQuotes bld =
let bs = LB.toStrict $ BB.toLazyByteString bld
in BB.byteString $ B.concatMap (\c -> if c == '"' then "\"\"" else B.singleton c) bs
@@ -0,0 +1,25 @@
{-# LANGUAGE OverloadedStrings #-}
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
}
@@ -0,0 +1,47 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
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)
]
-- | The list of migrations in ascending order by date
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,
file_size INTEGER NOT NULL,
file_digest BYTEA NOT NULL,
sender_key BYTEA NOT NULL,
file_path TEXT,
created_at BIGINT 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);
|]
+10 -6
View File
@@ -10,6 +10,7 @@ module Simplex.FileTransfer.Server.StoreLog
FileStoreLogRecord (..),
closeStoreLog,
readWriteFileStore,
writeFileStore,
logAddFile,
logPutFile,
logAddRecipients,
@@ -32,6 +33,7 @@ import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Simplex.FileTransfer.Protocol (FileInfo (..))
import Simplex.FileTransfer.Server.Store
import Simplex.FileTransfer.Transport (XFTPErrorType (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol (BlockingInfo, RcvPublicAuthKey, RecipientId, SenderId)
import Simplex.Messaging.Server.QueueStore (ServerEntityStatus (..))
@@ -87,20 +89,22 @@ logBlockFile s fId = logFileStoreRecord s . BlockFile fId
logAckFile :: StoreLog 'WriteMode -> RecipientId -> IO ()
logAckFile s = logFileStoreRecord s . AckFile
readWriteFileStore :: FilePath -> FileStore -> IO (StoreLog 'WriteMode)
readWriteFileStore :: FilePath -> STMFileStore -> IO (StoreLog 'WriteMode)
readWriteFileStore = readWriteStoreLog readFileStore writeFileStore
readFileStore :: FilePath -> FileStore -> IO ()
readFileStore :: FilePath -> STMFileStore -> IO ()
readFileStore f st = mapM_ (addFileLogRecord . LB.toStrict) . LB.lines =<< LB.readFile f
where
addFileLogRecord s = case strDecode s of
Left e -> B.putStrLn $ "Log parsing error (" <> B.pack e <> "): " <> B.take 100 s
Right lr ->
atomically (addToStore lr) >>= \case
addToStore lr >>= \case
Left e -> B.putStrLn $ "Log processing error (" <> bshow e <> "): " <> B.take 100 s
_ -> pure ()
addToStore = \case
AddFile sId file createdAt status -> addFile st sId file createdAt status
AddFile sId file createdAt status
| size file > 0 -> addFile st sId file createdAt status
| otherwise -> pure $ Left SIZE
PutFile qId path -> setFilePath st qId path
AddRecipients sId rcps -> runExceptT $ addRecipients sId rcps
DeleteFile sId -> deleteFile st sId
@@ -108,8 +112,8 @@ readFileStore f st = mapM_ (addFileLogRecord . LB.toStrict) . LB.lines =<< LB.re
AckFile rId -> ackFile st rId
addRecipients sId rcps = mapM_ (ExceptT . addRecipient st sId) rcps
writeFileStore :: StoreLog 'WriteMode -> FileStore -> IO ()
writeFileStore s FileStore {files, recipients} = do
writeFileStore :: StoreLog 'WriteMode -> STMFileStore -> IO ()
writeFileStore s STMFileStore {files, recipients} = do
allRcps <- readTVarIO recipients
readTVarIO files >>= mapM_ (logFile allRcps)
where
+3 -1
View File
@@ -46,6 +46,7 @@ import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers
import Simplex.Messaging.Protocol (XFTPServer)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$$>))
import System.FilePath ((</>))
type RcvFileId = ByteString -- Agent entity ID
@@ -65,7 +66,8 @@ data FileHeader = FileHeader
instance Encoding FileHeader where
smpEncode FileHeader {fileName, fileExtra} = smpEncode (fileName, fileExtra)
smpP = do
(fileName, fileExtra) <- smpP
fileName <- safeDecodeUtf8 <$> smpP
fileExtra <- safeDecodeUtf8 <$$> smpP
pure FileHeader {fileName, fileExtra}
type DBRcvFileId = Int64
+18 -8
View File
@@ -3158,18 +3158,28 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(userId, srv, _), _v, sessId
pure conn''
| otherwise = pure conn'
Right Nothing -> prohibited "msg: bad agent msg" >> ack
Left e@(AGENT A_DUPLICATE) -> do
Left e@(AGENT A_DUPLICATE {}) -> do
atomically $ incSMPServerStat c userId srv recvDuplicates
withStore' c (\db -> getLastMsg db connId srvMsgId) >>= \case
Just RcvMsg {internalId, msgMeta, msgBody = agentMsgBody, userAck}
| userAck -> ackDel internalId
| otherwise ->
liftEither (parse smpP (AGENT A_MESSAGE) agentMsgBody) >>= \case
AgentMessage _ (A_MSG body) -> do
logServer "<--" c srv rId $ "MSG <MSG>:" <> logSecret' srvMsgId
notify $ MSG msgMeta msgFlags body
pure ACKPending
_ -> ack
| otherwise -> do
attempts <- withStore' c $ \db -> incMsgRcvAttempts db connId internalId
AgentConfig {rcvExpireCount, rcvExpireInterval} <- asks config
let firstTs = snd $ recipient msgMeta
brokerTs = snd $ broker msgMeta
now <- liftIO getCurrentTime
if attempts >= rcvExpireCount && diffUTCTime now firstTs >= rcvExpireInterval
then do
notify $ ERR (AGENT $ A_DUPLICATE $ Just DroppedMsg {brokerTs, attempts})
ackDel internalId
else
liftEither (parse smpP (AGENT A_MESSAGE) agentMsgBody) >>= \case
AgentMessage _ (A_MSG body) -> do
logServer "<--" c srv rId $ "MSG <MSG>:" <> logSecret' srvMsgId
notify $ MSG msgMeta msgFlags body
pure ACKPending
_ -> ack
_ -> checkDuplicateHash e encryptedMsgHash >> ack
Left (AGENT (A_CRYPTO e)) -> do
atomically $ incSMPServerStat c userId srv recvCryptoErrs
+1 -1
View File
@@ -2105,7 +2105,7 @@ cryptoError :: C.CryptoError -> AgentErrorType
cryptoError = \case
C.CryptoLargeMsgError -> CMD LARGE "CryptoLargeMsgError"
C.CryptoHeaderError _ -> AGENT A_MESSAGE -- parsing error
C.CERatchetDuplicateMessage -> AGENT A_DUPLICATE
C.CERatchetDuplicateMessage -> AGENT $ A_DUPLICATE Nothing
C.AESDecryptError -> c DECRYPT_AES
C.CBDecryptError -> c DECRYPT_CB
C.CERatchetHeader -> c RATCHET_HEADER
@@ -172,6 +172,8 @@ data AgentConfig = AgentConfig
caCertificateFile :: FilePath,
privateKeyFile :: FilePath,
certificateFile :: FilePath,
rcvExpireCount :: Int,
rcvExpireInterval :: NominalDiffTime,
e2eEncryptVRange :: VersionRangeE2E,
smpAgentVRange :: VersionRangeSMPA,
smpClientVRange :: VersionRangeSMPC
@@ -247,6 +249,8 @@ defaultAgentConfig =
caCertificateFile = "/etc/opt/simplex-agent/ca.crt",
privateKeyFile = "/etc/opt/simplex-agent/agent.key",
certificateFile = "/etc/opt/simplex-agent/agent.crt",
rcvExpireCount = 8,
rcvExpireInterval = nominalDay,
e2eEncryptVRange = supportedE2EEncryptVRange,
smpAgentVRange = supportedSMPAgentVRange,
smpClientVRange = supportedSMPClientVRange
+14 -4
View File
@@ -146,6 +146,7 @@ module Simplex.Messaging.Agent.Protocol
ConnectionErrorType (..),
BrokerErrorType (..),
SMPAgentError (..),
DroppedMsg (..),
AgentCryptoError (..),
cryptoErrToSyncState,
ATransmission,
@@ -788,6 +789,12 @@ data MsgMeta = MsgMeta
}
deriving (Eq, Show)
data DroppedMsg = DroppedMsg
{ brokerTs :: UTCTime,
attempts :: Int
}
deriving (Eq, Show)
data SMPConfirmation = SMPConfirmation
{ -- | sender's public key to use for authentication of sender's commands at the recepient's server
senderKey :: Maybe SndPublicAuthKey,
@@ -2050,12 +2057,13 @@ data SMPAgentError
A_LINK {linkErr :: String}
| -- | cannot decrypt message
A_CRYPTO {cryptoErr :: AgentCryptoError}
| -- | duplicate message - this error is detected by ratchet decryption - this message will be ignored and not shown
-- it may also indicate a loss of ratchet synchronization (when only one message is sent via copied ratchet)
A_DUPLICATE
| -- | duplicate message - this error is detected by ratchet decryption - this message will be ignored and not shown.
-- it may also indicate a loss of ratchet synchronization (when only one message is sent via copied ratchet).
-- when message is dropped after too many reception attempts, DroppedMsg is included.
A_DUPLICATE {droppedMsg_ :: Maybe DroppedMsg}
| -- | error in the message to add/delete/etc queue in connection
A_QUEUE {queueErr :: String}
deriving (Eq, Read, Show, Exception)
deriving (Eq, Show, Exception)
data AgentCryptoError
= -- | AES decryption error
@@ -2165,6 +2173,8 @@ $(J.deriveJSON (sumTypeJSON id) ''ConnectionErrorType)
$(J.deriveJSON (sumTypeJSON id) ''AgentCryptoError)
$(J.deriveJSON defaultJSON ''DroppedMsg)
$(J.deriveJSON (sumTypeJSON id) ''SMPAgentError)
$(J.deriveJSON (sumTypeJSON id) ''AgentErrorType)
@@ -127,6 +127,7 @@ module Simplex.Messaging.Agent.Store.AgentStore
setMsgUserAck,
getRcvMsg,
getLastMsg,
incMsgRcvAttempts,
checkRcvMsgHashExists,
getRcvMsgBrokerTs,
deleteMsg,
@@ -1110,6 +1111,19 @@ toRcvMsg ((agentMsgId, internalTs, brokerId, brokerTs) :. (sndMsgId, integrity,
msgReceipt = MsgReceipt <$> rcptInternalId_ <*> rcptStatus_
in RcvMsg {internalId = InternalId agentMsgId, msgMeta, msgType, msgBody, internalHash, msgReceipt, userAck}
incMsgRcvAttempts :: DB.Connection -> ConnId -> InternalId -> IO Int
incMsgRcvAttempts db connId (InternalId msgId) =
fromOnly . head
<$> DB.query
db
[sql|
UPDATE rcv_messages
SET receive_attempts = receive_attempts + 1
WHERE conn_id = ? AND internal_id = ?
RETURNING receive_attempts
|]
(connId, msgId)
checkRcvMsgHashExists :: DB.Connection -> ConnId -> ByteString -> IO Bool
checkRcvMsgHashExists db connId hash =
maybeFirstRow' False fromOnlyBI $
@@ -11,6 +11,7 @@ import Simplex.Messaging.Agent.Store.Postgres.Migrations.M20250702_conn_invitati
import Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251009_queue_to_subscribe
import Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251010_client_notices
import Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251230_strict_tables
import Simplex.Messaging.Agent.Store.Postgres.Migrations.M20260410_receive_attempts
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -21,7 +22,8 @@ schemaMigrations =
("20250702_conn_invitations_remove_cascade_delete", m20250702_conn_invitations_remove_cascade_delete, Just down_m20250702_conn_invitations_remove_cascade_delete),
("20251009_queue_to_subscribe", m20251009_queue_to_subscribe, Just down_m20251009_queue_to_subscribe),
("20251010_client_notices", m20251010_client_notices, Just down_m20251010_client_notices),
("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables)
("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables),
("20260410_receive_attempts", m20260410_receive_attempts, Just down_m20260410_receive_attempts)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Messaging.Agent.Store.Postgres.Migrations.M20260410_receive_attempts where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260410_receive_attempts :: Text
m20260410_receive_attempts =
[r|
ALTER TABLE rcv_messages ADD COLUMN receive_attempts SMALLINT NOT NULL DEFAULT 0;
|]
down_m20260410_receive_attempts :: Text
down_m20260410_receive_attempts =
[r|
ALTER TABLE rcv_messages DROP COLUMN receive_attempts;
|]
@@ -47,6 +47,7 @@ import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20250702_conn_invitation
import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251009_queue_to_subscribe
import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251010_client_notices
import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251230_strict_tables
import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20260410_receive_attempts
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -93,7 +94,8 @@ schemaMigrations =
("m20250702_conn_invitations_remove_cascade_delete", m20250702_conn_invitations_remove_cascade_delete, Just down_m20250702_conn_invitations_remove_cascade_delete),
("m20251009_queue_to_subscribe", m20251009_queue_to_subscribe, Just down_m20251009_queue_to_subscribe),
("m20251010_client_notices", m20251010_client_notices, Just down_m20251010_client_notices),
("m20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables)
("m20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables),
("m20260410_receive_attempts", m20260410_receive_attempts, Just down_m20260410_receive_attempts)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Messaging.Agent.Store.SQLite.Migrations.M20260410_receive_attempts where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260410_receive_attempts :: Query
m20260410_receive_attempts =
[sql|
ALTER TABLE rcv_messages ADD COLUMN receive_attempts INTEGER NOT NULL DEFAULT 0;
|]
down_m20260410_receive_attempts :: Query
down_m20260410_receive_attempts =
[sql|
ALTER TABLE rcv_messages DROP COLUMN receive_attempts;
|]
@@ -119,6 +119,7 @@ CREATE TABLE rcv_messages(
integrity BLOB NOT NULL,
user_ack INTEGER NULL DEFAULT 0,
rcv_queue_id INTEGER CHECK(rcv_queue_id NOT NULL),
receive_attempts INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(conn_id, internal_rcv_id),
FOREIGN KEY(conn_id, internal_id) REFERENCES messages
ON DELETE CASCADE
+21 -6
View File
@@ -7,6 +7,8 @@ module Simplex.Messaging.Compression
compressionLevel,
compress1,
decompress1,
limitDecompress1,
decompressedSize,
) where
import qualified Codec.Compression.Zstd as Z1
@@ -42,12 +44,25 @@ compress1 bs
| B.length bs <= maxLengthPassthrough = Passthrough bs
| otherwise = Compressed . Large $ Z1.compress compressionLevel bs
decompress1 :: Int -> Compressed -> Either String ByteString
decompress1 limit = \case
decompressedSize :: Compressed -> Maybe Int
decompressedSize = \case
Passthrough bs -> Just $ B.length bs
Compressed (Large bs) -> Z1.decompressedSize bs
decompress1 :: Compressed -> Either String ByteString
decompress1 = \case
Passthrough bs -> Right bs
Compressed (Large bs) -> decompress_ bs
limitDecompress1 :: Int -> Compressed -> Either String ByteString
limitDecompress1 limit = \case
Passthrough bs -> Right bs
Compressed (Large bs) -> case Z1.decompressedSize bs of
Just sz | sz <= limit -> case Z1.decompress bs of
Z1.Error e -> Left e
Z1.Skip -> Right mempty
Z1.Decompress bs' -> Right bs'
Just sz | sz <= limit -> decompress_ bs
_ -> Left $ "compressed size not specified or exceeds " <> show limit
decompress_ :: ByteString -> Either String ByteString
decompress_ bs = case Z1.decompress bs of
Z1.Error e -> Left e
Z1.Skip -> Right mempty
Z1.Decompress bs' -> Right bs'
+9 -4
View File
@@ -230,7 +230,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bifunctor (bimap, first)
import Data.ByteArray (ByteArrayAccess)
import qualified Data.ByteArray as BA
import Data.ByteString.Base64 (decode, encode)
import Data.ByteString.Base64 (decode)
import qualified Data.ByteString.Base64.URL as U
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
@@ -805,17 +805,22 @@ data ASignature
deriving instance Show ASignature
class CryptoSignature s where
serializeSignature :: s -> ByteString
serializeSignature = encode . signatureBytes
signatureBytes :: s -> ByteString
decodeSignature :: ByteString -> Either String s
instance CryptoSignature (Signature s) => StrEncoding (Signature s) where
strEncode = serializeSignature
strEncode = strEncode . signatureBytes
{-# INLINE strEncode #-}
strDecode = decodeSignature
{-# INLINE strDecode #-}
instance CryptoSignature (Signature s) => ToJSON (Signature s) where
toJSON = strToJSON
toEncoding = strToJEncoding
instance CryptoSignature (Signature s) => FromJSON (Signature s) where
parseJSON = strParseJSON "Signature"
instance CryptoSignature (Signature s) => Encoding (Signature s) where
smpEncode = smpEncode . signatureBytes
{-# INLINE smpEncode #-}
@@ -165,50 +165,50 @@ ntfServerCLI cfgPath logPath =
\# This option enables saving memory to append only log,\n\
\# and restoring it when the server is started.\n\
\# Log is compacted on start (deleted objects are removed).\n"
<> ("enable: " <> onOff enableStoreLog <> "\n\n")
<> ("enable = " <> onOff enableStoreLog <> "\n\n")
<> "# Database connection settings for PostgreSQL database.\n"
<> iniDbOpts dbOptions defaultNtfDBOpts
<> "Time to retain deleted entities in the database, days.\n"
<> ("# db_deleted_ttl: " <> tshow defaultDeletedTTL <> "\n\n")
<> "log_stats: off\n\n\
<> "# Time to retain deleted entities in the database, days.\n"
<> ("# db_deleted_ttl = " <> tshow defaultDeletedTTL <> "\n\n")
<> "log_stats = off\n\n\
\# Log interval for real-time Prometheus metrics\n\
\# prometheus_interval: 60\n\
\# prometheus_interval = 60\n\
\\n\
\[AUTH]\n\
\# control_port_admin_password:\n\
\# control_port_user_password:\n\
\# control_port_admin_password =\n\
\# control_port_user_password =\n\
\\n\
\[TRANSPORT]\n\
\# Host is only used to print server address on start.\n\
\# You can specify multiple server ports.\n"
<> ("host: " <> T.pack host <> "\n")
<> ("port: " <> T.pack defaultServerPort <> "\n")
<> "log_tls_errors: off\n\n\
\# Use `websockets: 443` to run websockets server in addition to plain TLS.\n\
\websockets: off\n\n\
\# control_port: 5227\n\
<> ("host = " <> T.pack host <> "\n")
<> ("port = " <> T.pack defaultServerPort <> "\n")
<> "log_tls_errors = off\n\n\
\# Use `websockets = 443` to run websockets server in addition to plain TLS.\n\
\websockets = off\n\n\
\# control_port = 5227\n\
\\n\
\[SUBSCRIBER]\n\
\# Network configuration for notification server client.\n\
\# `host_mode` can be 'public' (default) or 'onion'.\n\
\# It defines prefferred hostname for destination servers with multiple hostnames.\n\
\# host_mode: public\n\
\# required_host_mode: off\n\n\
\# host_mode = public\n\
\# required_host_mode = off\n\n\
\# SOCKS proxy port for subscribing to SMP servers.\n\
\# You may need a separate instance of SOCKS proxy for incoming single-hop requests.\n\
\# socks_proxy: localhost:9050\n\n\
\# socks_proxy = localhost:9050\n\n\
\# `socks_mode` can be 'onion' for SOCKS proxy to be used for .onion destination hosts only (default)\n\
\# or 'always' to be used for all destination hosts (can be used if it is an .onion server).\n\
\# socks_mode: onion\n\n\
\# socks_mode = onion\n\n\
\# The domain suffixes of the relays you operate (space-separated) to count as separate proxy statistics.\n\
\# own_server_domains: \n\n\
\# own_server_domains = \n\n\
\# User service subscriptions with server certificate\n\n\
\# use_service_credentials: off\n\n\
\# use_service_credentials = off\n\n\
\[INACTIVE_CLIENTS]\n\
\# TTL and interval to check inactive clients\n\
\disconnect: off\n"
<> ("# ttl: " <> tshow (ttl defaultInactiveClientExpiration) <> "\n")
<> ("# check_interval: " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n")
\disconnect = off\n"
<> ("# ttl = " <> tshow (ttl defaultInactiveClientExpiration) <> "\n")
<> ("# check_interval = " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n")
enableStoreLog' = settingIsOn "STORE_LOG" "enable"
runServer startOptions ini = do
setLogLevel $ logLevel startOptions
+6 -2
View File
@@ -40,6 +40,7 @@ module Simplex.Messaging.Server
dummyVerifyCmd,
randomId,
AttachHTTP,
WSHandler,
MessageStats (..),
)
where
@@ -121,6 +122,7 @@ import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Buffer (trimCR)
import Simplex.Messaging.Transport.Server
import Simplex.Messaging.Transport.WebSockets (WS (..))
import Simplex.Messaging.Util
import Simplex.Messaging.Version
import System.Environment (lookupEnv)
@@ -160,7 +162,8 @@ runSMPServerBlocking :: MsgStoreClass s => TMVar Bool -> ServerConfig s -> Maybe
runSMPServerBlocking started cfg attachHTTP_ = newEnv cfg >>= runReaderT (smpServer started cfg attachHTTP_)
type M s a = ReaderT (Env s) IO a
type AttachHTTP = Socket -> TLS.Context -> IO ()
type AttachHTTP = Socket -> TLS 'TServer -> Maybe WSHandler -> IO ()
type WSHandler = WS 'TServer -> IO ()
-- actions used in serverThread to reduce STM transaction scope
data ClientSubAction
@@ -211,10 +214,11 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt
(Just httpCreds, Just attachHTTP) | addHTTP ->
runTransportServerState_ ss started tcpPort defaultSupportedParamsHTTPS combinedCreds tCfg $ \s (sniUsed, h) ->
case cast h of
Just (TLS {tlsContext} :: TLS 'TServer) | sniUsed -> labelMyThread "https client" >> attachHTTP s tlsContext
Just (tls :: TLS 'TServer) | sniUsed -> labelMyThread "https client" >> attachHTTP s tls (Just wsHandler)
_ -> runClient srvCert srvSignKey t h `runReaderT` env
where
combinedCreds = TLSServerCredential {credential = smpCreds, sniCredential = Just httpCreds}
wsHandler ws = runClient srvCert srvSignKey (TProxy :: TProxy WS 'TServer) ws `runReaderT` env
_ ->
runTransportServerState ss started tcpPort defaultSupportedParams smpCreds tCfg $ \h -> runClient srvCert srvSignKey t h `runReaderT` env
+7 -6
View File
@@ -27,6 +27,7 @@ module Simplex.Messaging.Server.CLI
certOptionsP,
dbOptsP,
startOptionsP,
parseConfirmMigrations,
parseLogLevel,
genOnline,
warnCAPrivateKeyFile,
@@ -288,12 +289,12 @@ startOptionsP = do
<> value MCConsole
)
pure StartOptions {maintenance, compactLog, logLevel, skipWarnings, confirmMigrations}
where
parseConfirmMigrations :: ReadM MigrationConfirmation
parseConfirmMigrations = eitherReader $ \case
"up" -> Right MCYesUp
"down" -> Right MCYesUpDown
_ -> Left "invalid migration confirmation, pass 'up' or 'down'"
parseConfirmMigrations :: ReadM MigrationConfirmation
parseConfirmMigrations = eitherReader $ \case
"up" -> Right MCYesUp
"down" -> Right MCYesUpDown
_ -> Left "invalid migration confirmation, pass 'up' or 'down'"
parseLogLevel :: ReadM LogLevel
parseLogLevel = eitherReader $ \case
+3 -3
View File
@@ -106,7 +106,7 @@ import System.Directory (renameFile)
#endif
smpServerCLI :: FilePath -> FilePath -> IO ()
smpServerCLI = smpServerCLI_ (\_ _ _ -> pure ()) (\_ -> pure ()) (\_ -> error "attachStaticFiles not available")
smpServerCLI = smpServerCLI_ (\_ _ _ -> pure ()) (\_ -> pure ()) (\_ -> error "attachStaticAndWS not available")
smpServerCLI_ ::
(ServerInformation -> Maybe TransportHost -> FilePath -> IO ()) ->
@@ -115,7 +115,7 @@ smpServerCLI_ ::
FilePath ->
FilePath ->
IO ()
smpServerCLI_ generateSite serveStaticFiles attachStaticFiles cfgPath logPath =
smpServerCLI_ generateSite serveStaticFiles attachStaticAndWS cfgPath logPath =
getCliCommand' (cliCommandP cfgPath logPath iniFile) serverVersion >>= \case
Init opts ->
doesFileExist iniFile >>= \case
@@ -489,7 +489,7 @@ smpServerCLI_ generateSite serveStaticFiles attachStaticFiles cfgPath logPath =
case webStaticPath' of
Just path | sharedHTTP -> do
runWebServer path Nothing ServerInformation {config, information}
attachStaticFiles path $ \attachHTTP -> do
attachStaticAndWS path $ \attachHTTP -> do
logDebug "Allocated web server resources"
runSMPServer cfg (Just attachHTTP) `finally` logDebug "Releasing web server resources..."
Just path -> do
+59 -59
View File
@@ -78,101 +78,101 @@ iniFileContent cfgPath logPath opts host basicAuth controlPortPwds =
informationIniContent opts
<> "[STORE_LOG]\n\
\# The server uses memory or PostgreSQL database for persisting queue records.\n\
\# Use `enable: on` to use append-only log to preserve and restore queue records on restart.\n\
\# Use `enable = on` to use append-only log to preserve and restore queue records on restart.\n\
\# Log is compacted on start (deleted objects are removed).\n"
<> ("enable: " <> onOff enableStoreLog <> "\n\n")
<> ("enable = " <> onOff enableStoreLog <> "\n\n")
<> "# Queue storage mode: `memory` or `database` (to store queue records in PostgreSQL database).\n\
\# `memory` - in-memory persistence, with optional append-only log (`enable: on`).\n\
\# `database`- PostgreSQL databass (requires `store_messages: journal`).\n\
\store_queues: memory\n\n\
\# Database connection settings for PostgreSQL database (`store_queues: database`).\n"
\# `memory` - in-memory persistence, with optional append-only log (`enable = on`).\n\
\# `database`- PostgreSQL databass (requires `store_messages = journal`).\n\
\store_queues = memory\n\n\
\# Database connection settings for PostgreSQL database (`store_queues = database`).\n"
<> iniDbOpts dbOptions defaultDBOpts
<> "# Write database changes to store log file\n\
\# db_store_log: off\n\n\
\# db_store_log = off\n\n\
\# Time to retain deleted queues in the database, days.\n"
<> ("# db_deleted_ttl: " <> tshow defaultDeletedTTL <> "\n\n")
<> ("# db_deleted_ttl = " <> tshow defaultDeletedTTL <> "\n\n")
<> "# Message storage mode: `memory` or `journal`.\n\
\store_messages: memory\n\n\
\store_messages = memory\n\n\
\# When store_messages is `memory`, undelivered messages are optionally saved and restored\n\
\# when the server restarts, they are preserved in the .bak file until the next restart.\n"
<> ("restore_messages: " <> onOff enableStoreLog <> "\n\n")
<> ("restore_messages = " <> onOff enableStoreLog <> "\n\n")
<> "# Messages and notifications expiration periods.\n"
<> ("expire_messages_days: " <> tshow defMsgExpirationDays <> "\n")
<> "expire_messages_on_start: on\n\
\expire_messages_on_send: off\n"
<> ("expire_ntfs_hours: " <> tshow defNtfExpirationHours <> "\n\n")
<> ("expire_messages_days = " <> tshow defMsgExpirationDays <> "\n")
<> "expire_messages_on_start = on\n\
\expire_messages_on_send = off\n"
<> ("expire_ntfs_hours = " <> tshow defNtfExpirationHours <> "\n\n")
<> "# Log daily server statistics to CSV file\n"
<> ("log_stats: " <> onOff logStats <> "\n\n")
<> ("log_stats = " <> onOff logStats <> "\n\n")
<> "# Log interval for real-time Prometheus metrics\n\
\# prometheus_interval: 60\n\n\
\# prometheus_interval = 60\n\n\
\[AUTH]\n\
\# Set new_queues option to off to completely prohibit creating new messaging queues.\n\
\# This can be useful when you want to decommission the server, but not all connections are switched yet.\n\
\new_queues: on\n\n\
\new_queues = on\n\n\
\# Use create_password option to enable basic auth to create new messaging queues.\n\
\# The password should be used as part of server address in client configuration:\n\
\# smp://fingerprint:password@host1,host2\n\
\# The password will not be shared with the connecting contacts, you must share it only\n\
\# with the users who you want to allow creating messaging queues on your server.\n"
<> ( let noPassword = "password to create new queues and forward messages (any printable ASCII characters without whitespace, '@', ':' and '/')"
in optDisabled basicAuth <> "create_password: " <> maybe noPassword (safeDecodeUtf8 . strEncode) basicAuth
in optDisabled basicAuth <> "create_password = " <> maybe noPassword (safeDecodeUtf8 . strEncode) basicAuth
)
<> "\n\n"
<> (optDisabled controlPortPwds <> "control_port_admin_password: " <> maybe "" fst controlPortPwds <> "\n")
<> (optDisabled controlPortPwds <> "control_port_user_password: " <> maybe "" snd controlPortPwds <> "\n\n")
<> (optDisabled controlPortPwds <> "control_port_admin_password = " <> maybe "" fst controlPortPwds <> "\n")
<> (optDisabled controlPortPwds <> "control_port_user_password = " <> maybe "" snd controlPortPwds <> "\n\n")
<> "# The limit for queues that can be blocked via control port per day, resets at 0:00 UTC.\n\
\# Set to 0 to disable limit, to -1 to prohibit blocking. Default is 20.\n\
\# daily_block_queue_quota: 20\n\
\# daily_block_queue_quota = 20\n\
\\n\
\[TRANSPORT]\n\
\# Host is only used to print server address on start.\n\
\# You can specify multiple server ports.\n"
<> ("host: " <> T.pack host <> "\n")
<> ("port: " <> defaultServerPorts <> "\n")
<> "log_tls_errors: off\n\n\
\# Use `websockets: 443` to run websockets server in addition to plain TLS.\n\
<> ("host = " <> T.pack host <> "\n")
<> ("port = " <> defaultServerPorts <> "\n")
<> "log_tls_errors = off\n\n\
\# Use `websockets = 443` to run websockets server in addition to plain TLS.\n\
\# This option is deprecated and should be used for testing only.\n\
\# , port 443 should be specified in port above\n\
\websockets: off\n"
<> (optDisabled controlPort <> "control_port: " <> tshow (fromMaybe defaultControlPort controlPort))
\websockets = off\n"
<> (optDisabled controlPort <> "control_port = " <> tshow (fromMaybe defaultControlPort controlPort))
<> "\n\n\
\[PROXY]\n\
\# Network configuration for SMP proxy client.\n\
\# `host_mode` can be 'public' (default) or 'onion'.\n\
\# It defines prefferred hostname for destination servers with multiple hostnames.\n\
\# host_mode: public\n\
\# required_host_mode: off\n\n\
\# host_mode = public\n\
\# required_host_mode = off\n\n\
\# The domain suffixes of the relays you operate (space-separated) to count as separate proxy statistics.\n"
<> (optDisabled ownDomains <> "own_server_domains: " <> maybe "" (safeDecodeUtf8 . strEncode) ownDomains)
<> (optDisabled ownDomains <> "own_server_domains = " <> maybe "" (safeDecodeUtf8 . strEncode) ownDomains)
<> "\n\n\
\# SOCKS proxy port for forwarding messages to destination servers.\n\
\# You may need a separate instance of SOCKS proxy for incoming single-hop requests.\n"
<> (optDisabled socksProxy <> "socks_proxy: " <> maybe "localhost:9050" (safeDecodeUtf8 . strEncode) socksProxy)
<> (optDisabled socksProxy <> "socks_proxy = " <> maybe "localhost:9050" (safeDecodeUtf8 . strEncode) socksProxy)
<> "\n\n\
\# `socks_mode` can be 'onion' for SOCKS proxy to be used for .onion destination hosts only (default)\n\
\# or 'always' to be used for all destination hosts (can be used if it is an .onion server).\n\
\# socks_mode: onion\n\n\
\# socks_mode = onion\n\n\
\# Limit number of threads a client can spawn to process proxy commands in parrallel.\n"
<> ("# client_concurrency: " <> tshow defaultProxyClientConcurrency)
<> ("# client_concurrency = " <> tshow defaultProxyClientConcurrency)
<> "\n\n\
\[INACTIVE_CLIENTS]\n\
\# TTL and interval to check inactive clients\n\
\disconnect: on\n"
<> ("ttl: " <> tshow (ttl defaultInactiveClientExpiration) <> "\n")
<> ("check_interval: " <> tshow (checkInterval defaultInactiveClientExpiration))
\disconnect = on\n"
<> ("ttl = " <> tshow (ttl defaultInactiveClientExpiration) <> "\n")
<> ("check_interval = " <> tshow (checkInterval defaultInactiveClientExpiration))
<> "\n\n\
\[WEB]\n\
\# Set path to generate static mini-site for server information and qr codes/links\n"
<> ("static_path: " <> T.pack (fromMaybe defaultStaticPath webStaticPath) <> "\n\n")
<> ("static_path = " <> T.pack (fromMaybe defaultStaticPath webStaticPath) <> "\n\n")
<> "# Run an embedded server on this port\n\
\# Onion sites can use any port and register it in the hidden service config.\n\
\# Running on a port 80 may require setting process capabilities.\n\
\# http: 8000\n\n\
\# http = 8000\n\n\
\# You can run an embedded TLS web server too if you provide port and cert and key files.\n\
\# Not required for running relay on onion address.\n"
<> (webDisabled <> "https: 443\n")
<> (webDisabled <> "cert: " <> T.pack httpsCertFile <> "\n")
<> (webDisabled <> "key: " <> T.pack httpsKeyFile <> "\n")
<> (webDisabled <> "https = 443\n")
<> (webDisabled <> "cert = " <> T.pack httpsCertFile <> "\n")
<> (webDisabled <> "key = " <> T.pack httpsKeyFile <> "\n")
where
InitOptions {enableStoreLog, dbOptions, socksProxy, ownDomains, controlPort, webStaticPath, disableWeb, logStats} = opts
defaultServerPorts = "5223,443"
@@ -189,53 +189,53 @@ informationIniContent InitOptions {sourceCode, serverInfo} =
\# LICENSE: https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE\n\
\# Include correct source code URI in case the server source code is modified in any way.\n\
\# If any other information fields are present, source code property also MUST be present.\n\n"
<> (optDisabled sourceCode <> "source_code: " <> fromMaybe "URI" sourceCode)
<> (optDisabled sourceCode <> "source_code = " <> fromMaybe "URI" sourceCode)
<> "\n\n\
\# Declaring all below information is optional, any of these fields can be omitted.\n\
\\n\
\# Server usage conditions and amendments.\n\
\# It is recommended to use standard conditions with any amendments in a separate document.\n\
\# usage_conditions: https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md\n\
\# condition_amendments: link\n\
\# usage_conditions = https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md\n\
\# condition_amendments = link\n\
\\n\
\# Server location and operator.\n"
<> countryStr "server" serverCountry
<> enitiyStrs "operator" operator
<> (optDisabled website <> "website: " <> fromMaybe "" website)
<> (optDisabled website <> "website = " <> fromMaybe "" website)
<> "\n\n\
\# Administrative contacts.\n\
\# admin_simplex: SimpleX address\n\
\# admin_email:\n\
\# admin_pgp:\n\
\# admin_pgp_fingerprint:\n\
\# admin_simplex = SimpleX address\n\
\# admin_email =\n\
\# admin_pgp =\n\
\# admin_pgp_fingerprint =\n\
\\n\
\# Contacts for complaints and feedback.\n\
\# complaints_simplex: SimpleX address\n\
\# complaints_email:\n\
\# complaints_pgp:\n\
\# complaints_pgp_fingerprint:\n\
\# complaints_simplex = SimpleX address\n\
\# complaints_email =\n\
\# complaints_pgp =\n\
\# complaints_pgp_fingerprint =\n\
\\n\
\# Hosting provider.\n"
<> enitiyStrs "hosting" hosting
<> "\n\
\# Hosting type can be `virtual`, `dedicated`, `colocation`, `owned`\n"
<> ("hosting_type: " <> maybe "virtual" (decodeLatin1 . strEncode) hostingType <> "\n\n")
<> ("hosting_type = " <> maybe "virtual" (decodeLatin1 . strEncode) hostingType <> "\n\n")
where
ServerPublicInfo {operator, website, hosting, hostingType, serverCountry} = serverInfo
countryStr optName country = optDisabled country <> optName <> "_country: " <> fromMaybe "ISO-3166 2-letter code" country <> "\n"
countryStr optName country = optDisabled country <> optName <> "_country = " <> fromMaybe "ISO-3166 2-letter code" country <> "\n"
enitiyStrs optName entity =
optDisabled entity
<> optName
<> ": "
<> " = "
<> maybe "entity (organization or person name)" name entity
<> "\n"
<> countryStr optName (country =<< entity)
iniDbOpts :: DBOpts -> DBOpts -> Text
iniDbOpts DBOpts {connstr, schema, poolSize} DBOpts {connstr = defConnstr, schema = defSchema, poolSize = defPoolSize} =
(optDisabled' (connstr == defConnstr) <> "db_connection: " <> safeDecodeUtf8 connstr <> "\n")
<> (optDisabled' (schema == defSchema) <> "db_schema: " <> safeDecodeUtf8 schema <> "\n")
<> (optDisabled' (poolSize == defPoolSize) <> "db_pool_size: " <> tshow poolSize <> "\n\n")
(optDisabled' (connstr == defConnstr) <> "db_connection = " <> safeDecodeUtf8 connstr <> "\n")
<> (optDisabled' (schema == defSchema) <> "db_schema = " <> safeDecodeUtf8 schema <> "\n")
<> (optDisabled' (poolSize == defPoolSize) <> "db_pool_size = " <> tshow poolSize <> "\n\n")
optDisabled :: Maybe a -> Text
optDisabled = optDisabled' . isNothing
+19 -13
View File
@@ -1,3 +1,4 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -8,7 +9,7 @@ module Simplex.Messaging.Server.Web
WebHttpsParams (..),
EmbeddedContent (..),
serveStaticFiles,
attachStaticFiles,
attachStaticAndWS,
serveStaticPageH2,
generateSite,
serverInfoSubsts,
@@ -41,11 +42,14 @@ 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 qualified Network.Wai.Handler.WebSockets as WaiWS
import Network.WebSockets (defaultConnectionOptions, ConnectionOptions(..), SizeLimit(..), PendingConnection)
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Server (AttachHTTP)
import Simplex.Messaging.Server (AttachHTTP, WSHandler)
import Simplex.Messaging.Server.CLI (simplexmqCommit)
import Simplex.Messaging.Server.Information
import Simplex.Messaging.Transport (simplexMQVersion)
import Simplex.Messaging.Transport (TLS (..), smpBlockSize, simplexMQVersion)
import Simplex.Messaging.Transport.WebSockets (WS (..), acceptWSConnection)
import Simplex.Messaging.Util (tshow)
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesFileExist)
import System.FilePath
@@ -84,20 +88,23 @@ serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams}
where
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 = do
app <- staticFiles path
-- Initialize global internal state for http server.
attachStaticAndWS :: FilePath -> (AttachHTTP -> IO a) -> IO a
attachStaticAndWS path action =
WI.withII warpSettings $ \ii -> do
action $ \socket cxt -> do
-- Initialize internal per-connection resources.
action $ \socket tls wsHandler_ -> do
app <- case wsHandler_ of
Just wsHandler ->
WaiWS.websocketsOr wsOpts (acceptWSConnection tls >=> wsHandler) <$> staticFiles path
Nothing -> staticFiles path
addr <- getPeerName socket
withConnection addr cxt $ \(conn, transport) ->
withConnection addr (tlsContext tls) $ \(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
wsOpts = defaultConnectionOptions
{ connectionFramePayloadSizeLimit = SizeLimit $ fromIntegral smpBlockSize,
connectionMessageDataSizeLimit = SizeLimit 65536
}
-- from warp-tls
withConnection socket cxt = bracket (WT.attachConn socket cxt) (terminate . fst)
-- from warp
@@ -105,7 +112,6 @@ attachStaticFiles path action = do
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
+36 -11
View File
@@ -56,6 +56,7 @@ module Simplex.Messaging.Transport
serviceCertsSMPVersion,
newNtfCredsSMPVersion,
clientNoticesSMPVersion,
webClientSMPVersion,
simplexMQVersion,
smpBlockSize,
TransportConfig (..),
@@ -140,7 +141,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, parseRead1, sumTypeJSON)
import Simplex.Messaging.Transport.Buffer
import Simplex.Messaging.Transport.Shared
import Simplex.Messaging.Util (bshow, catchAll, catchAll_, liftEitherWith)
import Simplex.Messaging.Util (bshow, catchAll, catchAll_, liftEitherWith, (<$?>))
import Simplex.Messaging.Version
import Simplex.Messaging.Version.Internal
import System.IO.Error (isEOFError)
@@ -218,6 +219,9 @@ newNtfCredsSMPVersion = VersionSMP 17
clientNoticesSMPVersion :: VersionSMP
clientNoticesSMPVersion = VersionSMP 18
webClientSMPVersion :: VersionSMP
webClientSMPVersion = VersionSMP 19
minClientSMPRelayVersion :: VersionSMP
minClientSMPRelayVersion = VersionSMP 6
@@ -225,13 +229,13 @@ minServerSMPRelayVersion :: VersionSMP
minServerSMPRelayVersion = VersionSMP 6
currentClientSMPRelayVersion :: VersionSMP
currentClientSMPRelayVersion = VersionSMP 18
currentClientSMPRelayVersion = VersionSMP 19
legacyServerSMPRelayVersion :: VersionSMP
legacyServerSMPRelayVersion = VersionSMP 6
currentServerSMPRelayVersion :: VersionSMP
currentServerSMPRelayVersion = VersionSMP 18
currentServerSMPRelayVersion = VersionSMP 19
-- Max SMP protocol version to be used in e2e encrypted
-- connection between client and server, as defined by SMP proxy.
@@ -296,6 +300,10 @@ class Typeable c => Transport (c :: TransportPeer -> Type) where
-- | ALPN value negotiated for the session
getSessionALPN :: c p -> Maybe ALPN
-- | Web client challenge for server identity verification (WebSocket only)
getWebChallenge :: c p -> Maybe ByteString
getWebChallenge _ = Nothing
-- | Close connection
closeConnection :: c p -> IO ()
@@ -537,7 +545,9 @@ data SMPServerHandshake = SMPServerHandshake
sessionId :: SessionId,
-- pub key to agree shared secrets for command authorization and entity ID encryption.
-- todo C.PublicKeyX25519
authPubKey :: Maybe CertChainPubKey
authPubKey :: Maybe CertChainPubKey,
-- | signed web client challenge for server identity verification (v19+)
webIdentityProof :: Maybe C.ASignature
}
-- This is the third handshake message that SMP server sends to services
@@ -629,15 +639,19 @@ ifHasService :: VersionSMP -> a -> a -> a
ifHasService v a b = if v >= serviceCertsSMPVersion then a else b
instance Encoding SMPServerHandshake where
smpEncode SMPServerHandshake {smpVersionRange, sessionId, authPubKey} =
smpEncode (smpVersionRange, sessionId) <> auth
smpEncode SMPServerHandshake {smpVersionRange, sessionId, authPubKey, webIdentityProof} =
smpEncode (smpVersionRange, sessionId) <> auth <> webProof
where
auth = encodeAuthEncryptCmds (maxVersion smpVersionRange) authPubKey
v = maxVersion smpVersionRange
auth = encodeAuthEncryptCmds v authPubKey
webProof = encodeWebIdentityProof v webIdentityProof
smpP = do
(smpVersionRange, sessionId) <- smpP
let v = maxVersion smpVersionRange
-- TODO drop SMP v6: remove special parser and make key non-optional
authPubKey <- authEncryptCmdsP (maxVersion smpVersionRange) smpP
pure SMPServerHandshake {smpVersionRange, sessionId, authPubKey}
authPubKey <- authEncryptCmdsP v smpP
webIdentityProof <- webIdentityProofP v
pure SMPServerHandshake {smpVersionRange, sessionId, authPubKey, webIdentityProof}
-- newtype for CertificateChain and a session key signed with this certificate
data CertChainPubKey = CertChainPubKey
@@ -661,6 +675,16 @@ encodeAuthEncryptCmds v k
authEncryptCmdsP :: VersionSMP -> Parser a -> Parser (Maybe a)
authEncryptCmdsP v p = if v >= authCmdsSMPVersion then optional p else pure Nothing
encodeWebIdentityProof :: VersionSMP -> Maybe C.ASignature -> ByteString
encodeWebIdentityProof v sig
| v >= webClientSMPVersion = maybe "" (smpEncode . C.signatureBytes) sig
| otherwise = ""
webIdentityProofP :: VersionSMP -> Parser (Maybe C.ASignature)
webIdentityProofP v
| v >= webClientSMPVersion = optional $ C.decodeSignature <$?> smpP
| otherwise = pure Nothing
instance Encoding SMPServerHandshakeResponse where
smpEncode = \case
SMPServerHandshakeResponse serviceId -> smpEncode ('R', serviceId)
@@ -758,7 +782,8 @@ smpServerHandshake ::
smpServerHandshake srvCert srvSignKey c (k, pk) kh smpVRange getService = do
let sk = C.signX509 srvSignKey $ C.publicToX509 k
smpVersionRange = maybe legacyServerSMPRelayVRange (const smpVRange) $ getSessionALPN c
sendHandshake th $ SMPServerHandshake {sessionId, smpVersionRange, authPubKey = Just (CertChainPubKey srvCert sk)}
webIdentityProof = C.sign srvSignKey . (<> sessionId) <$> getWebChallenge c
sendHandshake th $ SMPServerHandshake {sessionId, smpVersionRange, authPubKey = Just (CertChainPubKey srvCert sk), webIdentityProof}
SMPClientHandshake {smpVersion = v, keyHash, authPubKey = k', proxyServer, clientService} <- getHandshake th
when (keyHash /= kh) $ throwE $ TEHandshake IDENTITY
case compatibleVRange' smpVersionRange v of
@@ -791,7 +816,7 @@ smpServerHandshake srvCert srvSignKey c (k, pk) kh smpVRange getService = do
-- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a
smpClientHandshake :: forall c. Transport c => c 'TClient -> Maybe C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> Bool -> Maybe (ServiceCredentials, C.KeyPairEd25519) -> ExceptT TransportError IO (THandleSMP c 'TClient)
smpClientHandshake c ks_ keyHash@(C.KeyHash kh) vRange proxyServer serviceKeys_ = do
SMPServerHandshake {sessionId = sessId, smpVersionRange, authPubKey} <- getHandshake th
SMPServerHandshake {sessionId = sessId, smpVersionRange, authPubKey, webIdentityProof = _} <- getHandshake th
when (sessionId /= sessId) $ throwE TEBadSession
-- Below logic downgrades version range in case the "client" is SMP proxy server and it is
-- connected to the destination server of the version 11 or older.
+28 -3
View File
@@ -1,5 +1,6 @@
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
@@ -7,10 +8,11 @@
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.Messaging.Transport.WebSockets (WS (..)) where
module Simplex.Messaging.Transport.WebSockets (WS (..), acceptWSConnection) where
import qualified Control.Exception as E
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import qualified Data.X509 as X
@@ -20,6 +22,7 @@ import Network.WebSockets.Stream (Stream)
import qualified Network.WebSockets.Stream as S
import Simplex.Messaging.Transport
( ALPN,
TLS (TLS, tlsContext, tlsPeerCert, tlsTransportConfig),
Transport (..),
TransportConfig (..),
TransportError (..),
@@ -40,7 +43,8 @@ data WS (p :: TransportPeer) = WS
wsConnection :: Connection,
wsTransportConfig :: TransportConfig,
wsCertSent :: Bool,
wsPeerCert :: X.CertificateChain
wsPeerCert :: X.CertificateChain,
wsWebChallenge :: Maybe ByteString
}
websocketsOpts :: ConnectionOptions
@@ -64,6 +68,8 @@ instance Transport WS where
{-# INLINE getPeerCertChain #-}
getSessionALPN = wsALPN
{-# INLINE getSessionALPN #-}
getWebChallenge = wsWebChallenge
{-# INLINE getWebChallenge #-}
tlsUnique = tlsUniq
{-# INLINE tlsUnique #-}
closeConnection = S.close . wsStream
@@ -93,7 +99,7 @@ getWS cfg wsCertSent wsPeerCert cxt = withTlsUnique @WS @p cxt connectWS
s <- makeTLSContextStream cxt
wsConnection <- connectPeer s
wsALPN <- T.getNegotiatedProtocol cxt
pure $ WS {tlsUniq, wsALPN, wsStream = s, wsConnection, wsTransportConfig = cfg, wsCertSent, wsPeerCert}
pure $ WS {tlsUniq, wsALPN, wsStream = s, wsConnection, wsTransportConfig = cfg, wsCertSent, wsPeerCert, wsWebChallenge = Nothing}
connectPeer :: Stream -> IO Connection
connectPeer = case sTransportPeer @p of
STServer -> acceptClientRequest
@@ -101,6 +107,25 @@ getWS cfg wsCertSent wsPeerCert cxt = withTlsUnique @WS @p cxt connectWS
acceptClientRequest s = makePendingConnectionFromStream s websocketsOpts >>= acceptRequest
sendClientRequest s = newClientConnection s "" "/" websocketsOpts []
acceptWSConnection :: TLS 'TServer -> PendingConnection -> IO (WS 'TServer)
acceptWSConnection tls pending = withTlsUnique @WS @'TServer cxt $ \wsUniq -> do
wsStream <- makeTLSContextStream cxt
wsConnection <- acceptRequest pending
wsALPN <- T.getNegotiatedProtocol cxt
let wsWebChallenge = parseChallenge $ requestPath $ pendingRequest pending
pure WS {tlsUniq = wsUniq, wsALPN, wsStream, wsConnection, wsTransportConfig = tlsTransportConfig tls, wsCertSent = False, wsPeerCert = tlsPeerCert tls, wsWebChallenge}
where
cxt = tlsContext tls
-- Parse ?challenge=<base64url> from request path
parseChallenge path = case B.breakSubstring "challenge=" path of
(_, rest)
| B.null rest -> Nothing
| otherwise ->
let val = B.takeWhile (/= '&') $ B.drop 10 rest -- drop "challenge="
in case B64.decodeUnpadded val of
Right ch | B.length ch == 32 -> Just ch
_ -> Nothing
makeTLSContextStream :: T.Context -> IO Stream
makeTLSContextStream cxt =
S.makeStream readStream writeStream
+2 -2
View File
@@ -108,9 +108,9 @@ instance StrEncoding RCSignedInvitation where
mconcat
[ strEncode invitation,
"&ssig=",
strEncode $ C.signatureBytes ssig,
strEncode ssig,
"&idsig=",
strEncode $ C.signatureBytes idsig
strEncode idsig
]
strP = do
+24 -18
View File
@@ -73,15 +73,19 @@ runMessageTests ::
Bool ->
Spec
runMessageTests initRatchets_ agreeRatchetKEMs = do
it "should encrypt and decrypt messages" $ run $ testEncryptDecrypt agreeRatchetKEMs
it "should encrypt and decrypt skipped messages" $ run $ testSkippedMessages agreeRatchetKEMs
it "should encrypt and decrypt many messages" $ run $ testManyMessages agreeRatchetKEMs
it "should allow skipped after ratchet advance" $ run $ testSkippedAfterRatchetAdvance agreeRatchetKEMs
it "should encrypt and decrypt messages" $ run testEncryptDecrypt
it "should encrypt and decrypt skipped messages" $ run testSkippedMessages
it "should encrypt and decrypt many messages" $ run testManyMessages
it "should allow skipped after ratchet advance" $ run testSkippedAfterRatchetAdvance
where
run :: (forall a. (AlgorithmI a, DhAlgorithm a) => TestRatchets a) -> IO ()
run test = do
withRatchets_ @X25519 initRatchets_ test
withRatchets_ @X448 initRatchets_ test
withRatchets_ @X25519 initRatchets_ (withKEM test)
withRatchets_ @X448 initRatchets_ (withKEM test)
withKEM :: (AlgorithmI a, DhAlgorithm a) => TestRatchets a -> TestRatchets a
withKEM test alice bob encrypt decrypt (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
test alice bob encrypt decrypt (#>)
testAlgs :: (forall a. (AlgorithmI a, DhAlgorithm a) => C.SAlgorithm a -> IO ()) -> IO ()
testAlgs test = test C.SX25519 >> test C.SX448
@@ -146,6 +150,12 @@ type TestRatchets a =
EncryptDecryptSpec a ->
IO ()
-- Peer-polymorphic types for cross-language testing
type EncryptP p = p -> ByteString -> IO (Either CryptoError ByteString)
type DecryptP p = p -> ByteString -> IO (Either CryptoError (Either CryptoError ByteString))
type EncryptDecryptSpecP p = (p, ByteString) -> p -> Expectation
type TestRatchetsP p = p -> p -> EncryptP p -> DecryptP p -> EncryptDecryptSpecP p -> IO ()
deriving instance Eq (Ratchet a)
deriving instance Eq (SndRatchet a)
@@ -170,9 +180,8 @@ deriving instance Eq (MsgHeader a)
initRatchetKEM :: (AlgorithmI a, DhAlgorithm a) => TVar (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> TVar (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> IO ()
initRatchetKEM s r = encryptDecrypt (Just $ PQEncOn) (const ()) (const ()) (s, "initialising ratchet") r
testEncryptDecrypt :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testEncryptDecrypt agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testEncryptDecrypt :: TestRatchetsP p
testEncryptDecrypt alice bob encrypt decrypt (#>) = do
(bob, "hello alice") #> alice
(alice, "hello bob") #> bob
Right b1 <- encrypt bob "how are you, alice?"
@@ -191,9 +200,8 @@ testEncryptDecrypt agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
(alice, "I'm here too, same") #> bob
pure ()
testSkippedMessages :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testSkippedMessages agreeRatchetKEMs alice bob encrypt decrypt _ = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testSkippedMessages :: TestRatchetsP p
testSkippedMessages alice bob encrypt decrypt _ = do
Right msg1 <- encrypt bob "hello alice"
Right msg2 <- encrypt bob "hello there again"
Right msg3 <- encrypt bob "are you there?"
@@ -203,9 +211,8 @@ testSkippedMessages agreeRatchetKEMs alice bob encrypt decrypt _ = do
Decrypted "hello alice" <- decrypt alice msg1
pure ()
testManyMessages :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testManyMessages agreeRatchetKEMs alice bob _ _ (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testManyMessages :: TestRatchetsP p
testManyMessages alice bob _ _ (#>) = do
(bob, "b1") #> alice
(bob, "b2") #> alice
(bob, "b3") #> alice
@@ -222,9 +229,8 @@ testManyMessages agreeRatchetKEMs alice bob _ _ (#>) = do
(bob, "b15") #> alice
(bob, "b16") #> alice
testSkippedAfterRatchetAdvance :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testSkippedAfterRatchetAdvance agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testSkippedAfterRatchetAdvance :: TestRatchetsP p
testSkippedAfterRatchetAdvance alice bob encrypt decrypt (#>) = do
(bob, "b1") #> alice
Right b2 <- encrypt bob "b2"
Right b3 <- encrypt bob "b3"
+34 -1
View File
@@ -408,6 +408,7 @@ functionalAPITests ps = do
it "should expire multiple messages" $ testExpireManyMessages ps
it "should expire one message if quota is exceeded" $ testExpireMessageQuota ps
it "should expire multiple messages if quota is exceeded" $ testExpireManyMessagesQuota ps
it "should drop message after too many receive attempts" $ testDropMsgAfterRcvAttempts ps
#if !defined(dbPostgres)
-- TODO [postgres] restore from outdated db backup (we use copyFile/renameFile for sqlite)
describe "Ratchet synchronization" $ do
@@ -2101,6 +2102,38 @@ testExpireManyMessagesQuota (t, msType) = withSmpServerConfigOn t cfg' testPort
where
cfg' = updateCfg (cfgMS msType) $ \cfg_ -> cfg_ {msgQueueQuota = 1, maxJournalMsgCount = 2}
testDropMsgAfterRcvAttempts :: HasCallStack => (ASrvTransport, AStoreType) -> IO ()
testDropMsgAfterRcvAttempts ps =
withSmpServerStoreLogOn ps testPort $ \_ -> do
let rcvCfg = agentCfg {rcvExpireCount = 2, rcvExpireInterval = 1}
alice <- getSMPAgentClient' 1 agentCfg initAgentServers testDB
bob <- getSMPAgentClient' 2 rcvCfg initAgentServers testDB2
(aliceId, bobId) <- runRight $ makeConnection alice bob
-- alice sends, bob receives but does NOT ack
runRight_ $ do
2 <- sendMessage alice bobId SMP.noMsgFlags "hello"
get alice ##> ("", bobId, SENT 2)
get bob =##> \case ("", c, Msg "hello") -> c == aliceId; _ -> False
-- bob disconnects without acking
disposeAgentClient bob
threadDelay 500000
-- bob reconnects, agent sees duplicate, counter=1
bob2 <- getSMPAgentClient' 3 rcvCfg initAgentServers testDB2
runRight_ $ do
subscribeConnection bob2 aliceId
get bob2 =##> \case ("", c, Msg "hello") -> c == aliceId; _ -> False
-- bob disconnects again without acking
disposeAgentClient bob2
-- wait for rcvExpireInterval (1 second)
threadDelay 500000
-- bob reconnects, agent sees duplicate, counter=2, interval exceeded -> drops
bob3 <- getSMPAgentClient' 4 rcvCfg initAgentServers testDB2
runRight_ $ do
subscribeConnection bob3 aliceId
get bob3 =##> \case ("", c, ERR (AGENT (A_DUPLICATE (Just DroppedMsg {})))) -> c == aliceId; _ -> False
disposeAgentClient bob3
disposeAgentClient alice
testRatchetSync :: HasCallStack => (ASrvTransport, AStoreType) -> IO ()
testRatchetSync ps = withAgentClients2 $ \alice bob ->
withSmpServerStoreMsgLogOn ps testPort $ \_ -> do
@@ -3224,7 +3257,7 @@ phase c connId d p statsExpectation =
d `shouldBe` d'
p `shouldBe` p'
statsExpectation stats
ERR (AGENT A_DUPLICATE) -> phase c connId d p statsExpectation
ERR (AGENT A_DUPLICATE {}) -> phase c connId d p statsExpectation
r -> do
liftIO . putStrLn $ "expected: " <> show p <> ", received: " <> show r
SWITCH {} <- pure r
+2 -2
View File
@@ -31,7 +31,7 @@ import qualified Simplex.Messaging.Transport.HTTP2.Client as HC
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
import Simplex.Messaging.Util (catchAll_)
import qualified SMPWeb
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticFiles)
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticAndWS)
import System.Directory (doesFileExist)
import System.Environment (withArgs)
import System.FilePath ((</>))
@@ -152,7 +152,7 @@ smpServerTestStatic = do
Right ini_ <- readIniFile iniFile
lookupValue "WEB" "https" ini_ `shouldBe` Right "5223"
let smpServerCLI' = smpServerCLI_ SMPWeb.smpGenerateSite serveStaticFiles attachStaticFiles
let smpServerCLI' = smpServerCLI_ SMPWeb.smpGenerateSite serveStaticFiles attachStaticAndWS
let server = capture_ (withArgs ["start"] $ smpServerCLI' cfgPath logPath `catchAny` print)
bracket (async server) cancel $ \_t -> do
threadDelay 1000000
+274
View File
@@ -0,0 +1,274 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module CoreTests.XFTPStoreTests (xftpStoreTests, xftpMigrationTests) where
import Control.Monad
import Data.Word (Word32)
import Simplex.FileTransfer.Protocol (FileInfo (..), SFileParty (..))
import Simplex.FileTransfer.Server.Store
import Simplex.FileTransfer.Server.Store.Postgres (PostgresFileStore, importFileStore, exportFileStore)
import Simplex.FileTransfer.Server.StoreLog (closeStoreLog, readWriteFileStore, writeFileStore)
import Simplex.FileTransfer.Transport (XFTPErrorType (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), EntityId (..))
import Simplex.Messaging.Server.QueueStore (ServerEntityStatus (..))
import Simplex.Messaging.Server.StoreLog (openWriteStoreLog)
import Simplex.Messaging.SystemTime (RoundedSystemTime (..))
import System.Directory (doesFileExist, removeFile)
import Test.Hspec hiding (fit, it)
import UnliftIO.STM
import Util
import XFTPClient (testXFTPPostgresCfg)
xftpStoreTests :: Spec
xftpStoreTests = describe "PostgresFileStore operations" $ do
it "should add and get file by sender" testAddGetFileSender
it "should add and get file by recipient" testAddGetFileRecipient
it "should reject duplicate file" testDuplicateFile
it "should return AUTH for nonexistent file" testGetNonexistent
it "should set file path with IS NULL guard" testSetFilePath
it "should reject duplicate recipient" testDuplicateRecipient
it "should delete file and cascade recipients" testDeleteFileCascade
it "should block file and update status" testBlockFile
it "should ack file reception" testAckFile
it "should return expired files with limit" testExpiredFiles
it "should compute used storage and file count" testStorageAndCount
xftpMigrationTests :: Spec
xftpMigrationTests = describe "XFTP migration round-trip" $ do
it "should export to StoreLog and import back to Postgres preserving data" testMigrationRoundTrip
-- Test helpers
withPgStore :: (PostgresFileStore -> IO ()) -> IO ()
withPgStore test = do
st <- newFileStore testXFTPPostgresCfg :: IO PostgresFileStore
test st
closeFileStore st
testSenderId :: EntityId
testSenderId = EntityId "sender001_______"
testRecipientId :: EntityId
testRecipientId = EntityId "recipient001____"
testFileInfo :: C.APublicAuthKey -> FileInfo
testFileInfo sndKey =
FileInfo
{ sndKey,
size = 128000 :: Word32,
digest = "test_digest_bytes_here___"
}
testCreatedAt :: RoundedFileTime
testCreatedAt = RoundedSystemTime 1000000
-- Tests
testAddGetFileSender :: Expectation
testAddGetFileSender = withPgStore $ \st -> do
g <- C.newRandom
(sk, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sk
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
result <- getFile st SFSender testSenderId
case result of
Right (FileRec {senderId, fileInfo = fi, createdAt}, key) -> do
senderId `shouldBe` testSenderId
sndKey fi `shouldBe` sk
size fi `shouldBe` 128000
createdAt `shouldBe` testCreatedAt
key `shouldBe` sk
Left e -> expectationFailure $ "getFile failed: " <> show e
testAddGetFileRecipient :: Expectation
testAddGetFileRecipient = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(rcpKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
addRecipient st testSenderId (FileRecipient testRecipientId rcpKey) `shouldReturn` Right ()
result <- getFile st SFRecipient testRecipientId
case result of
Right (FileRec {senderId}, key) -> do
senderId `shouldBe` testSenderId
key `shouldBe` rcpKey
Left e -> expectationFailure $ "getFile failed: " <> show e
testDuplicateFile :: Expectation
testDuplicateFile = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Left DUPLICATE_
testGetNonexistent :: Expectation
testGetNonexistent = withPgStore $ \st -> do
getFile st SFSender testSenderId >>= (`shouldBe` Left AUTH) . fmap (const ())
getFile st SFRecipient testRecipientId >>= (`shouldBe` Left AUTH) . fmap (const ())
testSetFilePath :: Expectation
testSetFilePath = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
setFilePath st testSenderId "/tmp/test_file" `shouldReturn` Right ()
-- Second setFilePath should fail (file_path IS NULL guard)
setFilePath st testSenderId "/tmp/other_file" `shouldReturn` Left AUTH
-- Verify path was set
result <- getFile st SFSender testSenderId
case result of
Right (FileRec {filePath}, _) -> readTVarIO filePath `shouldReturn` Just "/tmp/test_file"
Left e -> expectationFailure $ "getFile failed: " <> show e
testDuplicateRecipient :: Expectation
testDuplicateRecipient = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(rcpKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
addRecipient st testSenderId (FileRecipient testRecipientId rcpKey) `shouldReturn` Right ()
addRecipient st testSenderId (FileRecipient testRecipientId rcpKey) `shouldReturn` Left DUPLICATE_
testDeleteFileCascade :: Expectation
testDeleteFileCascade = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(rcpKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
addRecipient st testSenderId (FileRecipient testRecipientId rcpKey) `shouldReturn` Right ()
deleteFile st testSenderId `shouldReturn` Right ()
-- File and recipient should both be gone
getFile st SFSender testSenderId >>= (`shouldBe` Left AUTH) . fmap (const ())
getFile st SFRecipient testRecipientId >>= (`shouldBe` Left AUTH) . fmap (const ())
testBlockFile :: Expectation
testBlockFile = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
let blockInfo = BlockingInfo {reason = BRContent, notice = Nothing}
blockFile st testSenderId blockInfo False `shouldReturn` Right ()
result <- getFile st SFSender testSenderId
case result of
Right (FileRec {fileStatus}, _) -> readTVarIO fileStatus `shouldReturn` EntityBlocked blockInfo
Left e -> expectationFailure $ "getFile failed: " <> show e
testAckFile :: Expectation
testAckFile = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(rcpKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
addFile st testSenderId fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
addRecipient st testSenderId (FileRecipient testRecipientId rcpKey) `shouldReturn` Right ()
ackFile st testRecipientId `shouldReturn` Right ()
-- Recipient gone, but file still exists
getFile st SFRecipient testRecipientId >>= (`shouldBe` Left AUTH) . fmap (const ())
result <- getFile st SFSender testSenderId
case result of
Right _ -> pure ()
Left e -> expectationFailure $ "getFile failed: " <> show e
testExpiredFiles :: Expectation
testExpiredFiles = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo = testFileInfo sndKey
oldTime = RoundedSystemTime 100000
newTime = RoundedSystemTime 999999999
-- Add old and new files
addFile st (EntityId "old_file________") fileInfo oldTime EntityActive `shouldReturn` Right ()
void $ setFilePath st (EntityId "old_file________") "/tmp/old"
addFile st (EntityId "new_file________") fileInfo newTime EntityActive `shouldReturn` Right ()
-- Query expired with cutoff that only catches old file
expired <- expiredFiles st 500000 100
length expired `shouldBe` 1
case expired of
[(sId, path, sz)] -> do
sId `shouldBe` EntityId "old_file________"
path `shouldBe` Just "/tmp/old"
sz `shouldBe` 128000
_ -> expectationFailure "expected 1 expired file"
testStorageAndCount :: Expectation
testStorageAndCount = withPgStore $ \st -> do
g <- C.newRandom
(sndKey, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
getUsedStorage st `shouldReturn` 0
getFileCount st `shouldReturn` 0
let fileInfo = testFileInfo sndKey
addFile st (EntityId "file_a__________") fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
addFile st (EntityId "file_b__________") fileInfo testCreatedAt EntityActive `shouldReturn` Right ()
getFileCount st `shouldReturn` 2
used <- getUsedStorage st
used `shouldBe` 256000 -- 128000 * 2
-- Migration round-trip test
testMigrationRoundTrip :: Expectation
testMigrationRoundTrip = do
let storeLogPath = "tests/tmp/xftp-migration-test.log"
storeLogPath2 = "tests/tmp/xftp-migration-test2.log"
-- 1. Create STM store with test data
stmStore <- newFileStore () :: IO STMFileStore
g <- C.newRandom
(sndKey1, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(rcpKey1, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(sndKey2, _) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
let fileInfo1 = testFileInfo sndKey1
fileInfo2 = FileInfo {sndKey = sndKey2, size = 64000, digest = "other_digest____________"}
sId1 = EntityId "migration_file_1"
sId2 = EntityId "migration_file_2"
rId1 = EntityId "migration_rcp_1_"
addFile stmStore sId1 fileInfo1 testCreatedAt EntityActive `shouldReturn` Right ()
void $ setFilePath stmStore sId1 "/tmp/file1"
addRecipient stmStore sId1 (FileRecipient rId1 rcpKey1) `shouldReturn` Right ()
let testBlockInfo = BlockingInfo {reason = BRSpam, notice = Nothing}
addFile stmStore sId2 fileInfo2 testCreatedAt (EntityBlocked testBlockInfo) `shouldReturn` Right ()
-- 2. Write to StoreLog
sl <- openWriteStoreLog False storeLogPath
writeFileStore sl stmStore
closeStoreLog sl
-- 3. Import StoreLog to Postgres
importFileStore storeLogPath testXFTPPostgresCfg
-- StoreLog should be renamed to .bak
doesFileExist storeLogPath `shouldReturn` False
doesFileExist (storeLogPath <> ".bak") `shouldReturn` True
-- 4. Export from Postgres back to StoreLog
exportFileStore storeLogPath2 testXFTPPostgresCfg
-- 5. Read exported StoreLog into a new STM store and verify
stmStore2 <- newFileStore () :: IO STMFileStore
sl2 <- readWriteFileStore storeLogPath2 stmStore2
closeStoreLog sl2
-- Verify file 1
result1 <- getFile stmStore2 SFSender sId1
case result1 of
Right (FileRec {fileInfo = fi, filePath, fileStatus}, _) -> do
size fi `shouldBe` 128000
readTVarIO filePath `shouldReturn` Just "/tmp/file1"
readTVarIO fileStatus `shouldReturn` EntityActive
Left e -> expectationFailure $ "getFile sId1 failed: " <> show e
-- Verify recipient
result1r <- getFile stmStore2 SFRecipient rId1
case result1r of
Right (_, key) -> key `shouldBe` rcpKey1
Left e -> expectationFailure $ "getFile rId1 failed: " <> show e
-- Verify file 2 (blocked)
result2 <- getFile stmStore2 SFSender sId2
case result2 of
Right (FileRec {fileInfo = fi, fileStatus}, _) -> do
size fi `shouldBe` 64000
readTVarIO fileStatus `shouldReturn` EntityBlocked (BlockingInfo {reason = BRSpam, notice = Nothing})
Left e -> expectationFailure $ "getFile sId2 failed: " <> show e
-- Cleanup
removeFile (storeLogPath <> ".bak")
removeFile storeLogPath2
+23 -5
View File
@@ -26,13 +26,16 @@ import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClie
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Protocol
import Simplex.Messaging.Server (runSMPServerBlocking)
import Simplex.Messaging.Server (runSMPServerBlocking, AttachHTTP)
import Simplex.Messaging.Server.Env.STM
import Simplex.Messaging.Server.MsgStore.Types (MsgStoreClass (..), SMSType (..), SQSType (..))
import Simplex.Messaging.Server.QueueStore.Postgres.Config (PostgresStoreCfg (..))
import Data.X509.Validation (Fingerprint (..))
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Client
import Simplex.Messaging.Transport.Server
import Simplex.Messaging.Transport.HTTP2 (httpALPN)
import Simplex.Messaging.Transport.Server (ServerCredentials (..), TransportServerConfig (..), loadFileFingerprint, loadFingerprint, loadServerCredential, mkTransportServerConfig)
import Simplex.Messaging.Transport.WebSockets (WS)
import Simplex.Messaging.Util (ifM)
import Simplex.Messaging.Version
import Simplex.Messaging.Version.Internal
@@ -155,7 +158,8 @@ testSMPClientVR vr client = do
testSMPClient_ :: Transport c => TransportHost -> ServiceName -> VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a
testSMPClient_ host port vr client = do
let tcConfig = defaultTransportClientConfig {clientALPN} :: TransportClientConfig
-- SMP clients use useSNI = False (matches defaultSMPClientConfig)
let tcConfig = defaultTransportClientConfig {clientALPN, useSNI = False} :: TransportClientConfig
runTransportClient tcConfig Nothing host port (Just testKeyHash) $ \h ->
runExceptT (smpClientHandshake h Nothing testKeyHash vr False Nothing) >>= \case
Right th -> client th
@@ -283,6 +287,17 @@ serverStoreConfig_ useDbStoreLog = \case
dbStoreLogPath = if useDbStoreLog then Just testStoreLogFile else Nothing
storeCfg = PostgresStoreCfg {dbOpts = testStoreDBOpts, dbStoreLogPath, confirmMigrations = MCYesUp, deletedTTL = 86400}
cfgWebOn :: AStoreType -> ServiceName -> AServerConfig
cfgWebOn msType port' = updateCfg (cfgMS msType) $ \cfg' ->
cfg' { transports = [(port', transport @TLS, True)],
httpCredentials = Just ServerCredentials
{ caCertificateFile = Nothing,
privateKeyFile = "tests/fixtures/web.key",
certificateFile = "tests/fixtures/web.crt"
},
transportConfig = mkTransportServerConfig True (Just $ alpnSupportedSMPHandshakes <> httpALPN) True
}
cfgV7 :: AServerConfig
cfgV7 = updateCfg cfg $ \cfg' -> cfg' {smpServerVRange = mkVersionRange minServerSMPRelayVersion authCmdsSMPVersion}
@@ -333,9 +348,12 @@ withServerCfg :: AServerConfig -> (forall s. ServerConfig s -> a) -> a
withServerCfg (ASrvCfg _ _ cfg') f = f cfg'
withSmpServerConfigOn :: HasCallStack => ASrvTransport -> AServerConfig -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a
withSmpServerConfigOn t (ASrvCfg _ _ cfg') port' =
withSmpServerConfigOn t cfg' port' = withSmpServerConfig (updateCfg cfg' $ \c -> c {transports = [(port', t, False)]}) Nothing
withSmpServerConfig :: HasCallStack => AServerConfig -> Maybe AttachHTTP -> (HasCallStack => ThreadId -> IO a) -> IO a
withSmpServerConfig (ASrvCfg _ _ cfg') attachHTTP_ =
serverBracket
(\started -> runSMPServerBlocking started cfg' {transports = [(port', t, False)]} Nothing)
(\started -> runSMPServerBlocking started cfg' attachHTTP_)
(threadDelay 10000)
withSmpServerThreadOn :: HasCallStack => (ASrvTransport, AStoreType) -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a
+1837
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -23,6 +23,7 @@ import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM
import Control.Exception (SomeException, throwIO, try)
import Control.Monad
import Control.Monad.Except (runExceptT)
import Control.Monad.IO.Class
import CoreTests.MsgStoreTests (testJournalStoreCfg)
import Data.Bifunctor (first)
@@ -42,6 +43,7 @@ import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (parseAll, parseString)
import Simplex.Messaging.Protocol
import Simplex.Messaging.Client (chooseTransportHost, defaultNetworkConfig)
import Simplex.Messaging.Server (exportMessages)
import Simplex.Messaging.Server.Env.STM (AStoreType (..), MsgStore (..), ServerConfig (..), ServerStoreCfg (..), readWriteQueueStore)
import Simplex.Messaging.Server.Expiration
@@ -50,6 +52,11 @@ import Simplex.Messaging.Server.MsgStore.Types (MsgStoreClass (..), QSType (..),
import Simplex.Messaging.Server.Stats (PeriodStatsData (..), ServerStatsData (..))
import Simplex.Messaging.Server.StoreLog (StoreLogRecord (..), closeStoreLog)
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Client (TransportClientConfig (..), defaultTransportClientConfig, runTLSTransportClient)
import Simplex.Messaging.Transport.WebSockets (WS)
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
import Simplex.Messaging.Server.Web (attachStaticAndWS)
import Data.X509.Validation (Fingerprint (..))
import Simplex.Messaging.Util (whenM)
import Simplex.Messaging.Version (mkVersionRange)
import System.Directory (doesDirectoryExist, doesFileExist, removeDirectoryRecursive, removeFile)
@@ -101,6 +108,7 @@ serverTests = do
describe "Short links" $ do
testInvQueueLinkData
testContactQueueLinkData
describe "WebSocket and TLS on same port" testWebSocketAndTLS
pattern Resp :: CorrId -> QueueId -> BrokerMsg -> Transmission (Either ErrorType BrokerMsg)
pattern Resp corrId queueId command <- (corrId, queueId, Right command)
@@ -1484,3 +1492,41 @@ serverSyntaxTests (ATransport t) = do
(Maybe TAuthorizations, ByteString, ByteString, BrokerMsg) ->
Expectation
command >#> response = withFrozenCallStack $ smpServerTest t command `shouldReturn` response
-- | Test that both native TLS and WebSocket clients can connect to the same port.
-- Native TLS uses useSNI=False, WebSocket uses useSNI=True for routing.
testWebSocketAndTLS :: SpecWith (ASrvTransport, AStoreType)
testWebSocketAndTLS =
it "native TLS and WebSocket clients work on same port" $ \(_t, msType) -> do
Fingerprint fpHTTP <- loadFileFingerprint "tests/fixtures/web_ca.crt"
let httpKeyHash = C.KeyHash fpHTTP
attachStaticAndWS "tests/fixtures" $ \attachHTTP ->
withSmpServerConfig (cfgWebOn msType testPort) (Just attachHTTP) $ \_ -> do
g <- C.newRandom
(rPub, rKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(sPub, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(dhPub, dhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g
-- Connect via native TLS (useSNI=False, default) and create a queue
(sId, rId, srvDh) <- testSMPClient @TLS $ \rh -> do
Resp "1" _ (Ids rId sId srvDh) <- signSendRecv rh rKey ("1", NoEntity, New rPub dhPub)
Resp "2" _ OK <- signSendRecv rh rKey ("2", rId, KEY sPub)
pure (sId, rId, srvDh)
let dec = decryptMsgV3 $ C.dh' srvDh dhPriv
-- Connect via WebSocket (useSNI=True) and send a message
Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost
let wsTcConfig = defaultTransportClientConfig {useSNI = True} :: TransportClientConfig
runTLSTransportClient defaultSupportedParamsHTTPS Nothing wsTcConfig Nothing useHost testPort (Just httpKeyHash) $ \(h :: WS 'TClient) ->
runExceptT (smpClientHandshake h Nothing testKeyHash supportedClientSMPRelayVRange False Nothing) >>= \case
Right sh -> do
Resp "3" _ OK <- signSendRecv sh sKey ("3", sId, _SEND "hello from websocket")
pure ()
Left e -> error $ show e
-- Verify message received via native TLS
testSMPClient @TLS $ \rh -> do
(Resp "4" _ (SOK Nothing), Resp "" _ (Msg mId msg)) <- signSendRecv2 rh rKey ("4", rId, SUB)
dec mId msg `shouldBe` Right "hello from websocket"
Resp "5" _ OK <- signSendRecv rh rKey ("5", rId, ACK mId)
pure ()
+24 -4
View File
@@ -33,10 +33,13 @@ import System.Environment (setEnv)
import Test.Hspec hiding (fit, it)
import Util
import XFTPAgent
import XFTPCLI
import XFTPCLI (xftpCLIFileTests)
import Simplex.FileTransfer.Server.Env (AFStoreType (..))
import Simplex.FileTransfer.Server.Store (SFSType (..))
import XFTPServerTests (xftpServerTests)
import WebTests (webTests)
import XFTPWebTests (xftpWebTests)
import SMPWebTests (smpWebTests)
#if defined(dbPostgres)
import Fixtures
@@ -46,12 +49,14 @@ import AgentTests.SchemaDump (schemaDumpTest)
#endif
#if defined(dbServerPostgres)
import CoreTests.XFTPStoreTests (xftpStoreTests, xftpMigrationTests)
import NtfServerTests (ntfServerTests)
import NtfClient (ntfTestServerDBConnectInfo, ntfTestStoreDBOpts)
import PostgresSchemaDump (postgresSchemaDumpTest)
import SMPClient (testServerDBConnectInfo, testStoreDBOpts)
import Simplex.Messaging.Notifications.Server.Store.Migrations (ntfServerMigrations)
import Simplex.Messaging.Server.QueueStore.Postgres.Migrations (serverMigrations)
import XFTPClient (testXFTPDBConnectInfo)
#endif
#if defined(dbPostgres) || defined(dbServerPostgres)
@@ -148,15 +153,30 @@ main = do
describe "SMP proxy, jornal message store" $
before (pure $ ASType SQSMemory SMSJournal) smpProxyTests
describe "XFTP" $ do
describe "XFTP server" xftpServerTests
describe "XFTP server" $
before (pure $ AFSType SFSMemory) xftpServerTests
describe "XFTP file description" fileDescriptionTests
describe "XFTP CLI" xftpCLITests
describe "XFTP agent" xftpAgentTests
describe "XFTP CLI (memory)" $
before (pure $ AFSType SFSMemory) xftpCLIFileTests
describe "XFTP agent" $
before (pure $ AFSType SFSMemory) xftpAgentTests
#if defined(dbServerPostgres)
around_ (postgressBracket testXFTPDBConnectInfo) $ do
describe "XFTP Postgres store operations" xftpStoreTests
describe "XFTP migration round-trip" xftpMigrationTests
describe "XFTP server (PostgreSQL)" $
before (pure $ AFSType SFSPostgres) xftpServerTests
describe "XFTP agent (PostgreSQL)" $
before (pure $ AFSType SFSPostgres) xftpAgentTests
describe "XFTP CLI (PostgreSQL)" $
before (pure $ AFSType SFSPostgres) xftpCLIFileTests
#endif
#if defined(dbPostgres)
describe "XFTP Web Client" $ xftpWebTests (dropAllSchemasExceptSystem testDBConnectInfo)
#else
describe "XFTP Web Client" $ xftpWebTests (pure ())
#endif
describe "SMP Web Client" smpWebTests
describe "XRCP" remoteControlTests
describe "Web" webTests
describe "Server CLIs" cliTests
+92 -82
View File
@@ -26,7 +26,8 @@ import SMPClient (xit'')
import Simplex.FileTransfer.Client (XFTPClientConfig (..))
import Simplex.FileTransfer.Description (FileChunk (..), FileDescription (..), FileDescriptionURI (..), ValidFileDescription, fileDescriptionURI, kb, mb, qrSizeLimit, pattern ValidFileDescription)
import Simplex.FileTransfer.Protocol (FileParty (..))
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
import Simplex.FileTransfer.Server.Env (AFStoreType, XFTPServerConfig (..))
import Simplex.FileTransfer.Server.Store (STMFileStore)
import Simplex.FileTransfer.Transport (XFTPErrorType (AUTH))
import Simplex.FileTransfer.Types (RcvFileId, SndFileId)
import Simplex.Messaging.Agent (AgentClient, testProtocolServer, xftpDeleteRcvFile, xftpDeleteSndFileInternal, xftpDeleteSndFileRemote, xftpReceiveFile, xftpSendDescription, xftpSendFile, xftpStartWorkers)
@@ -54,7 +55,7 @@ import Fixtures
import Simplex.Messaging.Agent.Store.Postgres.Util (dropAllSchemasExceptSystem)
#endif
xftpAgentTests :: Spec
xftpAgentTests :: SpecWith AFStoreType
xftpAgentTests =
around_ testBracket
#if defined(dbPostgres)
@@ -63,35 +64,42 @@ xftpAgentTests =
. describe "agent XFTP API" $ do
it "should send and receive file" $ withXFTPServer testXFTPAgentSendReceive
-- uncomment CPP option slow_servers and run hpack to run this test
xit "should send and receive file with slow server responses" $
xit "should send and receive file with slow server responses" $ \_ ->
withXFTPServerCfg testXFTPServerConfig {responseDelay = 500000} $
\_ -> testXFTPAgentSendReceive
it "should send and receive with encrypted local files" testXFTPAgentSendReceiveEncrypted
it "should send and receive large file with a redirect" testXFTPAgentSendReceiveRedirect
it "should send and receive small file without a redirect" testXFTPAgentSendReceiveNoRedirect
describe "sending and receiving with version negotiation" testXFTPAgentSendReceiveMatrix
it "should resume receiving file after restart" testXFTPAgentReceiveRestore
it "should cleanup rcv tmp path after permanent error" testXFTPAgentReceiveCleanup
it "should resume sending file after restart" testXFTPAgentSendRestore
xit'' "should cleanup snd prefix path after permanent error" testXFTPAgentSendCleanup
describe "sending and receiving with version negotiation" $ beforeWith (const (pure ())) testXFTPAgentSendReceiveMatrix
it "should resume receiving file after restart" $ \_ -> testXFTPAgentReceiveRestore
it "should cleanup rcv tmp path after permanent error" $ \_ -> testXFTPAgentReceiveCleanup
it "should resume sending file after restart" $ \_ -> testXFTPAgentSendRestore
xit'' "should cleanup snd prefix path after permanent error" $ \_ -> testXFTPAgentSendCleanup
it "should delete sent file on server" testXFTPAgentDelete
it "should resume deleting file after restart" testXFTPAgentDeleteRestore
it "should resume deleting file after restart" $ \_ -> testXFTPAgentDeleteRestore
-- TODO when server is fixed to correctly send AUTH error, this test has to be modified to expect AUTH error
it "if file is deleted on server, should limit retries and continue receiving next file" testXFTPAgentDeleteOnServer
it "if file is expired on server, should report error and continue receiving next file" testXFTPAgentExpiredOnServer
it "should request additional recipient IDs when number of recipients exceeds maximum per request" testXFTPAgentRequestAdditionalRecipientIDs
describe "XFTP server test via agent API" $ do
it "should pass without basic auth" $ testXFTPServerTest Nothing (noAuthSrv testXFTPServer2) `shouldReturn` Nothing
it "should pass without basic auth" $ \_ -> testXFTPServerTest Nothing (noAuthSrv testXFTPServer2) `shouldReturn` Nothing
let srv1 = testXFTPServer2 {keyHash = "1234"}
it "should fail with incorrect fingerprint" $ do
it "should fail with incorrect fingerprint" $ \_ -> do
testXFTPServerTest Nothing (noAuthSrv srv1) `shouldReturn` Just (ProtocolTestFailure TSConnect $ BROKER (B.unpack $ strEncode srv1) $ NETWORK NEUnknownCAError)
describe "server with password" $ do
let auth = Just "abcd"
srv = ProtoServerWithAuth testXFTPServer2
authErr = Just (ProtocolTestFailure TSCreateFile $ XFTP (B.unpack $ strEncode testXFTPServer2) AUTH)
it "should pass with correct password" $ testXFTPServerTest auth (srv auth) `shouldReturn` Nothing
it "should fail without password" $ testXFTPServerTest auth (srv Nothing) `shouldReturn` authErr
it "should fail with incorrect password" $ testXFTPServerTest auth (srv $ Just "wrong") `shouldReturn` authErr
it "should pass with correct password" $ \_ -> testXFTPServerTest auth (srv auth) `shouldReturn` Nothing
it "should fail without password" $ \_ -> testXFTPServerTest auth (srv Nothing) `shouldReturn` authErr
it "should fail with incorrect password" $ \_ -> testXFTPServerTest auth (srv $ Just "wrong") `shouldReturn` authErr
testXFTPServerTest :: HasCallStack => Maybe BasicAuth -> XFTPServerWithAuth -> IO (Maybe ProtocolTestFailure)
testXFTPServerTest newFileBasicAuth srv =
withXFTPServerCfg testXFTPServerConfig {newFileBasicAuth, xftpPort = xftpTestPort2} $ \_ ->
-- initially passed server is not running
withAgent 1 agentCfg initAgentServers testDB $ \a ->
testProtocolServer a NRMInteractive 1 srv
rfProgress :: forall m. (HasCallStack, MonadIO m, MonadFail m) => AgentClient -> Int64 -> m ()
rfProgress c expected = loop 0
@@ -135,7 +143,7 @@ testXFTPAgentSendReceive = do
rfId <- runRight $ testReceive rcp rfd originalFilePath
xftpDeleteRcvFile rcp rfId
testXFTPAgentSendReceiveEncrypted :: HasCallStack => IO ()
testXFTPAgentSendReceiveEncrypted :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentSendReceiveEncrypted = withXFTPServer $ do
g <- C.newRandom
filePath <- createRandomFile
@@ -156,7 +164,7 @@ testXFTPAgentSendReceiveEncrypted = withXFTPServer $ do
rfId <- runRight $ testReceiveCF rcp rfd cfArgs originalFilePath
xftpDeleteRcvFile rcp rfId
testXFTPAgentSendReceiveRedirect :: HasCallStack => IO ()
testXFTPAgentSendReceiveRedirect :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentSendReceiveRedirect = withXFTPServer $ do
--- sender
filePathIn <- createRandomFile
@@ -214,7 +222,7 @@ testXFTPAgentSendReceiveRedirect = withXFTPServer $ do
inBytes <- B.readFile filePathIn
B.readFile out `shouldReturn` inBytes
testXFTPAgentSendReceiveNoRedirect :: HasCallStack => IO ()
testXFTPAgentSendReceiveNoRedirect :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentSendReceiveNoRedirect = withXFTPServer $ do
--- sender
let fileSize = mb 5
@@ -272,7 +280,7 @@ testXFTPAgentSendReceiveMatrix = do
newClient = agentCfg
oldServer = withXFTPServerCfgNoALPN
newServer = withXFTPServerCfg
run :: HasCallStack => (HasCallStack => XFTPServerConfig -> (ThreadId -> IO ()) -> IO ()) -> AgentConfig -> AgentConfig -> IO ()
run :: HasCallStack => (HasCallStack => XFTPServerConfig STMFileStore -> (ThreadId -> IO ()) -> IO ()) -> AgentConfig -> AgentConfig -> IO ()
run withServer sender receiver =
withServer testXFTPServerConfig $ \_t -> do
filePath <- createRandomFile_ (kb 319 :: Integer) "testfile"
@@ -498,37 +506,38 @@ testXFTPAgentSendCleanup = withGlobalLogging logCfgNoLogs $ do
doesDirectoryExist prefixPath `shouldReturn` False
doesFileExist encPath `shouldReturn` False
testXFTPAgentDelete :: HasCallStack => IO ()
testXFTPAgentDelete = withGlobalLogging logCfgNoLogs $
withXFTPServer $ do
filePath <- createRandomFile
testXFTPAgentDelete :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentDelete = withGlobalLogging logCfgNoLogs . withXFTPServer test
where
test = do
filePath <- createRandomFile
-- send file
withAgent 1 agentCfg initAgentServers testDB $ \sndr -> do
(sfId, sndDescr, rfd1, rfd2) <- runRight $ testSend sndr filePath
-- send file
withAgent 1 agentCfg initAgentServers testDB $ \sndr -> do
(sfId, sndDescr, rfd1, rfd2) <- runRight $ testSend sndr filePath
-- receive file
withAgent 2 agentCfg initAgentServers testDB2 $ \rcp1 -> do
runRight_ . void $ testReceive rcp1 rfd1 filePath
-- receive file
withAgent 2 agentCfg initAgentServers testDB2 $ \rcp1 -> do
runRight_ . void $ testReceive rcp1 rfd1 filePath
length <$> listDirectory xftpServerFiles `shouldReturn` 6
length <$> listDirectory xftpServerFiles `shouldReturn` 6
-- delete file
runRight_ $ xftpStartWorkers sndr (Just senderFiles)
xftpDeleteSndFileRemote sndr 1 sfId sndDescr
Nothing <- 100000 `timeout` sfGet sndr
pure ()
-- delete file
runRight_ $ xftpStartWorkers sndr (Just senderFiles)
xftpDeleteSndFileRemote sndr 1 sfId sndDescr
Nothing <- 100000 `timeout` sfGet sndr
pure ()
threadDelay 1000000
length <$> listDirectory xftpServerFiles `shouldReturn` 0
threadDelay 1000000
length <$> listDirectory xftpServerFiles `shouldReturn` 0
-- receive file - should fail with AUTH error
withAgent 3 agentCfg initAgentServers testDB2 $ \rcp2 -> runRight $ do
xftpStartWorkers rcp2 (Just recipientFiles)
rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing True
("", rfId', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:8000" AUTH)) <-
rfGet rcp2
liftIO $ rfId' `shouldBe` rfId
-- receive file - should fail with AUTH error
withAgent 3 agentCfg initAgentServers testDB2 $ \rcp2 -> runRight $ do
xftpStartWorkers rcp2 (Just recipientFiles)
rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing True
("", rfId', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:8000" AUTH)) <-
rfGet rcp2
liftIO $ rfId' `shouldBe` rfId
testXFTPAgentDeleteRestore :: HasCallStack => IO ()
testXFTPAgentDeleteRestore = withGlobalLogging logCfgNoLogs $ do
@@ -568,48 +577,48 @@ testXFTPAgentDeleteRestore = withGlobalLogging logCfgNoLogs $ do
rfGet rcp2
liftIO $ rfId' `shouldBe` rfId
testXFTPAgentDeleteOnServer :: HasCallStack => IO ()
testXFTPAgentDeleteOnServer = withGlobalLogging logCfgNoLogs $
withXFTPServer $ do
filePath1 <- createRandomFile' "testfile1"
testXFTPAgentDeleteOnServer :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentDeleteOnServer = withGlobalLogging logCfgNoLogs . withXFTPServer test
where
test = do
filePath1 <- createRandomFile' "testfile1"
-- send file 1
withAgent 1 agentCfg initAgentServers testDB $ \sndr -> do
(_, _, rfd1_1, rfd1_2) <- runRight $ testSend sndr filePath1
-- send file 1
withAgent 1 agentCfg initAgentServers testDB $ \sndr -> do
(_, _, rfd1_1, rfd1_2) <- runRight $ testSend sndr filePath1
-- receive file 1 successfully
withAgent 2 agentCfg initAgentServers testDB2 $ \rcp -> do
runRight_ . void $ testReceive rcp rfd1_1 filePath1
-- receive file 1 successfully
withAgent 2 agentCfg initAgentServers testDB2 $ \rcp -> do
runRight_ . void $ testReceive rcp rfd1_1 filePath1
serverFiles <- listDirectory xftpServerFiles
length serverFiles `shouldBe` 6
serverFiles <- listDirectory xftpServerFiles
length serverFiles `shouldBe` 6
-- delete file 1 on server from file system
forM_ serverFiles (\file -> removeFile (xftpServerFiles </> file))
-- delete file 1 on server from file system
forM_ serverFiles (\file -> removeFile (xftpServerFiles </> file))
threadDelay 1000000
length <$> listDirectory xftpServerFiles `shouldReturn` 0
threadDelay 1000000
length <$> listDirectory xftpServerFiles `shouldReturn` 0
-- create and send file 2
filePath2 <- createRandomFile' "testfile2"
(_, _, rfd2, _) <- runRight $ testSend sndr filePath2
-- create and send file 2
filePath2 <- createRandomFile' "testfile2"
(_, _, rfd2, _) <- runRight $ testSend sndr filePath2
length <$> listDirectory xftpServerFiles `shouldReturn` 6
length <$> listDirectory xftpServerFiles `shouldReturn` 6
runRight_ . void $ do
-- receive file 1 again
rfId1 <- xftpReceiveFile rcp 1 rfd1_2 Nothing True
("", rfId1', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:8000" AUTH)) <-
rfGet rcp
liftIO $ rfId1 `shouldBe` rfId1'
runRight_ . void $ do
-- receive file 1 again
rfId1 <- xftpReceiveFile rcp 1 rfd1_2 Nothing True
("", rfId1', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:8000" AUTH)) <-
rfGet rcp
liftIO $ rfId1 `shouldBe` rfId1'
-- receive file 2
testReceive' rcp rfd2 filePath2
-- receive file 2
testReceive' rcp rfd2 filePath2
testXFTPAgentExpiredOnServer :: HasCallStack => IO ()
testXFTPAgentExpiredOnServer = withGlobalLogging logCfgNoLogs $ do
let fastExpiration = ExpirationConfig {ttl = 2, checkInterval = 1}
withXFTPServerCfg testXFTPServerConfig {fileExpiration = Just fastExpiration} . const $ do
testXFTPAgentExpiredOnServer :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentExpiredOnServer fsType = withGlobalLogging logCfgNoLogs $
withXFTPServerConfigOn (updateXFTPCfg (cfgFS fsType) $ \c -> c {fileExpiration = Just fastExpiration}) . const $ do
filePath1 <- createRandomFile' "testfile1"
-- send file 1
@@ -644,8 +653,10 @@ testXFTPAgentExpiredOnServer = withGlobalLogging logCfgNoLogs $ do
-- receive file 2 successfully
runRight_ . void $ testReceive' rcp rfd2 filePath2
where
fastExpiration = ExpirationConfig {ttl = 2, checkInterval = 1}
testXFTPAgentRequestAdditionalRecipientIDs :: HasCallStack => IO ()
testXFTPAgentRequestAdditionalRecipientIDs :: HasCallStack => AFStoreType -> IO ()
testXFTPAgentRequestAdditionalRecipientIDs = withXFTPServer $ do
filePath <- createRandomFile
@@ -670,9 +681,8 @@ testXFTPAgentRequestAdditionalRecipientIDs = withXFTPServer $ do
void $ testReceive rcp (rfds !! 299) filePath
void $ testReceive rcp (rfds !! 499) filePath
testXFTPServerTest :: HasCallStack => Maybe BasicAuth -> XFTPServerWithAuth -> IO (Maybe ProtocolTestFailure)
testXFTPServerTest newFileBasicAuth srv =
withXFTPServerCfg testXFTPServerConfig {newFileBasicAuth, xftpPort = xftpTestPort2} $ \_ ->
-- initially passed server is not running
withAgent 1 agentCfg initAgentServers testDB $ \a ->
testProtocolServer a NRMInteractive 1 srv
testXFTPServerTest_ :: HasCallStack => XFTPServerWithAuth -> IO (Maybe ProtocolTestFailure)
testXFTPServerTest_ srv =
-- initially passed server is not running
withAgent 1 agentCfg initAgentServers testDB $ \a ->
testProtocolServer a NRMInteractive 1 srv
+17 -14
View File
@@ -1,4 +1,4 @@
module XFTPCLI where
module XFTPCLI (xftpCLIFileTests, xftpCLI, senderFiles, recipientFiles, testBracket) where
import Control.Exception (bracket_)
import qualified Data.ByteString as LB
@@ -11,14 +11,17 @@ import System.FilePath ((</>))
import System.IO.Silently (capture_)
import Test.Hspec hiding (fit, it)
import Util
import XFTPClient (testXFTPServerStr, testXFTPServerStr2, withXFTPServer, withXFTPServer2, xftpServerFiles, xftpServerFiles2)
import Simplex.FileTransfer.Server.Env (AFStoreType)
import XFTPClient (cfgFS, cfgFS2, withXFTPServer, withXFTPServerConfigOn, testXFTPServerStr, testXFTPServerStr2, xftpServerFiles, xftpServerFiles2)
xftpCLITests :: Spec
xftpCLITests = around_ testBracket . describe "XFTP CLI" $ do
it "should send and receive file" testXFTPCLISendReceive
it "should send and receive file with 2 servers" testXFTPCLISendReceive2servers
it "should delete file from 2 servers" testXFTPCLIDelete
it "prepareChunkSizes should use 2 chunk sizes" testPrepareChunkSizes
xftpCLIFileTests :: SpecWith AFStoreType
xftpCLIFileTests = around_ testBracket $ do
it "should send and receive file" $ withXFTPServer testXFTPCLISendReceive_
it "should send and receive file with 2 servers" $ \fsType ->
withXFTPServerConfigOn (cfgFS fsType) $ \_ -> withXFTPServerConfigOn (cfgFS2 fsType) $ \_ -> testXFTPCLISendReceive2servers_
it "should delete file from 2 servers" $ \fsType ->
withXFTPServerConfigOn (cfgFS fsType) $ \_ -> withXFTPServerConfigOn (cfgFS2 fsType) $ \_ -> testXFTPCLIDelete_
it "prepareChunkSizes should use 2 chunk sizes" $ \_ -> testPrepareChunkSizes
testBracket :: IO () -> IO ()
testBracket =
@@ -37,8 +40,8 @@ recipientFiles = "tests/tmp/xftp-recipient-files"
xftpCLI :: [String] -> IO [String]
xftpCLI params = lines <$> capture_ (withArgs params xftpClientCLI)
testXFTPCLISendReceive :: IO ()
testXFTPCLISendReceive = withXFTPServer $ do
testXFTPCLISendReceive_ :: IO ()
testXFTPCLISendReceive_ = do
let filePath = senderFiles </> "testfile"
xftpCLI ["rand", filePath, "17mb"] `shouldReturn` ["File created: " <> filePath]
file <- LB.readFile filePath
@@ -73,8 +76,8 @@ testXFTPCLISendReceive = withXFTPServer $ do
recvResult `shouldBe` ["File description " <> fd <> " is deleted."]
LB.readFile (recipientFiles </> fileName) `shouldReturn` file
testXFTPCLISendReceive2servers :: IO ()
testXFTPCLISendReceive2servers = withXFTPServer . withXFTPServer2 $ do
testXFTPCLISendReceive2servers_ :: IO ()
testXFTPCLISendReceive2servers_ = do
let filePath = senderFiles </> "testfile"
xftpCLI ["rand", filePath, "17mb"] `shouldReturn` ["File created: " <> filePath]
file <- LB.readFile filePath
@@ -111,8 +114,8 @@ testXFTPCLISendReceive2servers = withXFTPServer . withXFTPServer2 $ do
recvResult `shouldBe` ["File description " <> fd <> " is deleted."]
LB.readFile (recipientFiles </> fileName) `shouldReturn` file
testXFTPCLIDelete :: IO ()
testXFTPCLIDelete = withXFTPServer . withXFTPServer2 $ do
testXFTPCLIDelete_ :: IO ()
testXFTPCLIDelete_ = do
let filePath = senderFiles </> "testfile"
xftpCLI ["rand", filePath, "17mb"] `shouldReturn` ["File created: " <> filePath]
file <- LB.readFile filePath
+94 -27
View File
@@ -1,3 +1,5 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -7,6 +9,7 @@
module XFTPClient where
import Control.Concurrent (ThreadId, threadDelay)
import Control.Monad (void)
import Data.String (fromString)
import Data.Time.Clock (getCurrentTime)
import Network.Socket (ServiceName)
@@ -14,48 +17,106 @@ import SMPClient (serverBracket)
import Simplex.FileTransfer.Client
import Simplex.FileTransfer.Description
import Simplex.FileTransfer.Server (runXFTPServerBlocking)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration, defaultInactiveClientExpiration)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), XFTPStoreConfig (..), AFStoreType (..), defaultFileExpiration, defaultInactiveClientExpiration)
import Simplex.FileTransfer.Server.Store (FileStoreClass, SFSType (..), STMFileStore)
import Simplex.FileTransfer.Transport (alpnSupportedXFTPhandshakes, supportedFileServerVRange)
import Simplex.Messaging.Protocol (XFTPServer)
import Simplex.Messaging.Transport.HTTP2 (httpALPN)
import Simplex.Messaging.Transport.Server
import Test.Hspec hiding (fit, it)
#if defined(dbServerPostgres)
import qualified Database.PostgreSQL.Simple as PSQL
import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo)
import Simplex.FileTransfer.Server.Store.Postgres.Config (PostgresFileStoreCfg (..), defaultXFTPDBOpts)
import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..))
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..))
#endif
xftpTest :: HasCallStack => (HasCallStack => XFTPClient -> IO ()) -> Expectation
xftpTest test = runXFTPTest test `shouldReturn` ()
data AXFTPServerConfig = forall s. FileStoreClass s => AXFTPSrvCfg (XFTPServerConfig s)
xftpTestN :: HasCallStack => Int -> (HasCallStack => [XFTPClient] -> IO ()) -> Expectation
xftpTestN n test = runXFTPTestN n test `shouldReturn` ()
updateXFTPCfg :: AXFTPServerConfig -> (forall s. XFTPServerConfig s -> XFTPServerConfig s) -> AXFTPServerConfig
updateXFTPCfg (AXFTPSrvCfg cfg) f = AXFTPSrvCfg (f cfg)
xftpTest2 :: HasCallStack => (HasCallStack => XFTPClient -> XFTPClient -> IO ()) -> Expectation
cfgFS :: AFStoreType -> AXFTPServerConfig
cfgFS (AFSType fs) = case fs of
SFSMemory -> AXFTPSrvCfg testXFTPServerConfig
#if defined(dbServerPostgres)
SFSPostgres -> AXFTPSrvCfg testXFTPServerConfig {serverStoreCfg = XSCDatabase testXFTPPostgresCfg}
#else
SFSPostgres -> error "no postgres support"
#endif
cfgFS2 :: AFStoreType -> AXFTPServerConfig
cfgFS2 (AFSType fs) = case fs of
SFSMemory -> AXFTPSrvCfg testXFTPServerConfig2
#if defined(dbServerPostgres)
SFSPostgres -> AXFTPSrvCfg testXFTPServerConfig2 {serverStoreCfg = XSCDatabase testXFTPPostgresCfg}
#else
SFSPostgres -> error "no postgres support"
#endif
withXFTPServerConfigOn :: HasCallStack => AXFTPServerConfig -> (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerConfigOn (AXFTPSrvCfg cfg) = withXFTPServerCfg cfg
#if defined(dbServerPostgres)
testXFTPDBConnectInfo :: ConnectInfo
testXFTPDBConnectInfo =
defaultConnectInfo
{ connectUser = "test_xftp_server_user",
connectDatabase = "test_xftp_server_db"
}
testXFTPPostgresCfg :: PostgresFileStoreCfg
testXFTPPostgresCfg =
PostgresFileStoreCfg
{ dbOpts = defaultXFTPDBOpts
{ connstr = "postgresql://test_xftp_server_user@/test_xftp_server_db",
schema = "xftp_server_test",
poolSize = 10,
createSchema = True
},
dbStoreLogPath = Nothing,
confirmMigrations = MCYesUp
}
clearXFTPPostgresStore :: IO ()
clearXFTPPostgresStore = do
let DBOpts {connstr} = dbOpts testXFTPPostgresCfg
conn <- PSQL.connectPostgreSQL connstr
void $ PSQL.execute_ conn "SET search_path TO xftp_server_test,public"
void $ PSQL.execute_ conn "DELETE FROM files"
PSQL.close conn
#endif
xftpTest :: HasCallStack => (HasCallStack => XFTPClient -> IO ()) -> AFStoreType -> Expectation
xftpTest test fsType = withXFTPServerConfigOn (cfgFS fsType) (\_ -> testXFTPClient test) `shouldReturn` ()
xftpTestN :: HasCallStack => Int -> (HasCallStack => [XFTPClient] -> IO ()) -> AFStoreType -> Expectation
xftpTestN nClients test fsType = withXFTPServerConfigOn (cfgFS fsType) (\_ -> run nClients []) `shouldReturn` ()
where
run :: Int -> [XFTPClient] -> IO ()
run 0 hs = test hs
run n hs = testXFTPClient $ \h -> run (n - 1) (h : hs)
xftpTest2 :: HasCallStack => (HasCallStack => XFTPClient -> XFTPClient -> IO ()) -> AFStoreType -> Expectation
xftpTest2 test = xftpTestN 2 _test
where
_test [h1, h2] = test h1 h2
_test _ = error "expected 2 handles"
xftpTest4 :: HasCallStack => (HasCallStack => XFTPClient -> XFTPClient -> XFTPClient -> XFTPClient -> IO ()) -> Expectation
xftpTest4 :: HasCallStack => (HasCallStack => XFTPClient -> XFTPClient -> XFTPClient -> XFTPClient -> IO ()) -> AFStoreType -> Expectation
xftpTest4 test = xftpTestN 4 _test
where
_test [h1, h2, h3, h4] = test h1 h2 h3 h4
_test _ = error "expected 4 handles"
runXFTPTest :: HasCallStack => (HasCallStack => XFTPClient -> IO a) -> IO a
runXFTPTest test = withXFTPServer $ testXFTPClient test
runXFTPTestN :: forall a. HasCallStack => Int -> (HasCallStack => [XFTPClient] -> IO a) -> IO a
runXFTPTestN nClients test = withXFTPServer $ run nClients []
where
run :: Int -> [XFTPClient] -> IO a
run 0 hs = test hs
run n hs = testXFTPClient $ \h -> run (n - 1) (h : hs)
withXFTPServerStoreLogOn :: HasCallStack => (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerStoreLogOn = withXFTPServerCfg testXFTPServerConfig {storeLogFile = Just testXFTPLogFile, serverStatsBackupFile = Just testXFTPStatsBackupFile}
withXFTPServerStoreLogOn = withXFTPServerCfg testXFTPServerConfig {serverStoreCfg = XSCMemory (Just testXFTPLogFile), storeLogFile = Just testXFTPLogFile, serverStatsBackupFile = Just testXFTPStatsBackupFile}
withXFTPServerCfgNoALPN :: HasCallStack => XFTPServerConfig -> (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerCfgNoALPN :: (HasCallStack, FileStoreClass s) => XFTPServerConfig s -> (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerCfgNoALPN cfg = withXFTPServerCfg cfg {transportConfig = (transportConfig cfg) {serverALPN = Nothing}}
withXFTPServerCfg :: HasCallStack => XFTPServerConfig -> (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerCfg :: (HasCallStack, FileStoreClass s) => XFTPServerConfig s -> (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerCfg cfg =
serverBracket
(\started -> runXFTPServerBlocking started cfg)
@@ -64,11 +125,13 @@ withXFTPServerCfg cfg =
withXFTPServerThreadOn :: HasCallStack => (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerThreadOn = withXFTPServerCfg testXFTPServerConfig
withXFTPServer :: HasCallStack => IO a -> IO a
withXFTPServer = withXFTPServerCfg testXFTPServerConfig . const
withXFTPServer :: HasCallStack => IO a -> AFStoreType -> IO a
withXFTPServer test fsType = withXFTPServerConfigOn (cfgFS fsType) $ const test
withXFTPServer2 :: HasCallStack => IO a -> IO a
withXFTPServer2 = withXFTPServerCfg testXFTPServerConfig {xftpPort = xftpTestPort2, filesPath = xftpServerFiles2} . const
withXFTPServer2 :: HasCallStack => IO a -> AFStoreType -> IO a
withXFTPServer2 test fsType = withXFTPServerConfigOn (cfgFS2 fsType) $ const test
-- Constants
xftpTestPort :: ServiceName
xftpTestPort = "8000"
@@ -103,12 +166,13 @@ testXFTPStatsBackupFile = "tests/tmp/xftp-server-stats.log"
xftpTestPrometheusMetricsFile :: FilePath
xftpTestPrometheusMetricsFile = "tests/tmp/xftp-server-metrics.txt"
testXFTPServerConfig :: XFTPServerConfig
testXFTPServerConfig :: XFTPServerConfig STMFileStore
testXFTPServerConfig =
XFTPServerConfig
{ xftpPort = xftpTestPort,
controlPort = Nothing,
fileIdSize = 16,
serverStoreCfg = XSCMemory Nothing,
storeLogFile = Nothing,
filesPath = xftpServerFiles,
fileSizeQuota = Nothing,
@@ -139,6 +203,9 @@ testXFTPServerConfig =
webStaticPath = Nothing
}
testXFTPServerConfig2 :: XFTPServerConfig STMFileStore
testXFTPServerConfig2 = testXFTPServerConfig {xftpPort = xftpTestPort2, filesPath = xftpServerFiles2}
testXFTPClientConfig :: XFTPClientConfig
testXFTPClientConfig = defaultXFTPClientConfig
@@ -152,7 +219,7 @@ testXFTPClientWith cfg client = do
Right c -> client c
Left e -> error $ show e
testXFTPServerConfigSNI :: XFTPServerConfig
testXFTPServerConfigSNI :: XFTPServerConfig STMFileStore
testXFTPServerConfigSNI =
testXFTPServerConfig
{ httpCredentials =
@@ -171,7 +238,7 @@ testXFTPServerConfigSNI =
withXFTPServerSNI :: HasCallStack => (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerSNI = withXFTPServerCfg testXFTPServerConfigSNI
testXFTPServerConfigEd25519SNI :: XFTPServerConfig
testXFTPServerConfigEd25519SNI :: XFTPServerConfig STMFileStore
testXFTPServerConfigEd25519SNI =
testXFTPServerConfig
{ xftpCredentials =
+27 -26
View File
@@ -6,7 +6,7 @@
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE ScopedTypeVariables #-}
module XFTPServerTests where
module XFTPServerTests (xftpServerTests) where
import AgentTests.FunctionalAPITests (runRight_)
import Control.Concurrent (threadDelay)
@@ -31,7 +31,7 @@ import ServerTests (logSize)
import Simplex.FileTransfer.Client
import Simplex.FileTransfer.Description (kb)
import Simplex.FileTransfer.Protocol (FileInfo (..), XFTPFileId, xftpBlockSize)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
import Simplex.FileTransfer.Server.Env (AFStoreType, XFTPServerConfig (..))
import Simplex.FileTransfer.Transport (XFTPClientHandshake (..), XFTPClientHello (..), XFTPErrorType (..), XFTPRcvChunkSpec (..), XFTPServerHandshake (..), pattern VersionXFTP)
import Simplex.Messaging.Client (ProtocolClientError (..))
import qualified Simplex.Messaging.Crypto as C
@@ -52,7 +52,7 @@ import UnliftIO.STM
import Util
import XFTPClient
xftpServerTests :: Spec
xftpServerTests :: SpecWith AFStoreType
xftpServerTests =
before_ (createDirectoryIfMissing False xftpServerFiles) . after_ (removeDirectoryRecursive xftpServerFiles) $ do
describe "XFTP file chunk delivery" $ do
@@ -76,7 +76,7 @@ xftpServerTests =
it "allowed with correct basic auth" $ testFileBasicAuth True (Just "pwd") (Just "pwd") True
it "allowed with auth on server without auth" $ testFileBasicAuth True Nothing (Just "any") True
it "should not change content for uploaded and committed files" testFileSkipCommitted
describe "XFTP SNI and CORS" $ do
describe "XFTP SNI and CORS" $ beforeWith (const (pure ())) $ do
it "should select web certificate when SNI is used" testSNICertSelection
it "should select XFTP certificate when SNI is not used" testNoSNICertSelection
it "should add CORS headers when SNI is used" testCORSHeaders
@@ -103,10 +103,10 @@ createTestChunk fp = do
readChunk :: XFTPFileId -> IO ByteString
readChunk sId = B.readFile (xftpServerFiles </> B.unpack (B64.encode $ unEntityId sId))
testFileChunkDelivery :: Expectation
testFileChunkDelivery :: AFStoreType -> Expectation
testFileChunkDelivery = xftpTest $ \c -> runRight_ $ runTestFileChunkDelivery c c
testFileChunkDelivery2 :: Expectation
testFileChunkDelivery2 :: AFStoreType -> Expectation
testFileChunkDelivery2 = xftpTest2 $ \s r -> runRight_ $ runTestFileChunkDelivery s r
runTestFileChunkDelivery :: XFTPClient -> XFTPClient -> ExceptT XFTPClientError IO ()
@@ -129,7 +129,7 @@ runTestFileChunkDelivery s r = do
downloadXFTPChunk g r rpKey rId $ XFTPRcvChunkSpec "tests/tmp/received_chunk1" chSize digest
liftIO $ B.readFile "tests/tmp/received_chunk1" `shouldReturn` bytes
testFileChunkDeliveryAddRecipients :: Expectation
testFileChunkDeliveryAddRecipients :: AFStoreType -> Expectation
testFileChunkDeliveryAddRecipients = xftpTest4 $ \s r1 r2 r3 -> runRight_ $ do
g <- liftIO C.newRandom
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -150,10 +150,10 @@ testFileChunkDeliveryAddRecipients = xftpTest4 $ \s r1 r2 r3 -> runRight_ $ do
testReceiveChunk r2 rpKey2 rId2 "tests/tmp/received_chunk2"
testReceiveChunk r3 rpKey3 rId3 "tests/tmp/received_chunk3"
testFileChunkDelete :: Expectation
testFileChunkDelete :: AFStoreType -> Expectation
testFileChunkDelete = xftpTest $ \c -> runRight_ $ runTestFileChunkDelete c c
testFileChunkDelete2 :: Expectation
testFileChunkDelete2 :: AFStoreType -> Expectation
testFileChunkDelete2 = xftpTest2 $ \s r -> runRight_ $ runTestFileChunkDelete s r
runTestFileChunkDelete :: XFTPClient -> XFTPClient -> ExceptT XFTPClientError IO ()
@@ -179,10 +179,10 @@ runTestFileChunkDelete s r = do
deleteXFTPChunk s spKey sId
`catchError` (liftIO . (`shouldBe` PCEProtocolError AUTH))
testFileChunkAck :: Expectation
testFileChunkAck :: AFStoreType -> Expectation
testFileChunkAck = xftpTest $ \c -> runRight_ $ runTestFileChunkAck c c
testFileChunkAck2 :: Expectation
testFileChunkAck2 :: AFStoreType -> Expectation
testFileChunkAck2 = xftpTest2 $ \s r -> runRight_ $ runTestFileChunkAck s r
runTestFileChunkAck :: XFTPClient -> XFTPClient -> ExceptT XFTPClientError IO ()
@@ -206,7 +206,7 @@ runTestFileChunkAck s r = do
ackXFTPChunk r rpKey rId
`catchError` (liftIO . (`shouldBe` PCEProtocolError AUTH))
testWrongChunkSize :: Expectation
testWrongChunkSize :: AFStoreType -> Expectation
testWrongChunkSize = xftpTest $ \c -> do
g <- C.newRandom
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -218,8 +218,8 @@ testWrongChunkSize = xftpTest $ \c -> do
void (createXFTPChunk c spKey file [rcvKey] Nothing)
`catchError` (liftIO . (`shouldBe` PCEProtocolError SIZE))
testFileChunkExpiration :: Expectation
testFileChunkExpiration = withXFTPServerCfg testXFTPServerConfig {fileExpiration} $
testFileChunkExpiration :: AFStoreType -> Expectation
testFileChunkExpiration fsType = withXFTPServerConfigOn (updateXFTPCfg (cfgFS fsType) $ \c -> c {fileExpiration}) $
\_ -> testXFTPClient $ \c -> runRight_ $ do
g <- liftIO C.newRandom
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -242,8 +242,8 @@ testFileChunkExpiration = withXFTPServerCfg testXFTPServerConfig {fileExpiration
where
fileExpiration = Just ExpirationConfig {ttl = 1, checkInterval = 1}
testInactiveClientExpiration :: Expectation
testInactiveClientExpiration = withXFTPServerCfg testXFTPServerConfig {inactiveClientExpiration} $ \_ -> runRight_ $ do
testInactiveClientExpiration :: AFStoreType -> Expectation
testInactiveClientExpiration fsType = withXFTPServerConfigOn (updateXFTPCfg (cfgFS fsType) $ \c -> c {inactiveClientExpiration}) $ \_ -> runRight_ $ do
disconnected <- newEmptyTMVarIO
ts <- liftIO getCurrentTime
c <- ExceptT $ getXFTPClient (1, testXFTPServer, Nothing) testXFTPClientConfig [] ts (\_ -> atomically $ putTMVar disconnected ())
@@ -258,8 +258,8 @@ testInactiveClientExpiration = withXFTPServerCfg testXFTPServerConfig {inactiveC
where
inactiveClientExpiration = Just ExpirationConfig {ttl = 1, checkInterval = 1}
testFileStorageQuota :: Expectation
testFileStorageQuota = withXFTPServerCfg testXFTPServerConfig {fileSizeQuota = Just $ chSize * 2} $
testFileStorageQuota :: AFStoreType -> Expectation
testFileStorageQuota fsType = withXFTPServerConfigOn (updateXFTPCfg (cfgFS fsType) $ \c -> c {fileSizeQuota = Just $ chSize * 2}) $
\_ -> testXFTPClient $ \c -> runRight_ $ do
g <- liftIO C.newRandom
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -286,8 +286,8 @@ testFileStorageQuota = withXFTPServerCfg testXFTPServerConfig {fileSizeQuota = J
uploadXFTPChunk c spKey sId3 chunkSpec
download rId3
testFileLog :: Expectation
testFileLog = do
testFileLog :: AFStoreType -> Expectation
testFileLog _ = do
g <- C.newRandom
bytes <- liftIO $ createTestChunk testChunkPath
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -378,9 +378,9 @@ testFileLog = do
downloadXFTPChunk g c rpKey rId $ XFTPRcvChunkSpec "tests/tmp/received_chunk1" chSize digest
liftIO $ B.readFile "tests/tmp/received_chunk1" `shouldReturn` bytes
testFileBasicAuth :: Bool -> Maybe BasicAuth -> Maybe BasicAuth -> Bool -> IO ()
testFileBasicAuth allowNewFiles newFileBasicAuth clntAuth success =
withXFTPServerCfg testXFTPServerConfig {allowNewFiles, newFileBasicAuth} $
testFileBasicAuth :: Bool -> Maybe BasicAuth -> Maybe BasicAuth -> Bool -> AFStoreType -> IO ()
testFileBasicAuth allowNewFiles newFileBasicAuth clntAuth success fsType =
withXFTPServerConfigOn (updateXFTPCfg (cfgFS fsType) $ \c -> c {allowNewFiles, newFileBasicAuth}) $
\_ -> testXFTPClient $ \c -> do
g <- C.newRandom
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -400,9 +400,9 @@ testFileBasicAuth allowNewFiles newFileBasicAuth clntAuth success =
void (createXFTPChunk c spKey file [rcvKey] clntAuth)
`catchError` (liftIO . (`shouldBe` PCEProtocolError AUTH))
testFileSkipCommitted :: IO ()
testFileSkipCommitted =
withXFTPServerCfg testXFTPServerConfig $
testFileSkipCommitted :: AFStoreType -> IO ()
testFileSkipCommitted fsType =
withXFTPServerConfigOn (cfgFS fsType) $
\_ -> testXFTPClient $ \c -> do
g <- C.newRandom
(sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
@@ -598,3 +598,4 @@ testStaleWebSession =
decoded <- either (error . show) pure $ C.unPad respBody
decoded `shouldBe` smpEncode SESSION
+18 -14
View File
@@ -11,7 +11,7 @@
--
-- Prerequisites: cd xftp-web && npm install && npm run build
-- Run: cabal test --test-option=--match="/XFTP Web Client/"
module XFTPWebTests (xftpWebTests) where
module XFTPWebTests (xftpWebTests, callNode_, jsOut, jsUint8, redirectConsole) where
import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
import Control.Monad (replicateM, when)
@@ -45,6 +45,7 @@ import System.Process (CreateProcess (..), StdStream (..), createProcess, proc,
import Test.Hspec hiding (fit, it)
import Util
import Simplex.FileTransfer.Server.Env (XFTPServerConfig)
import Simplex.FileTransfer.Server.Store (STMFileStore)
import XFTPClient (testXFTPServerConfigEd25519SNI, testXFTPServerConfigSNI, withXFTPServerCfg, xftpTestPort)
import AgentTests.FunctionalAPITests (rfGet, runRight, runRight_, sfGet, withAgent)
import Simplex.Messaging.Agent (AgentClient, xftpReceiveFile, xftpSendFile, xftpStartWorkers)
@@ -60,9 +61,9 @@ xftpWebDir = "xftp-web"
redirectConsole :: String
redirectConsole = "console.log = console.warn = (...a) => process.stderr.write(a.map(String).join(' ') + '\\n');"
-- | Run an inline ES module script via node, return stdout as ByteString.
callNode :: String -> IO B.ByteString
callNode script = do
-- | Run an inline ES module script via node in a given directory, return stdout as ByteString.
callNode_ :: FilePath -> String -> IO B.ByteString
callNode_ dir script = do
baseEnv <- getEnvironment
let nodeEnv = ("NODE_TLS_REJECT_UNAUTHORIZED", "0") : baseEnv
(_, Just hout, Just herr, ph) <-
@@ -70,7 +71,7 @@ callNode script = do
(proc "node" ["--input-type=module", "-e", redirectConsole <> script])
{ std_out = CreatePipe,
std_err = CreatePipe,
cwd = Just xftpWebDir,
cwd = Just dir,
env = Just nodeEnv
}
errVar <- newEmptyMVar
@@ -83,6 +84,9 @@ callNode script = do
"node " <> show ec <> "\nstderr: " <> map (toEnum . fromIntegral) (B.unpack err)
pure out
callNode :: String -> IO B.ByteString
callNode = callNode_ xftpWebDir
-- | Format a ByteString as a JS Uint8Array constructor.
jsUint8 :: B.ByteString -> String
jsUint8 bs = "new Uint8Array([" <> intercalate "," (map show (B.unpack bs)) <> "])"
@@ -2854,7 +2858,7 @@ tsIntegrationTests dbCleanup = describe "integration" $
it "cross-language: Haskell upload, TS download" $
haskellUploadTsDownloadTest testXFTPServerConfigSNI
webHandshakeTest :: XFTPServerConfig -> FilePath -> Expectation
webHandshakeTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
webHandshakeTest cfg caFile = do
withXFTPServerCfg cfg $ \_ -> do
Fingerprint fp <- loadFileFingerprint caFile
@@ -2895,7 +2899,7 @@ webHandshakeTest cfg caFile = do
<> jsOut "new Uint8Array([idOk ? 1 : 0, ack.length === 0 ? 1 : 0])"
result `shouldBe` B.pack [1, 1]
pingTest :: XFTPServerConfig -> FilePath -> Expectation
pingTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
pingTest cfg caFile = do
withXFTPServerCfg cfg $ \_ -> do
Fingerprint fp <- loadFileFingerprint caFile
@@ -2917,7 +2921,7 @@ pingTest cfg caFile = do
<> jsOut "new Uint8Array([1])"
result `shouldBe` B.pack [1]
fullRoundTripTest :: XFTPServerConfig -> FilePath -> Expectation
fullRoundTripTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
fullRoundTripTest cfg caFile = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
withXFTPServerCfg cfg $ \_ -> do
@@ -2998,7 +3002,7 @@ agentURIRoundTripTest = do
<> jsOut "new Uint8Array([match])"
result `shouldBe` B.pack [1]
agentUploadDownloadTest :: XFTPServerConfig -> FilePath -> Expectation
agentUploadDownloadTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
agentUploadDownloadTest cfg caFile = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
withXFTPServerCfg cfg $ \_ -> do
@@ -3031,7 +3035,7 @@ agentUploadDownloadTest cfg caFile = do
<> jsOut "new Uint8Array([nameMatch, sizeMatch, dataMatch])"
result `shouldBe` B.pack [1, 1, 1]
agentDeleteTest :: XFTPServerConfig -> FilePath -> Expectation
agentDeleteTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
agentDeleteTest cfg caFile = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
withXFTPServerCfg cfg $ \_ -> do
@@ -3063,7 +3067,7 @@ agentDeleteTest cfg caFile = do
<> jsOut "new Uint8Array([deleted])"
result `shouldBe` B.pack [1]
agentRedirectTest :: XFTPServerConfig -> FilePath -> Expectation
agentRedirectTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
agentRedirectTest cfg caFile = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
withXFTPServerCfg cfg $ \_ -> do
@@ -3097,7 +3101,7 @@ agentRedirectTest cfg caFile = do
<> jsOut "new Uint8Array([hasRedirect, nameMatch, sizeMatch, dataMatch])"
result `shouldBe` B.pack [1, 1, 1, 1]
tsUploadHaskellDownloadTest :: XFTPServerConfig -> FilePath -> Expectation
tsUploadHaskellDownloadTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
tsUploadHaskellDownloadTest cfg caFile = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
createDirectoryIfMissing False recipientFiles
@@ -3132,7 +3136,7 @@ tsUploadHaskellDownloadTest cfg caFile = do
downloadedData <- B.readFile outPath
downloadedData `shouldBe` originalData
tsUploadRedirectHaskellDownloadTest :: XFTPServerConfig -> FilePath -> Expectation
tsUploadRedirectHaskellDownloadTest :: XFTPServerConfig STMFileStore -> FilePath -> Expectation
tsUploadRedirectHaskellDownloadTest cfg caFile = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
createDirectoryIfMissing False recipientFiles
@@ -3167,7 +3171,7 @@ tsUploadRedirectHaskellDownloadTest cfg caFile = do
downloadedData <- B.readFile outPath
downloadedData `shouldBe` originalData
haskellUploadTsDownloadTest :: XFTPServerConfig -> Expectation
haskellUploadTsDownloadTest :: XFTPServerConfig STMFileStore -> Expectation
haskellUploadTsDownloadTest cfg = do
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
createDirectoryIfMissing False senderFiles
+170
View File
@@ -0,0 +1,170 @@
# XFTP Server Manual Test Suite
Automated integration tests for the XFTP server covering memory and PostgreSQL backends, migration, persistence, blocking, and edge cases.
- `xftp-test.py` — automated test script (143 checks)
- `xftp-server-testing.md` — manual step-by-step guide covering the same scenarios
## Prerequisites
- Linux (tested)
- Python 3
- Haskell toolchain (`cabal`, `ghc`)
- PostgreSQL 16+ (`postgresql-16` package or equivalent)
## Setup
### 1. Build the XFTP binaries
```bash
cabal build -fserver_postgres exe:xftp-server exe:xftp
```
### 2. Set up a local PostgreSQL instance
The test script connects to PostgreSQL via `PGHOST` (Unix socket path). Set up a local instance that you own (no root required):
```bash
# Pick a data directory and socket directory
export PGDATA=/tmp/pgdata
export PGHOST=/tmp/pgsocket
# Clean up any previous instance
rm -rf $PGDATA $PGHOST
mkdir -p $PGDATA $PGHOST
# Initialize the cluster
/usr/lib/postgresql/16/bin/initdb -D $PGDATA --auth=trust --no-locale --encoding=UTF8
# Configure to listen on our socket directory and localhost TCP
echo "unix_socket_directories = '$PGHOST'" >> $PGDATA/postgresql.conf
echo "listen_addresses = '127.0.0.1'" >> $PGDATA/postgresql.conf
# Start the server
/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -l /tmp/pg.log start
# Verify it's running
pg_isready -h $PGHOST
# Expected: /tmp/pgsocket:5432 - accepting connections
```
### 3. Create the required PostgreSQL roles
The test script expects three roles to exist:
- `postgres` — admin role used by the test bracket to create/drop databases
- `xftp` — test user for the XFTP server database
```bash
# Create the postgres admin role (if initdb created the cluster as your user)
psql -h $PGHOST -d postgres -c "CREATE USER postgres WITH SUPERUSER;"
# Create the xftp test user
psql -h $PGHOST -U postgres -d postgres -c "CREATE USER xftp WITH SUPERUSER;"
```
Verify both roles exist:
```bash
psql -h $PGHOST -U postgres -d postgres -c "\du"
```
## Run the test suite
```bash
PGHOST=/tmp/pgsocket python3 tests/manual/xftp-test.py
```
Expected output (abbreviated):
```
XFTP server: /project/git/simplexmq-4/dist-newstyle/.../xftp-server
XFTP client: /project/git/simplexmq-4/dist-newstyle/.../xftp
Test dir: /project/git/simplexmq-4/xftp-test
PGHOST: /tmp/pgsocket
=== 1. Basic send/receive (memory) ===
[PASS] 1.1 rcv1.xftp created
...
=== 12. Recipient cascade and storage accounting ===
...
[PASS] 12.2e DB files after delete (0)
==========================================
Results: 143 passed, 0 failed
==========================================
```
Total runtime: ~3 minutes. Exit code 0 on success, 1 on any failure.
## What the suite tests
| # | Section | Checks | Scope |
|---|---------|--------|-------|
| 1 | Basic memory | 9 | Send/recv/delete on STM backend |
| 2 | Basic PostgreSQL | 7 | Send/recv/delete on PG backend, DB row verification |
| 3 | Migration memory → PG | 12 | Send on memory, partial recv, import, recv remaining |
| 4 | Migration PG → memory | 5 | Export, switch to memory, delete exported files |
| 4b | Send PG, recv memory | 7 | Reverse direction — send on PG, export, recv on memory |
| 5 | Restart persistence | 6 | memory+log / memory no log / PostgreSQL |
| 6 | Config edge cases | 15 | store log conflicts, missing schema, dual-write, import/export guards |
| 7 | File blocking | 13 | Control port block, block state survives migration both directions |
| 8 | Migration edge cases | 23 | Acked recipients preserved, deleted files absent, 20MB multi-chunk, double round-trip |
| 9 | Auth & access control | 9 | allowNewFiles, basic auth (none/wrong/correct/server-no-auth), quota |
| 10 | Control port ops | 8 | No auth, wrong auth, stats, delete, invalid block |
| 11 | Blocked sender delete | 3 | Sender can't delete blocked file |
| 12 | Cascade & storage | 8 | Recipient cascade, disk/DB accounting |
## Troubleshooting
### Server binary not found
```
Binary not found: .../xftp-server
Run: cabal build -fserver_postgres exe:xftp-server
```
Run the cabal build command from step 1.
### Cannot connect to PostgreSQL
```
Cannot connect to PostgreSQL as postgres. Is it running?
```
Check:
1. `pg_isready -h $PGHOST` returns "accepting connections"
2. `PGHOST` environment variable is exported in the shell running the test
3. The `postgres` role exists: `psql -h $PGHOST -U postgres -d postgres -c "SELECT 1;"`
### PostgreSQL user 'xftp' does not exist
```
PostgreSQL user 'xftp' does not exist.
Run: psql -U postgres -c "CREATE USER xftp WITH SUPERUSER;"
```
Run the create-user command from step 3.
### Port 7921 or 15230 already in use
The test uses port 7921 for XFTP and 15230 for the control port. If these are occupied, stop whatever is using them or edit `PORT` / `CONTROL_PORT` constants at the top of `xftp-test.py`.
### Server fails to start mid-test
Check `xftp-test/server.log` in the project directory for the server's stdout/stderr. The test framework prints the last 5 lines of the log on startup failure.
## Stopping the test PostgreSQL instance
```bash
/usr/lib/postgresql/16/bin/pg_ctl -D /tmp/pgdata stop
```
## Cleanup
The test script cleans up its own test directory (`./xftp-test/`) and drops the test database (`xftp_server_store`) on completion. To also remove the PostgreSQL instance:
```bash
/usr/lib/postgresql/16/bin/pg_ctl -D /tmp/pgdata stop
rm -rf /tmp/pgdata /tmp/pgsocket /tmp/pg.log
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
dist-web/
package-lock.json
test-results
+2 -2
View File
@@ -81,7 +81,7 @@ export function encodePING(): Uint8Array { return ascii("PING") }
// -- Response decoding
function readTag(d: Decoder): string {
export function readTag(d: Decoder): string {
const start = d.offset()
while (d.remaining() > 0) {
if (d.buf[d.offset()] === 0x20 || d.buf[d.offset()] === 0x0a) break
@@ -92,7 +92,7 @@ function readTag(d: Decoder): string {
return s
}
function readSpace(d: Decoder): void {
export function readSpace(d: Decoder): void {
if (d.anyByte() !== 0x20) throw new Error("expected space")
}