mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-12 02:22:45 +00:00
Compare commits
21
Commits
v6.5.0-beta.7
...
smp-web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56c12ab3c | ||
|
|
34e3d30c78 | ||
|
|
a0af7377ab | ||
|
|
3f40febe60 | ||
|
|
fd298ae328 | ||
|
|
1f173abf6d | ||
|
|
21f4597dad | ||
|
|
ba6af65c54 | ||
|
|
858fac7f4f | ||
|
|
3536a156d1 | ||
|
|
90432a44b4 | ||
|
|
1e1f897c79 | ||
|
|
0dc2940eff | ||
|
|
8833e5c1b5 | ||
|
|
95b17ada27 | ||
|
|
43cdf55f3b | ||
|
|
bc5ea42bec | ||
|
|
0933cbcb9c | ||
|
|
f2dafd983b | ||
|
|
34c0909c1a | ||
|
|
01fe841e3c |
+22
-12
@@ -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 }}
|
||||
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
# 6.5.1
|
||||
|
||||
Version 6.5.1.0
|
||||
|
||||
XFTP client:
|
||||
- backwards compatible file header decoding.
|
||||
|
||||
# 6.5.0
|
||||
|
||||
Version 6.5.0.17
|
||||
|
||||
SMP agent:
|
||||
- improve subscriptions
|
||||
- reduce memory usage and retries during initial subscription (#1758)
|
||||
- fix race resulting in pending subscriptions never subscribed (#1756)
|
||||
- batch processing of subscription results and errors (#1652)
|
||||
- reduce memory usage of active subscriptions.
|
||||
- drop message after N reception attempts (#1762)
|
||||
- fix possible deadlocks of queue overloading when processing messages (#1713)
|
||||
- improved APIs for short link management and creation.
|
||||
- support multiple link owners in link data (#1701)
|
||||
|
||||
SMP server:
|
||||
- store messages in PostgreSQL (#1622).
|
||||
- reduce memory usage with PostgreSQL database - do not use queue cache (#1637)
|
||||
- fix in-memory server not restoring queue/service associations after 2+ restarts (#1618)
|
||||
|
||||
XFTP server:
|
||||
- support PostgreSQL database.
|
||||
- add server page.
|
||||
- support uploads from web clients.
|
||||
|
||||
Servers:
|
||||
- better socket leak prevention during TLS handshake, NetworkError type to bette diagnose connection errors (#1619)
|
||||
- use "=" as default INI key-value separator (#1767)
|
||||
|
||||
# 6.4.4
|
||||
|
||||
Servers:
|
||||
|
||||
@@ -33,7 +33,7 @@ To initialize the server use `smp-server init -n <fqdn>` (or `smp-server init --
|
||||
|
||||
SMP server uses in-memory persistence with an optional append-only log of created queues that allows to re-start the server without losing the connections. This log is compacted on every server restart, permanently removing suspended and removed queues.
|
||||
|
||||
To enable store log, initialize server using `smp-server -l` command, or modify `smp-server.ini` created during initialization (uncomment `enable: on` option in the store log section). Use `smp-server --help` for other usage tips.
|
||||
To enable store log, initialize server using `smp-server -l` command, or modify `smp-server.ini` created during initialization (uncomment `enable = on` option in the store log section). Use `smp-server --help` for other usage tips.
|
||||
|
||||
Starting from version 2.3.0, when store log is enabled, the server would also enable saving undelivered messages on exit and restoring them on start. This can be disabled via a separate setting `restore_messages` in `smp-server.ini` file. Saving messages would only work if the server is stopped with SIGINT signal (keyboard interrupt), if it is stopped with SIGTERM signal the messages would not be saved.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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` |
|
||||
@@ -67,14 +67,14 @@ if [ ! -f "${confd}/smp-server.ini" ]; then
|
||||
|
||||
# Fix path to certificates
|
||||
if [ -n "${WEB_MANUAL}" ]; then
|
||||
sed -i -e 's|^[^#]*https: |#&|' \
|
||||
-e 's|^[^#]*cert: |#&|' \
|
||||
-e 's|^[^#]*key: |#&|' \
|
||||
-e 's|^port:.*|port: 5223|' \
|
||||
sed -i -e 's|^[^#]*https = |#&|' \
|
||||
-e 's|^[^#]*cert = |#&|' \
|
||||
-e 's|^[^#]*key = |#&|' \
|
||||
-e 's|^port = .*|port = 5223|' \
|
||||
"${confd}/smp-server.ini"
|
||||
else
|
||||
sed -i -e "s|cert: /etc/opt/simplex/web.crt|cert: $cert_path/$ADDR.crt|" \
|
||||
-e "s|key: /etc/opt/simplex/web.key|key: $cert_path/$ADDR.key|" \
|
||||
sed -i -e "s|cert = /etc/opt/simplex/web.crt|cert = $cert_path/$ADDR.crt|" \
|
||||
-e "s|key = /etc/opt/simplex/web.key|key = $cert_path/$ADDR.key|" \
|
||||
"${confd}/smp-server.ini"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -76,7 +76,7 @@ if [ ! -f "${confd}/file-server.ini" ]; then
|
||||
|
||||
# Optionally, set password
|
||||
if [ -n "${PASS}" ]; then
|
||||
sed -i -e "/^# create_password:/a create_password: $PASS" \
|
||||
sed -i -e "/^# create_password =/a create_password = $PASS" \
|
||||
"${confd}/file-server.ini"
|
||||
fi
|
||||
fi
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-test/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,14 @@
|
||||
addToLibrary({
|
||||
js_random_bytes: function(buf, len) {
|
||||
var bytes = new Uint8Array(len);
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
// Node.js fallback
|
||||
var nodeCrypto = require('crypto');
|
||||
var nodeBytes = nodeCrypto.randomBytes(len);
|
||||
bytes.set(nodeBytes);
|
||||
}
|
||||
HEAPU8.set(bytes, buf);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
20080913
|
||||
D. J. Bernstein
|
||||
Public domain.
|
||||
|
||||
SHA-512 implementation from SUPERCOP/NaCl.
|
||||
Source: https://bench.cr.yp.to/supercop.html
|
||||
crypto_hashblocks/sha512/ref/blocks.c
|
||||
crypto_hash/sha512/ref/hash.c
|
||||
|
||||
Combined into a single file for WASM compilation alongside sntrup761.
|
||||
*/
|
||||
|
||||
#include "sha512.h"
|
||||
|
||||
typedef unsigned long long uint64;
|
||||
|
||||
/* -- crypto_hashblocks_sha512 (blocks.c) -- */
|
||||
|
||||
static uint64 load_bigendian(const unsigned char *x)
|
||||
{
|
||||
return
|
||||
(uint64) (x[7]) \
|
||||
| (((uint64) (x[6])) << 8) \
|
||||
| (((uint64) (x[5])) << 16) \
|
||||
| (((uint64) (x[4])) << 24) \
|
||||
| (((uint64) (x[3])) << 32) \
|
||||
| (((uint64) (x[2])) << 40) \
|
||||
| (((uint64) (x[1])) << 48) \
|
||||
| (((uint64) (x[0])) << 56)
|
||||
;
|
||||
}
|
||||
|
||||
static void store_bigendian(unsigned char *x,uint64 u)
|
||||
{
|
||||
x[7] = u; u >>= 8;
|
||||
x[6] = u; u >>= 8;
|
||||
x[5] = u; u >>= 8;
|
||||
x[4] = u; u >>= 8;
|
||||
x[3] = u; u >>= 8;
|
||||
x[2] = u; u >>= 8;
|
||||
x[1] = u; u >>= 8;
|
||||
x[0] = u;
|
||||
}
|
||||
|
||||
#define SHR(x,c) ((x) >> (c))
|
||||
#define ROTR(x,c) (((x) >> (c)) | ((x) << (64 - (c))))
|
||||
|
||||
#define Ch(x,y,z) ((x & y) ^ (~x & z))
|
||||
#define Maj(x,y,z) ((x & y) ^ (x & z) ^ (y & z))
|
||||
#define Sigma0(x) (ROTR(x,28) ^ ROTR(x,34) ^ ROTR(x,39))
|
||||
#define Sigma1(x) (ROTR(x,14) ^ ROTR(x,18) ^ ROTR(x,41))
|
||||
#define sigma0(x) (ROTR(x, 1) ^ ROTR(x, 8) ^ SHR(x,7))
|
||||
#define sigma1(x) (ROTR(x,19) ^ ROTR(x,61) ^ SHR(x,6))
|
||||
|
||||
#define M(w0,w14,w9,w1) w0 = sigma1(w14) + w9 + sigma0(w1) + w0;
|
||||
|
||||
#define EXPAND \
|
||||
M(w0 ,w14,w9 ,w1 ) \
|
||||
M(w1 ,w15,w10,w2 ) \
|
||||
M(w2 ,w0 ,w11,w3 ) \
|
||||
M(w3 ,w1 ,w12,w4 ) \
|
||||
M(w4 ,w2 ,w13,w5 ) \
|
||||
M(w5 ,w3 ,w14,w6 ) \
|
||||
M(w6 ,w4 ,w15,w7 ) \
|
||||
M(w7 ,w5 ,w0 ,w8 ) \
|
||||
M(w8 ,w6 ,w1 ,w9 ) \
|
||||
M(w9 ,w7 ,w2 ,w10) \
|
||||
M(w10,w8 ,w3 ,w11) \
|
||||
M(w11,w9 ,w4 ,w12) \
|
||||
M(w12,w10,w5 ,w13) \
|
||||
M(w13,w11,w6 ,w14) \
|
||||
M(w14,w12,w7 ,w15) \
|
||||
M(w15,w13,w8 ,w0 )
|
||||
|
||||
#define F(w,k) \
|
||||
T1 = h + Sigma1(e) + Ch(e,f,g) + k + w; \
|
||||
T2 = Sigma0(a) + Maj(a,b,c); \
|
||||
h = g; \
|
||||
g = f; \
|
||||
f = e; \
|
||||
e = d + T1; \
|
||||
d = c; \
|
||||
c = b; \
|
||||
b = a; \
|
||||
a = T1 + T2;
|
||||
|
||||
static int crypto_hashblocks_sha512(unsigned char *statebytes,const unsigned char *in,unsigned long long inlen)
|
||||
{
|
||||
uint64 state[8];
|
||||
uint64 a;
|
||||
uint64 b;
|
||||
uint64 c;
|
||||
uint64 d;
|
||||
uint64 e;
|
||||
uint64 f;
|
||||
uint64 g;
|
||||
uint64 h;
|
||||
uint64 T1;
|
||||
uint64 T2;
|
||||
|
||||
a = load_bigendian(statebytes + 0); state[0] = a;
|
||||
b = load_bigendian(statebytes + 8); state[1] = b;
|
||||
c = load_bigendian(statebytes + 16); state[2] = c;
|
||||
d = load_bigendian(statebytes + 24); state[3] = d;
|
||||
e = load_bigendian(statebytes + 32); state[4] = e;
|
||||
f = load_bigendian(statebytes + 40); state[5] = f;
|
||||
g = load_bigendian(statebytes + 48); state[6] = g;
|
||||
h = load_bigendian(statebytes + 56); state[7] = h;
|
||||
|
||||
while (inlen >= 128) {
|
||||
uint64 w0 = load_bigendian(in + 0);
|
||||
uint64 w1 = load_bigendian(in + 8);
|
||||
uint64 w2 = load_bigendian(in + 16);
|
||||
uint64 w3 = load_bigendian(in + 24);
|
||||
uint64 w4 = load_bigendian(in + 32);
|
||||
uint64 w5 = load_bigendian(in + 40);
|
||||
uint64 w6 = load_bigendian(in + 48);
|
||||
uint64 w7 = load_bigendian(in + 56);
|
||||
uint64 w8 = load_bigendian(in + 64);
|
||||
uint64 w9 = load_bigendian(in + 72);
|
||||
uint64 w10 = load_bigendian(in + 80);
|
||||
uint64 w11 = load_bigendian(in + 88);
|
||||
uint64 w12 = load_bigendian(in + 96);
|
||||
uint64 w13 = load_bigendian(in + 104);
|
||||
uint64 w14 = load_bigendian(in + 112);
|
||||
uint64 w15 = load_bigendian(in + 120);
|
||||
|
||||
F(w0 ,0x428a2f98d728ae22ULL)
|
||||
F(w1 ,0x7137449123ef65cdULL)
|
||||
F(w2 ,0xb5c0fbcfec4d3b2fULL)
|
||||
F(w3 ,0xe9b5dba58189dbbcULL)
|
||||
F(w4 ,0x3956c25bf348b538ULL)
|
||||
F(w5 ,0x59f111f1b605d019ULL)
|
||||
F(w6 ,0x923f82a4af194f9bULL)
|
||||
F(w7 ,0xab1c5ed5da6d8118ULL)
|
||||
F(w8 ,0xd807aa98a3030242ULL)
|
||||
F(w9 ,0x12835b0145706fbeULL)
|
||||
F(w10,0x243185be4ee4b28cULL)
|
||||
F(w11,0x550c7dc3d5ffb4e2ULL)
|
||||
F(w12,0x72be5d74f27b896fULL)
|
||||
F(w13,0x80deb1fe3b1696b1ULL)
|
||||
F(w14,0x9bdc06a725c71235ULL)
|
||||
F(w15,0xc19bf174cf692694ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0xe49b69c19ef14ad2ULL)
|
||||
F(w1 ,0xefbe4786384f25e3ULL)
|
||||
F(w2 ,0x0fc19dc68b8cd5b5ULL)
|
||||
F(w3 ,0x240ca1cc77ac9c65ULL)
|
||||
F(w4 ,0x2de92c6f592b0275ULL)
|
||||
F(w5 ,0x4a7484aa6ea6e483ULL)
|
||||
F(w6 ,0x5cb0a9dcbd41fbd4ULL)
|
||||
F(w7 ,0x76f988da831153b5ULL)
|
||||
F(w8 ,0x983e5152ee66dfabULL)
|
||||
F(w9 ,0xa831c66d2db43210ULL)
|
||||
F(w10,0xb00327c898fb213fULL)
|
||||
F(w11,0xbf597fc7beef0ee4ULL)
|
||||
F(w12,0xc6e00bf33da88fc2ULL)
|
||||
F(w13,0xd5a79147930aa725ULL)
|
||||
F(w14,0x06ca6351e003826fULL)
|
||||
F(w15,0x142929670a0e6e70ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0x27b70a8546d22ffcULL)
|
||||
F(w1 ,0x2e1b21385c26c926ULL)
|
||||
F(w2 ,0x4d2c6dfc5ac42aedULL)
|
||||
F(w3 ,0x53380d139d95b3dfULL)
|
||||
F(w4 ,0x650a73548baf63deULL)
|
||||
F(w5 ,0x766a0abb3c77b2a8ULL)
|
||||
F(w6 ,0x81c2c92e47edaee6ULL)
|
||||
F(w7 ,0x92722c851482353bULL)
|
||||
F(w8 ,0xa2bfe8a14cf10364ULL)
|
||||
F(w9 ,0xa81a664bbc423001ULL)
|
||||
F(w10,0xc24b8b70d0f89791ULL)
|
||||
F(w11,0xc76c51a30654be30ULL)
|
||||
F(w12,0xd192e819d6ef5218ULL)
|
||||
F(w13,0xd69906245565a910ULL)
|
||||
F(w14,0xf40e35855771202aULL)
|
||||
F(w15,0x106aa07032bbd1b8ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0x19a4c116b8d2d0c8ULL)
|
||||
F(w1 ,0x1e376c085141ab53ULL)
|
||||
F(w2 ,0x2748774cdf8eeb99ULL)
|
||||
F(w3 ,0x34b0bcb5e19b48a8ULL)
|
||||
F(w4 ,0x391c0cb3c5c95a63ULL)
|
||||
F(w5 ,0x4ed8aa4ae3418acbULL)
|
||||
F(w6 ,0x5b9cca4f7763e373ULL)
|
||||
F(w7 ,0x682e6ff3d6b2b8a3ULL)
|
||||
F(w8 ,0x748f82ee5defb2fcULL)
|
||||
F(w9 ,0x78a5636f43172f60ULL)
|
||||
F(w10,0x84c87814a1f0ab72ULL)
|
||||
F(w11,0x8cc702081a6439ecULL)
|
||||
F(w12,0x90befffa23631e28ULL)
|
||||
F(w13,0xa4506cebde82bde9ULL)
|
||||
F(w14,0xbef9a3f7b2c67915ULL)
|
||||
F(w15,0xc67178f2e372532bULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0xca273eceea26619cULL)
|
||||
F(w1 ,0xd186b8c721c0c207ULL)
|
||||
F(w2 ,0xeada7dd6cde0eb1eULL)
|
||||
F(w3 ,0xf57d4f7fee6ed178ULL)
|
||||
F(w4 ,0x06f067aa72176fbaULL)
|
||||
F(w5 ,0x0a637dc5a2c898a6ULL)
|
||||
F(w6 ,0x113f9804bef90daeULL)
|
||||
F(w7 ,0x1b710b35131c471bULL)
|
||||
F(w8 ,0x28db77f523047d84ULL)
|
||||
F(w9 ,0x32caab7b40c72493ULL)
|
||||
F(w10,0x3c9ebe0a15c9bebcULL)
|
||||
F(w11,0x431d67c49c100d4cULL)
|
||||
F(w12,0x4cc5d4becb3e42b6ULL)
|
||||
F(w13,0x597f299cfc657e2aULL)
|
||||
F(w14,0x5fcb6fab3ad6faecULL)
|
||||
F(w15,0x6c44198c4a475817ULL)
|
||||
|
||||
a += state[0];
|
||||
b += state[1];
|
||||
c += state[2];
|
||||
d += state[3];
|
||||
e += state[4];
|
||||
f += state[5];
|
||||
g += state[6];
|
||||
h += state[7];
|
||||
|
||||
state[0] = a;
|
||||
state[1] = b;
|
||||
state[2] = c;
|
||||
state[3] = d;
|
||||
state[4] = e;
|
||||
state[5] = f;
|
||||
state[6] = g;
|
||||
state[7] = h;
|
||||
|
||||
in += 128;
|
||||
inlen -= 128;
|
||||
}
|
||||
|
||||
store_bigendian(statebytes + 0,state[0]);
|
||||
store_bigendian(statebytes + 8,state[1]);
|
||||
store_bigendian(statebytes + 16,state[2]);
|
||||
store_bigendian(statebytes + 24,state[3]);
|
||||
store_bigendian(statebytes + 32,state[4]);
|
||||
store_bigendian(statebytes + 40,state[5]);
|
||||
store_bigendian(statebytes + 48,state[6]);
|
||||
store_bigendian(statebytes + 56,state[7]);
|
||||
|
||||
return inlen;
|
||||
}
|
||||
|
||||
/* -- crypto_hash_sha512 (hash.c) -- */
|
||||
|
||||
static const unsigned char iv[64] = {
|
||||
0x6a,0x09,0xe6,0x67,0xf3,0xbc,0xc9,0x08,
|
||||
0xbb,0x67,0xae,0x85,0x84,0xca,0xa7,0x3b,
|
||||
0x3c,0x6e,0xf3,0x72,0xfe,0x94,0xf8,0x2b,
|
||||
0xa5,0x4f,0xf5,0x3a,0x5f,0x1d,0x36,0xf1,
|
||||
0x51,0x0e,0x52,0x7f,0xad,0xe6,0x82,0xd1,
|
||||
0x9b,0x05,0x68,0x8c,0x2b,0x3e,0x6c,0x1f,
|
||||
0x1f,0x83,0xd9,0xab,0xfb,0x41,0xbd,0x6b,
|
||||
0x5b,0xe0,0xcd,0x19,0x13,0x7e,0x21,0x79
|
||||
};
|
||||
|
||||
void crypto_hash_sha512(unsigned char *out,
|
||||
const unsigned char *in,
|
||||
unsigned long long inlen)
|
||||
{
|
||||
unsigned char h[64];
|
||||
unsigned char padded[256];
|
||||
int i;
|
||||
unsigned long long bytes = inlen;
|
||||
|
||||
for (i = 0;i < 64;++i) h[i] = iv[i];
|
||||
|
||||
crypto_hashblocks_sha512(h,in,inlen);
|
||||
in += inlen;
|
||||
inlen &= 127;
|
||||
in -= inlen;
|
||||
|
||||
for (i = 0;i < (int)inlen;++i) padded[i] = in[i];
|
||||
padded[inlen] = 0x80;
|
||||
|
||||
if (inlen < 112) {
|
||||
for (i = inlen + 1;i < 119;++i) padded[i] = 0;
|
||||
padded[119] = bytes >> 61;
|
||||
padded[120] = bytes >> 53;
|
||||
padded[121] = bytes >> 45;
|
||||
padded[122] = bytes >> 37;
|
||||
padded[123] = bytes >> 29;
|
||||
padded[124] = bytes >> 21;
|
||||
padded[125] = bytes >> 13;
|
||||
padded[126] = bytes >> 5;
|
||||
padded[127] = bytes << 3;
|
||||
crypto_hashblocks_sha512(h,padded,128);
|
||||
} else {
|
||||
for (i = inlen + 1;i < 247;++i) padded[i] = 0;
|
||||
padded[247] = bytes >> 61;
|
||||
padded[248] = bytes >> 53;
|
||||
padded[249] = bytes >> 45;
|
||||
padded[250] = bytes >> 37;
|
||||
padded[251] = bytes >> 29;
|
||||
padded[252] = bytes >> 21;
|
||||
padded[253] = bytes >> 13;
|
||||
padded[254] = bytes >> 5;
|
||||
padded[255] = bytes << 3;
|
||||
crypto_hashblocks_sha512(h,padded,256);
|
||||
}
|
||||
|
||||
for (i = 0;i < 64;++i) out[i] = h[i];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface Sntrup761Module {
|
||||
_sntrup761_wasm_keypair(pk: number, sk: number): void
|
||||
_sntrup761_wasm_enc(c: number, k: number, pk: number): void
|
||||
_sntrup761_wasm_dec(k: number, c: number, sk: number): void
|
||||
_malloc(size: number): number
|
||||
_free(ptr: number): void
|
||||
HEAPU8: Uint8Array
|
||||
}
|
||||
|
||||
declare function createSntrup761(): Promise<Sntrup761Module>
|
||||
export default createSntrup761
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* WASM wrapper for sntrup761.
|
||||
* Provides JS-callable functions with RNG from JS imports.
|
||||
*
|
||||
* Build: emcc sntrup761_wasm.c sntrup761.c sha512.c -O2 -o sntrup761.js \
|
||||
* -s EXPORTED_FUNCTIONS='["_sntrup761_wasm_keypair","_sntrup761_wasm_enc","_sntrup761_wasm_dec","_malloc","_free"]' \
|
||||
* -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
|
||||
*/
|
||||
|
||||
#include "sntrup761.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Import RNG from JS environment */
|
||||
extern void js_random_bytes(unsigned char *buf, int len);
|
||||
|
||||
/* RNG callback adapter for sntrup761 */
|
||||
static void wasm_random(void *ctx, size_t length, uint8_t *dst) {
|
||||
(void)ctx;
|
||||
js_random_bytes(dst, (int)length);
|
||||
}
|
||||
|
||||
/* JS-callable wrappers */
|
||||
|
||||
void sntrup761_wasm_keypair(unsigned char *pk, unsigned char *sk) {
|
||||
sntrup761_keypair(pk, sk, NULL, wasm_random);
|
||||
}
|
||||
|
||||
void sntrup761_wasm_enc(unsigned char *c, unsigned char *k, const unsigned char *pk) {
|
||||
sntrup761_enc(c, k, pk, NULL, wasm_random);
|
||||
}
|
||||
|
||||
void sntrup761_wasm_dec(unsigned char *k, const unsigned char *c, const unsigned char *sk) {
|
||||
sntrup761_dec(k, c, sk);
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Agent message encoding/decoding.
|
||||
// Mirrors: Simplex.Messaging.Agent.Protocol (AgentMsgEnvelope, AgentMessage, APrivHeader, AMessage)
|
||||
|
||||
import {
|
||||
Decoder, concatBytes,
|
||||
encodeBytes, decodeBytes,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeInt64, decodeInt64,
|
||||
encodeWord16, decodeWord16,
|
||||
encodeMaybe, decodeMaybe,
|
||||
encodeNonEmpty, decodeNonEmpty,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Constants (Agent/Protocol.hs:318-319)
|
||||
|
||||
export const currentSMPAgentVersion = 7
|
||||
|
||||
// -- AMessage (Agent/Protocol.hs:1001-1020)
|
||||
|
||||
export type AMessage =
|
||||
| {type: "HELLO"}
|
||||
| {type: "A_MSG", body: Uint8Array}
|
||||
| {type: "A_RCVD", receipts: AMessageReceipt[]} // NonEmpty
|
||||
| {type: "EREADY", lastDecryptedMsgId: bigint}
|
||||
|
||||
// Agent/Protocol.hs:1040-1045
|
||||
export interface AMessageReceipt {
|
||||
agentMsgId: bigint // Int64
|
||||
msgHash: Uint8Array // ByteString (32-byte SHA-256)
|
||||
rcptInfo: Uint8Array // MsgReceiptInfo (ByteString, Large-encoded)
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:1078-1100
|
||||
export function encodeAMessage(msg: AMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "HELLO": return new Uint8Array([0x48]) // "H"
|
||||
case "A_MSG": return concatBytes(new Uint8Array([0x4D]), msg.body) // "M" + Tail
|
||||
case "A_RCVD": return concatBytes(new Uint8Array([0x56]), encodeNonEmpty(encodeAMessageReceipt, msg.receipts)) // "V" + NonEmpty
|
||||
case "EREADY": return concatBytes(new Uint8Array([0x45]), encodeInt64(msg.lastDecryptedMsgId)) // "E" + Int64
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAMessage(d: Decoder): AMessage {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x48: return {type: "HELLO"} // 'H'
|
||||
case 0x4D: return {type: "A_MSG", body: d.takeAll()} // 'M' + Tail
|
||||
case 0x56: return {type: "A_RCVD", receipts: decodeNonEmpty(decodeAMessageReceipt, d)} // 'V'
|
||||
case 0x45: return {type: "EREADY", lastDecryptedMsgId: decodeInt64(d)} // 'E'
|
||||
// Queue management tags (not needed for chat messages, but recognized for decoding)
|
||||
case 0x51: { // 'Q'
|
||||
const sub = d.anyByte()
|
||||
switch (sub) {
|
||||
case 0x43: // 'C' = A_QCONT
|
||||
case 0x41: // 'A' = QADD
|
||||
case 0x4B: // 'K' = QKEY
|
||||
case 0x55: // 'U' = QUSE
|
||||
case 0x54: // 'T' = QTEST
|
||||
throw new Error("decodeAMessage: queue management message (Q" + String.fromCharCode(sub) + ") not implemented")
|
||||
default:
|
||||
throw new Error("decodeAMessage: unknown Q-subtag " + sub)
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error("decodeAMessage: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:1106-1111
|
||||
function encodeAMessageReceipt(r: AMessageReceipt): Uint8Array {
|
||||
return concatBytes(encodeInt64(r.agentMsgId), encodeBytes(r.msgHash), encodeLarge(r.rcptInfo))
|
||||
}
|
||||
|
||||
function decodeAMessageReceipt(d: Decoder): AMessageReceipt {
|
||||
return {agentMsgId: decodeInt64(d), msgHash: decodeBytes(d), rcptInfo: decodeLarge(d)}
|
||||
}
|
||||
|
||||
// -- APrivHeader (Agent/Protocol.hs:946-957)
|
||||
|
||||
export interface APrivHeader {
|
||||
sndMsgId: bigint // AgentMsgId = Int64
|
||||
prevMsgHash: Uint8Array // MsgHash = ByteString
|
||||
}
|
||||
|
||||
export function encodeAPrivHeader(h: APrivHeader): Uint8Array {
|
||||
return concatBytes(encodeInt64(h.sndMsgId), encodeBytes(h.prevMsgHash))
|
||||
}
|
||||
|
||||
export function decodeAPrivHeader(d: Decoder): APrivHeader {
|
||||
return {sndMsgId: decodeInt64(d), prevMsgHash: decodeBytes(d)}
|
||||
}
|
||||
|
||||
// -- AgentMessage (Agent/Protocol.hs:866-888)
|
||||
|
||||
export type AgentMessage =
|
||||
| {type: "connInfo", cInfo: Uint8Array}
|
||||
| {type: "connInfoReply", smpQueues: Uint8Array[], cInfo: Uint8Array} // NonEmpty raw-encoded SMPQueueInfo
|
||||
| {type: "ratchetInfo", info: Uint8Array}
|
||||
| {type: "message", header: APrivHeader, msg: AMessage}
|
||||
|
||||
export function encodeAgentMessage(msg: AgentMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "connInfo":
|
||||
return concatBytes(new Uint8Array([0x49]), msg.cInfo) // 'I' + Tail
|
||||
case "connInfoReply":
|
||||
// 'D' + NonEmpty SMPQueueInfo + Tail cInfo
|
||||
// SMPQueueInfo encoding is complex; for now encode the raw bytes
|
||||
return concatBytes(
|
||||
new Uint8Array([0x44]),
|
||||
encodeNonEmpty(b => b, msg.smpQueues),
|
||||
msg.cInfo,
|
||||
)
|
||||
case "ratchetInfo":
|
||||
return concatBytes(new Uint8Array([0x52]), msg.info) // 'R' + Tail
|
||||
case "message":
|
||||
return concatBytes(new Uint8Array([0x4D]), encodeAPrivHeader(msg.header), encodeAMessage(msg.msg)) // 'M' + header + msg
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAgentMessage(d: Decoder): AgentMessage {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x49: return {type: "connInfo", cInfo: d.takeAll()} // 'I' + Tail
|
||||
case 0x44: { // 'D'
|
||||
// NonEmpty SMPQueueInfo is complex to decode; skip for now, just capture raw
|
||||
throw new Error("decodeAgentMessage: connInfoReply ('D') not implemented")
|
||||
}
|
||||
case 0x52: return {type: "ratchetInfo", info: d.takeAll()} // 'R' + Tail
|
||||
case 0x4D: return {type: "message", header: decodeAPrivHeader(d), msg: decodeAMessage(d)} // 'M'
|
||||
default:
|
||||
throw new Error("decodeAgentMessage: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// -- AgentMsgEnvelope (Agent/Protocol.hs:812-861)
|
||||
|
||||
export type AgentMsgEnvelope =
|
||||
| {type: "confirmation", agentVersion: number, e2eEncryption: Uint8Array | null, encConnInfo: Uint8Array}
|
||||
| {type: "envelope", agentVersion: number, encAgentMessage: Uint8Array}
|
||||
| {type: "invitation", agentVersion: number, connReqBytes: Uint8Array, connInfo: Uint8Array}
|
||||
| {type: "ratchetKey", agentVersion: number, e2eEncryption: Uint8Array, info: Uint8Array}
|
||||
|
||||
// Agent/Protocol.hs:835-843
|
||||
export function encodeAgentMsgEnvelope(env: AgentMsgEnvelope): Uint8Array {
|
||||
switch (env.type) {
|
||||
case "confirmation":
|
||||
// (agentVersion, 'C', Maybe SndE2ERatchetParams, Tail encConnInfo)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x43]), // 'C'
|
||||
encodeMaybe(b => b, env.e2eEncryption), // e2eEncryption is already smpEncoded bytes or null
|
||||
env.encConnInfo, // Tail
|
||||
)
|
||||
case "envelope":
|
||||
// (agentVersion, 'M', Tail encAgentMessage)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x4D]), // 'M'
|
||||
env.encAgentMessage, // Tail
|
||||
)
|
||||
case "invitation":
|
||||
// (agentVersion, 'I', Large connReqBytes, Tail connInfo)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x49]), // 'I'
|
||||
encodeLarge(env.connReqBytes),
|
||||
env.connInfo, // Tail
|
||||
)
|
||||
case "ratchetKey":
|
||||
// (agentVersion, 'R', e2eEncryption, Tail info)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x52]), // 'R'
|
||||
env.e2eEncryption, // already smpEncoded
|
||||
env.info, // Tail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:844-861
|
||||
export function decodeAgentMsgEnvelope(d: Decoder): AgentMsgEnvelope {
|
||||
const agentVersion = decodeWord16(d)
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x43: // 'C' Confirmation
|
||||
// e2eEncryption_ is Maybe (SndE2ERatchetParams 'X448), encConnInfo is Tail
|
||||
// Full parsing of E2ERatchetParams needed to split the boundary — not implemented in spike
|
||||
throw new Error("decodeAgentMsgEnvelope: confirmation ('C') not fully implemented")
|
||||
case 0x4D: // 'M' Message envelope
|
||||
return {type: "envelope", agentVersion, encAgentMessage: d.takeAll()} // Tail
|
||||
case 0x49: { // 'I' Invitation
|
||||
const connReqBytes = decodeLarge(d)
|
||||
const connInfo = d.takeAll() // Tail
|
||||
return {type: "invitation", agentVersion, connReqBytes, connInfo}
|
||||
}
|
||||
case 0x52: { // 'R' RatchetKey
|
||||
// e2eEncryption is an E2ERatchetParams — variable-length, not Tail
|
||||
// For now, capture remaining minus nothing (since info is Tail and comes last)
|
||||
// This is tricky: e2eEncryption is smpEncoded E2ERatchetParams, info is Tail
|
||||
// We can't easily split without knowing the E2ERatchetParams length
|
||||
// For the spike, just capture all remaining as raw
|
||||
throw new Error("decodeAgentMsgEnvelope: ratchetKey ('R') not fully implemented")
|
||||
}
|
||||
default:
|
||||
throw new Error("decodeAgentMsgEnvelope: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
dist-web/
|
||||
package-lock.json
|
||||
test-results
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user