Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e19a18aed |
@@ -24,8 +24,6 @@ jobs:
|
||||
- name: Clone project
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build changelog
|
||||
id: build_changelog
|
||||
@@ -60,11 +58,11 @@ jobs:
|
||||
# =============================
|
||||
|
||||
build:
|
||||
name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }}, GHC: ${{ matrix.ghc }}"
|
||||
name: "ubuntu-${{ matrix.os }}, GHC: ${{ matrix.ghc }}"
|
||||
needs: maybe-release
|
||||
env:
|
||||
apps: "smp-server xftp-server ntf-server xftp"
|
||||
runs-on: ${{ matrix.runner }}
|
||||
runs-on: ubuntu-${{ matrix.os }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -83,41 +81,21 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: x86-64
|
||||
runner: "ubuntu-22.04"
|
||||
ghc: "8.10.7"
|
||||
platform_name: 22_04-8.10.7
|
||||
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: x86-64
|
||||
runner: "ubuntu-22.04"
|
||||
ghc: "9.6.3"
|
||||
platform_name: 22_04-x86-64
|
||||
should_run: true
|
||||
- os: 24.04
|
||||
os_underscore: 24_04
|
||||
arch: x86-64
|
||||
runner: "ubuntu-24.04"
|
||||
ghc: "9.6.3"
|
||||
should_run: true
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: aarch64
|
||||
runner: "ubuntu-22.04-arm"
|
||||
ghc: "9.6.3"
|
||||
should_run: true
|
||||
- os: 24.04
|
||||
os_underscore: 24_04
|
||||
arch: aarch64
|
||||
runner: "ubuntu-24.04-arm"
|
||||
ghc: "9.6.3"
|
||||
platform_name: 24_04-x86-64
|
||||
should_run: true
|
||||
steps:
|
||||
- name: Clone project
|
||||
if: matrix.should_run == true
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: matrix.should_run == true
|
||||
@@ -149,7 +127,11 @@ jobs:
|
||||
context: .
|
||||
load: true
|
||||
file: Dockerfile.build
|
||||
tags: build/${{ matrix.os }}:latest
|
||||
tags: build/${{ matrix.platform_name }}:latest
|
||||
cache-from: |
|
||||
type=gha
|
||||
type=gha,scope=master
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
TAG=${{ matrix.os }}
|
||||
GHC=${{ matrix.ghc }}
|
||||
@@ -161,37 +143,32 @@ jobs:
|
||||
path: |
|
||||
~/.cabal/store
|
||||
dist-newstyle
|
||||
key: ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplexmq.cabal') }}
|
||||
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplexmq.cabal') }}
|
||||
|
||||
- name: Start container
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
docker run -t -d \
|
||||
--device /dev/fuse \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt apparmor:unconfined \
|
||||
--name builder \
|
||||
-v ~/.cabal:/root/.cabal \
|
||||
-v /home/runner/work/_temp:/home/runner/work/_temp \
|
||||
-v ${{ github.workspace }}:/project \
|
||||
build/${{ matrix.os }}:latest
|
||||
build/${{ matrix.platform_name }}:latest
|
||||
|
||||
- name: Build smp-server, xftp-server (postgresql) and tests
|
||||
- name: Build smp-server (postgresql) and tests
|
||||
if: matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
run: |
|
||||
chmod -fR 777 ~/.cabal ./dist-newstyle || :; git config --global --add safe.directory '*'
|
||||
cabal clean
|
||||
cabal update
|
||||
cabal build --jobs=$(nproc) --enable-tests -fserver_postgres
|
||||
mkdir -p /out
|
||||
for i in smp-server xftp-server simplexmq-test; do
|
||||
for i in smp-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 /out/xftp-server
|
||||
strip /out/smp-server
|
||||
|
||||
- name: Copy simplexmq-test from container
|
||||
if: matrix.should_run == true
|
||||
@@ -199,29 +176,19 @@ jobs:
|
||||
run: |
|
||||
docker cp builder:/out/simplexmq-test .
|
||||
|
||||
- name: Copy smp-server, xftp-server (postgresql) from container and prepare it
|
||||
- name: Copy smp-server (postgresql) from container and prepare it
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
id: prepare-postgres
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'bins<<EOF\n' > bins.output
|
||||
printf 'hashes<<EOF\n' > hashes.output
|
||||
name="smp-server-postgres-ubuntu-${{ matrix.platform_name }}"
|
||||
docker cp builder:/out/smp-server $name
|
||||
|
||||
for i in smp-server xftp-server; do
|
||||
name="${i}-postgres-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
|
||||
docker cp builder:/out/$i $name
|
||||
path="${{ github.workspace }}/$name"
|
||||
echo "bin=$path" >> $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"
|
||||
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
|
||||
printf 'hash=%s' "$hash" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build everything else (standard)
|
||||
if: matrix.should_run == true
|
||||
@@ -246,9 +213,9 @@ jobs:
|
||||
printf 'bins<<EOF\n' > bins.output
|
||||
printf 'hashes<<EOF\n' > hashes.output
|
||||
for i in ${{ env.apps }}; do
|
||||
name="$i-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
|
||||
mv ./out/$i ./$i-ubuntu-${{ matrix.platform_name }}
|
||||
|
||||
mv ./out/$i ./$name
|
||||
name="$i-ubuntu-${{ matrix.platform_name }}"
|
||||
|
||||
path="${{ github.workspace }}/$name"
|
||||
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
|
||||
@@ -271,15 +238,15 @@ jobs:
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
${{ steps.prepare-regular.outputs.hashes }}
|
||||
${{ steps.prepare-postgres.outputs.hashes }}
|
||||
${{ steps.prepare-postgres.outputs.hash }}
|
||||
files: |
|
||||
${{ steps.prepare-regular.outputs.bins }}
|
||||
${{ steps.prepare-postgres.outputs.bins }}
|
||||
${{ steps.prepare-postgres.outputs.bin }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Test
|
||||
if: matrix.should_run == true && matrix.arch == 'x86-64'
|
||||
if: matrix.should_run == true
|
||||
timeout-minutes: 120
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
steps:
|
||||
- name: Clone project
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: simplex-chat/docker-login-action@v3
|
||||
@@ -41,17 +39,9 @@ jobs:
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: simplex-chat/docker-build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
APP=${{ matrix.app }}
|
||||
|
||||
@@ -11,8 +11,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Get latest release
|
||||
shell: bash
|
||||
@@ -27,7 +25,7 @@ jobs:
|
||||
|
||||
- name: Execute reproduce script
|
||||
run: |
|
||||
${GITHUB_WORKSPACE}/scripts/simplexmq-reproduce-builds.sh "$TAG" || :
|
||||
${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
|
||||
|
||||
- name: Check if build has been reproduced
|
||||
env:
|
||||
@@ -35,7 +33,7 @@ jobs:
|
||||
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
|
||||
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
|
||||
run: |
|
||||
if [ -f "${GITHUB_WORKSPACE}/${TAG}-simplexmq/_sha256sums" ]; then
|
||||
if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
|
||||
exit 0
|
||||
else
|
||||
curl --proto '=https' --tlsv1.2 -sSf \
|
||||
|
||||
@@ -11,4 +11,3 @@ cabal.project.local~
|
||||
.hpc/
|
||||
*.tix
|
||||
.coverage
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[submodule "cbits/libbbs"]
|
||||
path = cbits/libbbs
|
||||
url = https://github.com/simplex-chat/libbbs.git
|
||||
[submodule "cbits/blst"]
|
||||
path = cbits/blst
|
||||
url = https://github.com/supranational/blst.git
|
||||
[submodule "cbits/libsecp256k1"]
|
||||
path = cbits/libsecp256k1
|
||||
url = https://github.com/bitcoin-core/secp256k1.git
|
||||
@@ -1,53 +0,0 @@
|
||||
# XFTPClientAgent Pattern
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Changes: client.ts
|
||||
3. Changes: agent.ts
|
||||
4. Changes: test/browser.test.ts
|
||||
5. Verification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Add `XFTPClientAgent` — a per-server connection pool matching the Haskell pattern. The agent caches `XFTPClient` instances by server URL. All orchestration functions (`uploadFile`, `downloadFile`, `deleteFile`) take `agent` as first parameter and use `getXFTPServerClient(agent, server)` instead of calling `connectXFTP` directly. Connections stay open on success; the caller creates and closes the agent.
|
||||
|
||||
`connectXFTP` and `closeXFTP` stay exported (used by `XFTPWebTests.hs` Haskell tests). The `browserClients` hack, per-function `connections: Map`, and `getOrConnect` are deleted.
|
||||
|
||||
## Changes: client.ts
|
||||
|
||||
**Add** after types section: `XFTPClientAgent` interface, `newXFTPAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `closeXFTPAgent`.
|
||||
|
||||
**Delete**: `browserClients` Map and all `isNode` browser-cache checks in `connectXFTP` and `closeXFTP`.
|
||||
|
||||
**Revert `closeXFTP`** to unconditional `c.transport.close()` (browser transport.close() is already a no-op).
|
||||
|
||||
`connectXFTP` stays exported (backward compat) but becomes a raw low-level function — no caching.
|
||||
|
||||
## Changes: agent.ts
|
||||
|
||||
**Imports**: replace `connectXFTP`/`closeXFTP` with `getXFTPServerClient`/`closeXFTPAgent` etc.
|
||||
|
||||
**Re-export** from agent.ts: `newXFTPAgent`, `closeXFTPAgent`, `XFTPClientAgent`.
|
||||
|
||||
**`uploadFile`**: add `agent: XFTPClientAgent` as first param. Replace `connectXFTP` → `getXFTPServerClient`. Remove `finally { closeXFTP }`. Pass `agent` to `uploadRedirectDescription`.
|
||||
|
||||
**`uploadRedirectDescription`**: change from `(client, server, innerFd)` to `(agent, server, innerFd)`. Get client via `getXFTPServerClient`.
|
||||
|
||||
**`downloadFile`**: add `agent` param. Delete local `connections: Map`. Replace `getOrConnect` → `getXFTPServerClient`. Remove finally cleanup. Pass `agent` to `downloadWithRedirect`.
|
||||
|
||||
**`downloadWithRedirect`**: add `agent` param. Same replacements. Remove try/catch cleanup. Recursive call passes `agent`.
|
||||
|
||||
**`deleteFile`**: add `agent` param. Same pattern.
|
||||
|
||||
**Delete**: `getOrConnect` function entirely.
|
||||
|
||||
## Changes: test/browser.test.ts
|
||||
|
||||
Create agent before operations, pass to upload/download, close in finally.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npx vitest --run` — browser round-trip test passes
|
||||
2. No remaining `browserClients`, `getOrConnect`, or per-function `connections: Map` locals
|
||||
3. `connectXFTP` and `closeXFTP` still exported (XFTPWebTests.hs compat)
|
||||
4. All orchestration functions take `agent` as first param
|
||||
@@ -1,110 +1,3 @@
|
||||
# Unreleased
|
||||
|
||||
Crypto:
|
||||
- Ethereum primitives for SimpleX names: secp256k1 with public key
|
||||
recovery (vendored libsecp256k1), BIP-39 mnemonics, BIP-32 key derivation,
|
||||
Keccak-256, EIP-55 addresses and EIP-712 typed data hashing. Client-side
|
||||
signing only - no transaction construction and no chain writes; the resolver
|
||||
path remains read-only. See `plans/2026-08-05-eth-crypto-bindings.md`.
|
||||
- ERC-5564 stealth addresses (`Simplex.Messaging.Eth.Stealth`): a recipient
|
||||
publishes a spend/view meta-address, a sender derives a one-time address from
|
||||
it non-interactively, and only the recipient can find or spend from it. Adds
|
||||
`publicKeyTweakMul` and `publicKeyTweakAdd` to the secp256k1 bindings.
|
||||
|
||||
# 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:
|
||||
- fix server pages when source code is not specified.
|
||||
- include commit SHA in printed version and in web page (#1608).
|
||||
|
||||
SMP server:
|
||||
- support short SimpleX addresses in server information page (#1600).
|
||||
- wrap all queries in transactions (#1603).
|
||||
|
||||
SMP agent:
|
||||
- chat relay address type for short links (#1602).
|
||||
- extend xrcp certificate validity 1 hour in the past, to allow out of sync clocks (#1601).
|
||||
|
||||
# 6.4.3
|
||||
|
||||
SMP agent:
|
||||
- fix some connection errors by updating contact request server hosts to match server in short link (#1597).
|
||||
|
||||
SMP server:
|
||||
- support short link URI as queue identifier in control port commands (#1596).
|
||||
|
||||
# 6.4.2
|
||||
|
||||
SMP server:
|
||||
- fix memory leak when connection interrupts straight after client connects.
|
||||
- do not include repeated queue blocking into stats/quota.
|
||||
|
||||
XFTP server:
|
||||
- prometheus metrics
|
||||
|
||||
# 6.4.1
|
||||
|
||||
SMP protocol:
|
||||
- create notification credentials via NEW command that creates the queue (#1586)
|
||||
|
||||
SMP server:
|
||||
- control port session improvements (#1591)
|
||||
- additional stat counter for ntf credentials created together with the queue (#1589)
|
||||
|
||||
# 6.4.0
|
||||
|
||||
SMP protocol (server/client):
|
||||
- support associated queue data and short connection links (see [RFC](./rfcs/2025-03-16-smp-queues.md)).
|
||||
- service certificates to optimize subscriptions.
|
||||
|
||||
SMP agent:
|
||||
- support retries for interactive connection handshakes.
|
||||
- use web port 443 by default for preset servers.
|
||||
- use static RNG function to avoid creating dynamic C stubs when generating sntrup keys (it was detected as Dynamic Code Loading in GrapheneOS).
|
||||
- different timeouts for interactive and background operations.
|
||||
|
||||
Ntf server:
|
||||
- PostgreSQL storage.
|
||||
- Prometheus metrics.
|
||||
- use service certificates.
|
||||
- fix repeat token registration.
|
||||
|
||||
# 6.3.2
|
||||
|
||||
Servers:
|
||||
@@ -125,7 +18,7 @@ Servers:
|
||||
- update script (simplex-servers-update) downloads scripts from the specified or the latest stable tag.
|
||||
|
||||
SMP server:
|
||||
- support for PostgreSQL database for queue records for higher traffic servers.
|
||||
- support for PostrgreSQL database for queue records for higher traffic servers.
|
||||
- fix old clients sending messages to new servers (#1443)
|
||||
- remove empty journals when opening message queues and expiring idle queues (#1456, #1458).
|
||||
- additional start options (#1465):
|
||||
@@ -181,7 +74,7 @@ Servers: more reliable restoring of state.
|
||||
|
||||
SMP server: reduced memory usage and faster start.
|
||||
|
||||
Notifications: compensate for iOS notifications being dropped by Apple while device is offline (#1378):
|
||||
Notifications: compensate for iOS notifications being droppted by Apple while device is offline (#1378):
|
||||
- Ntf server: send multiple SMP notifications in one iOS notification.
|
||||
- Agent: get multiple messages for one iOS notification.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SimpleXMQ
|
||||
|
||||
[](https://github.com/simplex-chat/simplexmq/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplexmq/actions?query=workflow%3Abuild)
|
||||
[](https://github.com/simplex-chat/simplexmq/releases)
|
||||
|
||||
📢 SimpleXMQ v1 is released - with many security, privacy and efficiency improvements, new functionality - see [release notes](https://github.com/simplex-chat/simplexmq/releases/tag/v1.0.0).
|
||||
@@ -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.
|
||||
|
||||
@@ -116,7 +116,7 @@ On Linux, you can deploy smp and xftp server using Docker. This will download im
|
||||
2. Run your Docker container.
|
||||
|
||||
- `smp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain**. `-e "pass=password"` is optional variable to password-protect your `smp` server:
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -129,7 +129,7 @@ On Linux, you can deploy smp and xftp server using Docker. This will download im
|
||||
```
|
||||
|
||||
- `xftp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain** and **maximum_storage**.
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -187,7 +187,7 @@ On Linux, you can build smp server using Docker.
|
||||
3. Run your Docker container.
|
||||
|
||||
- `smp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain**. `-e "pass=password"` is optional variable to password-protect your `smp` server:
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -200,7 +200,7 @@ On Linux, you can build smp server using Docker.
|
||||
```
|
||||
|
||||
- `xftp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain** and **maximum_storage**.
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -247,7 +247,7 @@ On Linux, you can build smp server using Docker.
|
||||
|
||||
`xftp-server`
|
||||
```sh
|
||||
cabal list-bin exe:xftp-server
|
||||
cabal list-bin exe:xftp-server
|
||||
```
|
||||
|
||||
- Initialize SMP server with `smp-server init [-l] -n <fqdn>` or `smp-server init [-l] --ip <ip>` - depending on how you initialize it, either FQDN or IP will be used for server's address.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Web.Embedded where
|
||||
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Simplex.Messaging.Server.Web (EmbeddedContent (..))
|
||||
|
||||
embeddedContent :: EmbeddedContent
|
||||
embeddedContent =
|
||||
EmbeddedContent
|
||||
{ indexHtml = $(embedFile "apps/common/Web/static/index.html"),
|
||||
linkHtml = $(embedFile "apps/common/Web/static/link.html"),
|
||||
mediaContent = $(embedDir "apps/common/Web/static/media/"),
|
||||
wellKnown = $(embedDir "apps/common/Web/static/.well-known/")
|
||||
}
|
||||
@@ -15,6 +15,7 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
setLogLevel LogInfo
|
||||
cfgPath <- getEnvPath "NTF_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "NTF_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ ntfServerCLI cfgPath logPath
|
||||
|
||||
@@ -2,9 +2,8 @@ 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 SMPWeb (smpGenerateSite)
|
||||
import Simplex.Messaging.Server.Main
|
||||
import qualified Static
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
defaultCfgPath = "/etc/opt/simplex"
|
||||
@@ -17,6 +16,7 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
setLogLevel LogDebug
|
||||
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_ Static.generateSite Static.serveStaticFiles Static.attachStaticFiles cfgPath logPath
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module SMPWeb
|
||||
( smpGenerateSite,
|
||||
serverInformation,
|
||||
) where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.String (fromString)
|
||||
import Web.Embedded (embeddedContent)
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
|
||||
smpGenerateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO ()
|
||||
smpGenerateSite si onionHost path =
|
||||
Web.generateSite embeddedContent (serverInformation si onionHost) smpLinkPages path
|
||||
|
||||
smpLinkPages :: [String]
|
||||
smpLinkPages = ["contact", "invitation", "a", "c", "g", "r", "i"]
|
||||
|
||||
serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString
|
||||
serverInformation ServerInformation {config, information} onionHost = render (Web.indexHtml embeddedContent) substs
|
||||
where
|
||||
substs = [("smpConfig", Just "y"), ("xftpConfig", Nothing)] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "smp-server.ini")]
|
||||
substConfig =
|
||||
[ ( "persistence",
|
||||
Just $ case persistence config of
|
||||
SPMMemoryOnly -> "In-memory only"
|
||||
SPMQueues -> "Queues"
|
||||
SPMMessages -> "Queues and messages"
|
||||
),
|
||||
("messageExpiration", Just $ maybe "Never" (fromString . timedTTLText) $ messageExpiration config),
|
||||
("statsEnabled", Just . yesNo $ statsEnabled config),
|
||||
("newQueuesAllowed", Just . yesNo $ newQueuesAllowed config),
|
||||
("basicAuthEnabled", Just . yesNo $ basicAuthEnabled config)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
@@ -0,0 +1 @@
|
||||
../link.html
|
||||
@@ -0,0 +1 @@
|
||||
../link.html
|
||||
@@ -0,0 +1 @@
|
||||
../link.html
|
||||
@@ -0,0 +1 @@
|
||||
../link.html
|
||||
@@ -105,13 +105,6 @@
|
||||
class="text-[16px] leading-[26px] tracking-[0.01em] nav-link-text text-black dark:text-white before:bg-black dark:before:bg-white">Server
|
||||
information</span></a>
|
||||
</li>
|
||||
<!-- <x-xftpConfig>
|
||||
<li class="nav-link relative"><a href="/file"
|
||||
class="flex items-center justify-between gap-2 lg:py-5 whitespace-nowrap"><span
|
||||
class="text-[16px] leading-[26px] tracking-[0.01em] nav-link-text text-black dark:text-white before:bg-black dark:before:bg-white">File
|
||||
transfer</span></a>
|
||||
</li>
|
||||
</x-xftpConfig> -->
|
||||
</ul><a target="_blank" href="https://github.com/simplex-chat/simplex-chat#help-us-with-donations"
|
||||
class="whitespace-nowrap flex items-center gap-1 self-center text-white dark:text-black text-[16px] font-medium tracking-[0.02em] rounded-[34px] bg-primary-light dark:bg-primary-dark py-3 lg:py-2 px-20 lg:px-5 mb-16 lg:mb-0">Donate</a>
|
||||
</div>
|
||||
@@ -230,14 +223,11 @@
|
||||
<table id="public-info">
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Server version:</td>
|
||||
<td>${version}<x-commit> / <a href="${commitSourceCode}/commit/${commit}" target="_blank">${shortCommit}</a></x-commit></td>
|
||||
<td>${version}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Source code:</td>
|
||||
<td>
|
||||
<x-sourceCode><a href="${sourceCode}" target="_blank">${sourceCode}</a></x-sourceCode>
|
||||
<x-noSourceCode>add to ${iniFileName} (required by <a href="https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE" target="_blank">AGPLv3</a>)</x-noSourceCode>
|
||||
</td>
|
||||
<td><a href="${sourceCode}" target="_blank">${sourceCode}</a></td>
|
||||
</tr>
|
||||
<x-website>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
@@ -324,7 +314,6 @@
|
||||
<h2 class="text-[30px] mb-[20px] leading-[28px] text-[#606C71] dark:text-white font-bold max-w-[475px]">
|
||||
Configuration</h2>
|
||||
<table id="config">
|
||||
<x-smpConfig>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Persistence:</td>
|
||||
<td>${persistence}</td>
|
||||
@@ -345,25 +334,6 @@
|
||||
<td>Basic auth enabled:</td>
|
||||
<td>${basicAuthEnabled}</td>
|
||||
</tr>
|
||||
</x-smpConfig>
|
||||
<x-xftpConfig>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>File expiration:</td>
|
||||
<td>${fileExpiration}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Stats enabled:</td>
|
||||
<td>${statsEnabled}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>New uploads allowed:</td>
|
||||
<td>${newUploadsAllowed}</td>
|
||||
</tr>
|
||||
<tr class="text-grey-black dark:text-white text-base">
|
||||
<td>Basic auth enabled:</td>
|
||||
<td>${basicAuthEnabled}</td>
|
||||
</tr>
|
||||
</x-xftpConfig>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
../link.html
|
||||
@@ -512,8 +512,6 @@
|
||||
element.innerHTML = 'This is a one-time link of the SimpleX network user'
|
||||
} else if (url.includes('/c')) {
|
||||
element.innerHTML = 'This is a public channel address on SimpleX network'
|
||||
} else if (url.includes('/r')) {
|
||||
element.innerHTML = 'This is a chat relay address on SimpleX network'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,244 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Static where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (toUpper)
|
||||
import Data.IORef (readIORef)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.String (fromString)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Network.Socket (getPeerName)
|
||||
import Network.Wai (Application, Request (..))
|
||||
import Network.Wai.Application.Static (StaticSettings (..))
|
||||
import qualified Network.Wai.Application.Static as S
|
||||
import qualified Network.Wai.Handler.Warp as W
|
||||
import qualified Network.Wai.Handler.Warp.Internal as WI
|
||||
import qualified Network.Wai.Handler.WarpTLS as WT
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server (AttachHTTP)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Main (EmbeddedWebParams (..), WebHttpsParams (..))
|
||||
import Simplex.Messaging.Transport (simplexMQVersion)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
import Simplex.Messaging.Util (tshow)
|
||||
import Static.Embedded as E
|
||||
import System.Directory (createDirectoryIfMissing)
|
||||
import System.FilePath
|
||||
import UnliftIO.Concurrent (forkFinally)
|
||||
import UnliftIO.Exception (bracket, finally)
|
||||
import qualified WaiAppStatic.Types as WAT
|
||||
|
||||
serveStaticFiles :: EmbeddedWebParams -> IO ()
|
||||
serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams} = do
|
||||
forM_ webHttpPort $ \port -> flip forkFinally (\e -> logError $ "HTTP server crashed: " <> tshow e) $ do
|
||||
logInfo $ "Serving static site on port " <> tshow port
|
||||
W.runSettings (mkSettings port) app
|
||||
forM_ webHttpsParams $ \WebHttpsParams {port, cert, key} -> flip forkFinally (\e -> logError $ "HTTPS server crashed: " <> tshow e) $ do
|
||||
logInfo $ "Serving static site on port " <> tshow port <> " (TLS)"
|
||||
WT.runTLS (WT.tlsSettings cert key) (mkSettings port) app
|
||||
where
|
||||
app = staticFiles webStaticPath
|
||||
mkSettings port = W.setPort port warpSettings
|
||||
|
||||
-- | Prepare context and prepare HTTP handler for TLS connections that already passed TLS.handshake and ALPN check.
|
||||
attachStaticFiles :: FilePath -> (AttachHTTP -> IO ()) -> IO ()
|
||||
attachStaticFiles path action =
|
||||
-- Initialize global internal state for http server.
|
||||
WI.withII warpSettings $ \ii -> do
|
||||
action $ \socket cxt -> do
|
||||
-- Initialize internal per-connection resources.
|
||||
addr <- getPeerName socket
|
||||
withConnection addr cxt $ \(conn, transport) ->
|
||||
withTimeout ii conn $ \th ->
|
||||
-- Run Warp connection handler to process HTTP requests for static files.
|
||||
WI.serveConnection conn ii th addr transport warpSettings app
|
||||
where
|
||||
app = staticFiles path
|
||||
-- from warp-tls
|
||||
withConnection socket cxt = bracket (WT.attachConn socket cxt) (terminate . fst)
|
||||
-- from warp
|
||||
withTimeout ii conn =
|
||||
bracket
|
||||
(WI.registerKillThread (WI.timeoutManager ii) (WI.connClose conn))
|
||||
WI.cancel
|
||||
-- shared clean up
|
||||
terminate conn = WI.connClose conn `finally` (readIORef (WI.connWriteBuffer conn) >>= WI.bufFree)
|
||||
|
||||
warpSettings :: W.Settings
|
||||
warpSettings = W.setGracefulShutdownTimeout (Just 1) W.defaultSettings
|
||||
|
||||
staticFiles :: FilePath -> Application
|
||||
staticFiles root = S.staticApp settings . changeWellKnownPath
|
||||
where
|
||||
settings = defSettings {ssListing = Nothing, ssGetMimeType = getMimeType}
|
||||
defSettings = S.defaultFileServerSettings root
|
||||
getMimeType f
|
||||
| WAT.fromPiece (WAT.fileName f) == "apple-app-site-association" = pure "application/json"
|
||||
| otherwise = (ssGetMimeType defSettings) f
|
||||
changeWellKnownPath req = case pathInfo req of
|
||||
".well-known" : rest ->
|
||||
req
|
||||
{ pathInfo = "well-known" : rest,
|
||||
rawPathInfo = "/well-known/" <> B.drop pfxLen (rawPathInfo req)
|
||||
}
|
||||
_ -> req
|
||||
pfxLen = B.length "/.well-known/"
|
||||
|
||||
generateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO ()
|
||||
generateSite si onionHost sitePath = do
|
||||
createDirectoryIfMissing True sitePath
|
||||
B.writeFile (sitePath </> "index.html") $ serverInformation si onionHost
|
||||
copyDir "media" E.mediaContent
|
||||
-- `.well-known` path is re-written in changeWellKnownPath,
|
||||
-- staticApp does not allow hidden folders.
|
||||
copyDir "well-known" E.wellKnown
|
||||
createLinkPage "contact"
|
||||
createLinkPage "invitation"
|
||||
createLinkPage "a"
|
||||
createLinkPage "c"
|
||||
createLinkPage "g"
|
||||
createLinkPage "i"
|
||||
logInfo $ "Generated static site contents at " <> tshow sitePath
|
||||
where
|
||||
copyDir dir content = do
|
||||
createDirectoryIfMissing True $ sitePath </> dir
|
||||
forM_ content $ \(path, s) -> B.writeFile (sitePath </> dir </> path) s
|
||||
createLinkPage path = do
|
||||
createDirectoryIfMissing True $ sitePath </> path
|
||||
B.writeFile (sitePath </> path </> "index.html") E.linkHtml
|
||||
|
||||
serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString
|
||||
serverInformation ServerInformation {config, information} onionHost = render E.indexHtml substs
|
||||
where
|
||||
substs = substConfig <> maybe [] substInfo information <> [("onionHost", strEncode <$> onionHost)]
|
||||
substConfig =
|
||||
[ ( "persistence",
|
||||
Just $ case persistence config of
|
||||
SPMMemoryOnly -> "In-memory only"
|
||||
SPMQueues -> "Queues"
|
||||
SPMMessages -> "Queues and messages"
|
||||
),
|
||||
("messageExpiration", Just $ maybe "Never" (fromString . timedTTLText) $ messageExpiration config),
|
||||
("statsEnabled", Just . yesNo $ statsEnabled config),
|
||||
("newQueuesAllowed", Just . yesNo $ newQueuesAllowed config),
|
||||
("basicAuthEnabled", Just . yesNo $ basicAuthEnabled config)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
substInfo spi =
|
||||
concat
|
||||
[ basic,
|
||||
maybe [("usageConditions", Nothing), ("usageAmendments", Nothing)] conds (usageConditions spi),
|
||||
maybe [("operator", Nothing)] operatorE (operator spi),
|
||||
maybe [("admin", Nothing)] admin (adminContacts spi),
|
||||
maybe [("complaints", Nothing)] complaints (complaintsContacts spi),
|
||||
maybe [("hosting", Nothing)] hostingE (hosting spi),
|
||||
server
|
||||
]
|
||||
where
|
||||
basic =
|
||||
[ ("sourceCode", Just . encodeUtf8 $ sourceCode spi),
|
||||
("version", Just $ B.pack simplexMQVersion),
|
||||
("website", encodeUtf8 <$> website spi)
|
||||
]
|
||||
conds ServerConditions {conditions, amendments} =
|
||||
[ ("usageConditions", Just $ encodeUtf8 conditions),
|
||||
("usageAmendments", encodeUtf8 <$> amendments)
|
||||
]
|
||||
operatorE Entity {name, country} =
|
||||
[ ("operator", Just ""),
|
||||
("operatorEntity", Just $ encodeUtf8 name),
|
||||
("operatorCountry", encodeUtf8 <$> country)
|
||||
]
|
||||
admin ServerContactAddress {simplex, email, pgp} =
|
||||
[ ("admin", Just ""),
|
||||
("adminSimplex", strEncode <$> simplex),
|
||||
("adminEmail", encodeUtf8 <$> email),
|
||||
("adminPGP", encodeUtf8 . pkURI <$> pgp),
|
||||
("adminPGPFingerprint", encodeUtf8 . pkFingerprint <$> pgp)
|
||||
]
|
||||
complaints ServerContactAddress {simplex, email, pgp} =
|
||||
[ ("complaints", Just ""),
|
||||
("complaintsSimplex", strEncode <$> simplex),
|
||||
("complaintsEmail", encodeUtf8 <$> email),
|
||||
("complaintsPGP", encodeUtf8 . pkURI <$> pgp),
|
||||
("complaintsPGPFingerprint", encodeUtf8 . pkFingerprint <$> pgp)
|
||||
]
|
||||
hostingE Entity {name, country} =
|
||||
[ ("hosting", Just ""),
|
||||
("hostingEntity", Just $ encodeUtf8 name),
|
||||
("hostingCountry", encodeUtf8 <$> country)
|
||||
]
|
||||
server =
|
||||
[ ("serverCountry", encodeUtf8 <$> serverCountry spi),
|
||||
("hostingType", (\s -> maybe s (\(c, rest) -> toUpper c `B.cons` rest) $ B.uncons s) . strEncode <$> hostingType spi)
|
||||
]
|
||||
|
||||
-- Copy-pasted from simplex-chat Simplex.Chat.Types.Preferences
|
||||
{-# INLINE timedTTLText #-}
|
||||
timedTTLText :: (Integral i, Show i) => i -> String
|
||||
timedTTLText 0 = "0 sec"
|
||||
timedTTLText ttl = do
|
||||
let (m', s) = ttl `quotRem` 60
|
||||
(h', m) = m' `quotRem` 60
|
||||
(d', h) = h' `quotRem` 24
|
||||
(mm, d) = d' `quotRem` 30
|
||||
unwords $
|
||||
[mms mm | mm /= 0]
|
||||
<> [ds d | d /= 0]
|
||||
<> [hs h | h /= 0]
|
||||
<> [ms m | m /= 0]
|
||||
<> [ss s | s /= 0]
|
||||
where
|
||||
ss s = show s <> " sec"
|
||||
ms m = show m <> " min"
|
||||
hs 1 = "1 hour"
|
||||
hs h = show h <> " hours"
|
||||
ds 1 = "1 day"
|
||||
ds 7 = "1 week"
|
||||
ds 14 = "2 weeks"
|
||||
ds d = show d <> " days"
|
||||
mms 1 = "1 month"
|
||||
mms mm = show mm <> " months"
|
||||
|
||||
-- | Rewrite source with provided substitutions
|
||||
render :: ByteString -> [(ByteString, Maybe ByteString)] -> ByteString
|
||||
render src = \case
|
||||
[] -> src
|
||||
(label, content') : rest -> render (section_ label content' src) rest
|
||||
|
||||
-- | Rewrite section content inside @<x-label>...</x-label>@ markers.
|
||||
-- Markers are always removed when found. Closing marker is mandatory.
|
||||
-- If content is absent, whole section is removed.
|
||||
-- Section content is delegated to `item_`. If no sections found, the whole source is delegated.
|
||||
section_ :: ByteString -> Maybe ByteString -> ByteString -> ByteString
|
||||
section_ label content' src =
|
||||
case B.breakSubstring startMarker src of
|
||||
(_, "") -> item_ label (fromMaybe "" content') src -- no section, just replace items
|
||||
(before, afterStart') ->
|
||||
-- found section start, search for end too
|
||||
case B.breakSubstring endMarker $ B.drop (B.length startMarker) afterStart' of
|
||||
(_, "") -> error $ "missing section end: " <> show endMarker
|
||||
(inside, next') ->
|
||||
let next = B.drop (B.length endMarker) next'
|
||||
in case content' of
|
||||
Nothing -> before <> next -- collapse section
|
||||
Just content -> before <> item_ label content inside <> section_ label content' next
|
||||
where
|
||||
startMarker = "<x-" <> label <> ">"
|
||||
endMarker = "</x-" <> label <> ">"
|
||||
|
||||
-- | Replace all occurences of @${label}@ with provided content.
|
||||
item_ :: ByteString -> ByteString -> ByteString -> ByteString
|
||||
item_ label content' src =
|
||||
case B.breakSubstring marker src of
|
||||
(done, "") -> done
|
||||
(before, after') -> before <> content' <> item_ label content' (B.drop (B.length marker) after')
|
||||
where
|
||||
marker = "${" <> label <> "}"
|
||||
@@ -0,0 +1,18 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Static.Embedded where
|
||||
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Data.ByteString (ByteString)
|
||||
|
||||
indexHtml :: ByteString
|
||||
indexHtml = $(embedFile "apps/smp-server/static/index.html")
|
||||
|
||||
linkHtml :: ByteString
|
||||
linkHtml = $(embedFile "apps/smp-server/static/link.html")
|
||||
|
||||
mediaContent :: [(FilePath, ByteString)]
|
||||
mediaContent = $(embedDir "apps/smp-server/static/media/")
|
||||
|
||||
wellKnown :: [(FilePath, ByteString)]
|
||||
wellKnown = $(embedDir "apps/smp-server/static/.well-known/")
|
||||
@@ -1,10 +1,8 @@
|
||||
module Main where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Simplex.FileTransfer.Server.Main (xftpServerCLI_)
|
||||
import Simplex.Messaging.Server.CLI (getEnvPath)
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles)
|
||||
import XFTPWeb (xftpGenerateSite)
|
||||
import Simplex.FileTransfer.Server.Main
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
defaultCfgPath = "/etc/opt/simplex-xftp"
|
||||
@@ -20,4 +18,4 @@ main = do
|
||||
setLogLevel LogDebug -- change to LogError in production
|
||||
cfgPath <- getEnvPath "XFTP_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "XFTP_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ xftpServerCLI_ xftpGenerateSite serveStaticFiles cfgPath logPath
|
||||
withGlobalLogging logCfg $ xftpServerCLI cfgPath logPath
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module XFTPWeb
|
||||
( xftpGenerateSite,
|
||||
xftpServerInformation,
|
||||
) where
|
||||
|
||||
import Control.Monad (forM_)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.String (fromString)
|
||||
import Web.Embedded (embeddedContent)
|
||||
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Expiration (ExpirationConfig (..))
|
||||
import Simplex.Messaging.Server.Information (ServerPublicInfo)
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
import System.Directory (createDirectoryIfMissing)
|
||||
import System.FilePath ((</>))
|
||||
|
||||
xftpWebContent :: [(FilePath, ByteString)]
|
||||
xftpWebContent = $(embedDir "apps/xftp-server/static/xftp-web-bundle/")
|
||||
|
||||
xftpMediaContent :: [(FilePath, ByteString)]
|
||||
xftpMediaContent = $(embedDir "apps/xftp-server/static/media/")
|
||||
|
||||
xftpFilePageHtml :: ByteString
|
||||
xftpFilePageHtml = $(embedFile "apps/xftp-server/static/file.html")
|
||||
|
||||
xftpGenerateSite :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
|
||||
xftpGenerateSite cfg info onionHost path = do
|
||||
let substs = xftpSubsts cfg info onionHost
|
||||
Web.generateSite embeddedContent (render (Web.indexHtml embeddedContent) substs) [] path
|
||||
let xftpDir = path </> "xftp-web-bundle"
|
||||
mediaDir = path </> "media"
|
||||
fileDir = path </> "file"
|
||||
filePage xftpDir xftpWebContent
|
||||
filePage mediaDir xftpMediaContent
|
||||
createDirectoryIfMissing True fileDir
|
||||
B.writeFile (fileDir </> "index.html") $ render xftpFilePageHtml substs
|
||||
where
|
||||
filePage dir content_ = do
|
||||
createDirectoryIfMissing True dir
|
||||
forM_ content_ $ \(fp, content) -> B.writeFile (dir </> fp) content
|
||||
|
||||
xftpServerInformation :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
|
||||
xftpServerInformation cfg info onionHost = render (Web.indexHtml embeddedContent) (xftpSubsts cfg info onionHost)
|
||||
|
||||
xftpSubsts :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> [(ByteString, Maybe ByteString)]
|
||||
xftpSubsts XFTPServerConfig {fileExpiration, logStatsInterval, allowNewFiles, newFileBasicAuth} information onionHost =
|
||||
[("smpConfig", Nothing), ("xftpConfig", Just "y")] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "file-server.ini")]
|
||||
where
|
||||
substConfig =
|
||||
[ ("fileExpiration", Just $ maybe "Never" (fromString . timedTTLText . ttl) fileExpiration),
|
||||
("statsEnabled", Just . yesNo $ isJust logStatsInterval),
|
||||
("newUploadsAllowed", Just . yesNo $ allowNewFiles),
|
||||
("basicAuthEnabled", Just . yesNo $ isJust newFileBasicAuth)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
@@ -1,115 +0,0 @@
|
||||
<svg width="440" height="520" viewBox="-20 0 440 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sender browser -->
|
||||
<rect x="120" y="16" width="160" height="56" rx="10" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<text x="200" y="40" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#70F0F9">Sender's browser</text>
|
||||
<text x="200" y="56" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(112,240,249,0.7)">encrypts file</text>
|
||||
|
||||
<!-- Arrow down from sender to chunks -->
|
||||
<line x1="200" y1="72" x2="200" y2="120" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Chunks row -->
|
||||
<rect x="112" y="120" width="176" height="40" rx="8" fill="none" stroke="#70F0F9" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="145" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#70F0F9">encrypted chunks</text>
|
||||
|
||||
<!-- Arrows from chunks to routers -->
|
||||
<line x1="152" y1="160" x2="80" y2="220" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="200" y1="160" x2="200" y2="220" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="248" y1="160" x2="320" y2="220" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Router 1 (SimpleX) -->
|
||||
<rect x="20" y="220" width="120" height="56" rx="6" fill="none" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<g transform="translate(28, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#70F0F9"/>
|
||||
</g>
|
||||
<text x="80" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#70F0F9">SimpleX</text>
|
||||
<text x="80" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="rgba(112,240,249,0.7)">XFTP router</text>
|
||||
|
||||
<!-- Router 2 (Flux) -->
|
||||
<rect x="155" y="220" width="90" height="56" rx="6" fill="none" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<g transform="translate(163, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#70F0F9"/>
|
||||
</g>
|
||||
<text x="200" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#70F0F9">Flux</text>
|
||||
<text x="200" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="rgba(112,240,249,0.7)">XFTP router</text>
|
||||
|
||||
<!-- Router 3 (SimpleX) -->
|
||||
<rect x="260" y="220" width="120" height="56" rx="6" fill="none" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<g transform="translate(268, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="rgba(112,240,249,0.5)"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#70F0F9"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#70F0F9"/>
|
||||
</g>
|
||||
<text x="320" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#70F0F9">SimpleX</text>
|
||||
<text x="320" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="rgba(112,240,249,0.7)">XFTP router</text>
|
||||
|
||||
<!-- Arrows from routers down -->
|
||||
<line x1="80" y1="276" x2="152" y2="336" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="200" y1="276" x2="200" y2="336" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
<line x1="320" y1="276" x2="248" y2="336" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Re-encrypt label -->
|
||||
<text x="330" y="310" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="rgba(112,240,249,0.7)">re-encrypted</text>
|
||||
<text x="330" y="322" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="rgba(112,240,249,0.7)">per recipient</text>
|
||||
|
||||
<!-- Chunks row (download) -->
|
||||
<rect x="112" y="336" width="176" height="40" rx="8" fill="none" stroke="#70F0F9" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="361" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#70F0F9">encrypted chunks</text>
|
||||
|
||||
<!-- Arrow down to recipient -->
|
||||
<line x1="200" y1="376" x2="200" y2="424" stroke="#70F0F9" stroke-width="1.5" marker-end="url(#arrowC)"/>
|
||||
|
||||
<!-- Recipient browser -->
|
||||
<rect x="120" y="424" width="160" height="56" rx="10" stroke="#70F0F9" stroke-width="1.5"/>
|
||||
<text x="200" y="448" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#70F0F9">Recipient's browser</text>
|
||||
<text x="200" y="464" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(112,240,249,0.7)">decrypts file</text>
|
||||
|
||||
<!-- Key path (dashed, side) -->
|
||||
<path d="M120 44 L8 44 L8 452 L120 452" stroke="#70F0F9" stroke-width="1.5" stroke-dasharray="6 4" fill="none" marker-end="url(#arrowC)"/>
|
||||
<text x="-6" y="240" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#70F0F9" transform="rotate(-90 -6 240)">key in URL fragment - never sent to page server or data router</text>
|
||||
|
||||
|
||||
<!-- Closed padlock: encryption (between sender and chunks) -->
|
||||
<g transform="translate(192, 88)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V7" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#60a5fa"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#0B2A59"/>
|
||||
</g>
|
||||
|
||||
<!-- Open padlock: decryption (between chunks and recipient) -->
|
||||
<g transform="translate(192, 392)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V2" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#60a5fa"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#0B2A59"/>
|
||||
</g>
|
||||
|
||||
<!-- Key icon on dashed line -->
|
||||
<g transform="translate(8, 410)">
|
||||
<circle cx="0" cy="0" r="6" stroke="#FBBF24" stroke-width="2" fill="#FBBF24"/>
|
||||
<circle cx="0" cy="0" r="2" fill="#0B2A59"/>
|
||||
<line x1="6" y1="0" x2="16" y2="0" stroke="#FBBF24" stroke-width="2"/>
|
||||
<line x1="14" y1="0" x2="14" y2="4" stroke="#FBBF24" stroke-width="2"/>
|
||||
<line x1="11" y1="0" x2="11" y2="3.5" stroke="#FBBF24" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Annotation: no shared IDs -->
|
||||
<text x="200" y="510" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="rgba(112,240,249,0.7)">Each file fragment uses unique anonymous credentials - no shared identifiers</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrowC" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#70F0F9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,130 +0,0 @@
|
||||
<svg width="440" height="520" viewBox="-20 0 440 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sender browser -->
|
||||
<rect x="120" y="16" width="160" height="56" rx="10" fill="url(#gBox)" stroke="#606C71" stroke-width="1.5"/>
|
||||
<text x="200" y="40" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#fff">Sender's browser</text>
|
||||
<text x="200" y="56" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(255,255,255,0.8)">encrypts file</text>
|
||||
|
||||
<!-- Arrow down from sender to chunks -->
|
||||
<line x1="200" y1="72" x2="200" y2="120" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Chunks row -->
|
||||
<rect x="112" y="120" width="176" height="40" rx="8" fill="#f0f7ff" stroke="#0053D0" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="145" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#0053D0">encrypted chunks</text>
|
||||
|
||||
<!-- Arrows from chunks to routers -->
|
||||
<line x1="152" y1="160" x2="80" y2="220" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="200" y1="160" x2="200" y2="220" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="248" y1="160" x2="320" y2="220" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Router 1 (SimpleX) -->
|
||||
<rect x="20" y="220" width="120" height="56" rx="6" fill="#f0f4f8" stroke="#606C71" stroke-width="1.5"/>
|
||||
<g transform="translate(28, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#53C1FF"/>
|
||||
</g>
|
||||
<text x="80" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#3F484B">SimpleX</text>
|
||||
<text x="80" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="#606C71">XFTP router</text>
|
||||
|
||||
<!-- Router 2 (Flux) -->
|
||||
<rect x="155" y="220" width="90" height="56" rx="6" fill="#f0f4f8" stroke="#606C71" stroke-width="1.5"/>
|
||||
<g transform="translate(163, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#53C1FF"/>
|
||||
</g>
|
||||
<text x="200" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#3F484B">Flux</text>
|
||||
<text x="200" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="#606C71">XFTP router</text>
|
||||
|
||||
<!-- Router 3 (SimpleX) -->
|
||||
<rect x="260" y="220" width="120" height="56" rx="6" fill="#f0f4f8" stroke="#606C71" stroke-width="1.5"/>
|
||||
<g transform="translate(268, 227)">
|
||||
<rect width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="6" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<rect y="12" width="14" height="4" rx="1" fill="#606C71"/>
|
||||
<circle cx="11" cy="2" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="8" r="1" fill="#53C1FF"/>
|
||||
<circle cx="11" cy="14" r="1" fill="#53C1FF"/>
|
||||
</g>
|
||||
<text x="320" y="244" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#3F484B">SimpleX</text>
|
||||
<text x="320" y="258" text-anchor="middle" font-family="system-ui, sans-serif" font-size="9" fill="#606C71">XFTP router</text>
|
||||
|
||||
<!-- Arrows from routers down -->
|
||||
<line x1="80" y1="276" x2="152" y2="336" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="200" y1="276" x2="200" y2="336" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
<line x1="320" y1="276" x2="248" y2="336" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Re-encrypt label -->
|
||||
<text x="330" y="310" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="#606C71">re-encrypted</text>
|
||||
<text x="330" y="322" text-anchor="start" font-family="system-ui, sans-serif" font-size="10" fill="#606C71">per recipient</text>
|
||||
|
||||
<!-- Chunks row (download) -->
|
||||
<rect x="112" y="336" width="176" height="40" rx="8" fill="#f0f7ff" stroke="#0053D0" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="361" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" fill="#0053D0">encrypted chunks</text>
|
||||
|
||||
<!-- Arrow down to recipient -->
|
||||
<line x1="200" y1="376" x2="200" y2="424" stroke="#606C71" stroke-width="1.5" marker-end="url(#arrowG)"/>
|
||||
|
||||
<!-- Recipient browser -->
|
||||
<rect x="120" y="424" width="160" height="56" rx="10" fill="url(#gBox)" stroke="#606C71" stroke-width="1.5"/>
|
||||
<text x="200" y="448" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#fff">Recipient's browser</text>
|
||||
<text x="200" y="464" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="rgba(255,255,255,0.8)">decrypts file</text>
|
||||
|
||||
<!-- Key path (dashed, side) -->
|
||||
<path d="M120 44 L8 44 L8 452 L120 452" stroke="#0053D0" stroke-width="1.5" stroke-dasharray="6 4" fill="none" marker-end="url(#arrowB)"/>
|
||||
<text x="-6" y="240" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#0053D0" transform="rotate(-90 -6 240)">key in URL fragment - never sent to page server or data router</text>
|
||||
|
||||
|
||||
<!-- Closed padlock: encryption (between sender and chunks) -->
|
||||
<g transform="translate(192, 88)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V7" stroke="#0053D0" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#0053D0"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#fff"/>
|
||||
</g>
|
||||
|
||||
<!-- Open padlock: decryption (between chunks and recipient) -->
|
||||
<g transform="translate(192, 392)">
|
||||
<path d="M4,7 V4 C4,1.2 12,1.2 12,4 V2" stroke="#0053D0" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<rect x="2" y="7" width="12" height="9" rx="2" fill="#0053D0"/>
|
||||
<circle cx="8" cy="12" r="1.2" fill="#fff"/>
|
||||
</g>
|
||||
|
||||
<!-- Key icon on dashed line -->
|
||||
<g transform="translate(8, 410)">
|
||||
<circle cx="0" cy="0" r="6" stroke="#D97706" stroke-width="2" fill="#D97706"/>
|
||||
<circle cx="0" cy="0" r="2" fill="#fff"/>
|
||||
<line x1="6" y1="0" x2="16" y2="0" stroke="#D97706" stroke-width="2"/>
|
||||
<line x1="14" y1="0" x2="14" y2="4" stroke="#D97706" stroke-width="2"/>
|
||||
<line x1="11" y1="0" x2="11" y2="3.5" stroke="#D97706" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Annotation: no shared IDs -->
|
||||
<text x="200" y="510" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#606C71">Each file fragment uses unique anonymous credentials - no shared identifiers</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="gBox" x1="120" y1="16" x2="280" y2="72" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0053D0"/>
|
||||
<stop offset="1" stop-color="#53C1FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gSrv1" x1="20" y1="220" x2="140" y2="276" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0053D0"/>
|
||||
<stop offset="1" stop-color="#53C1FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gSrv2" x1="155" y1="220" x2="245" y2="276" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0053D0"/>
|
||||
<stop offset="1" stop-color="#53C1FF"/>
|
||||
</linearGradient>
|
||||
<marker id="arrowG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#606C71"/>
|
||||
</marker>
|
||||
<marker id="arrowB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0053D0"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
@@ -1,145 +0,0 @@
|
||||
#app, [data-xftp-app] {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #333;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
--xftp-ring-fg: #3b82f6;
|
||||
}
|
||||
|
||||
:is(#app, [data-xftp-app]) .card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:is(#app, [data-xftp-app]) h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:is(#app, [data-xftp-app]) .stage { margin-top: 16px; }
|
||||
|
||||
/* Drop zone */
|
||||
:is(#app, [data-xftp-app]) .drop-zone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 32px 16px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .drop-zone.drag-over {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
:is(#app, [data-xftp-app]) .btn {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: .9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .btn:hover { background: #2563eb; }
|
||||
:is(#app, [data-xftp-app]) .btn-secondary { background: #6b7280; }
|
||||
:is(#app, [data-xftp-app]) .btn-secondary:hover { background: #4b5563; }
|
||||
|
||||
/* Hints */
|
||||
:is(#app, [data-xftp-app]) .hint { color: #999; font-size: .85rem; margin-top: 8px; }
|
||||
:is(#app, [data-xftp-app]) .expiry { margin-top: 12px; }
|
||||
|
||||
/* Progress */
|
||||
:is(#app, [data-xftp-app]) .progress-ring { display: block; margin: 0 auto 12px; }
|
||||
:is(#app, [data-xftp-app]) #upload-status,
|
||||
:is(#app, [data-xftp-app]) #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
|
||||
|
||||
/* Share link row */
|
||||
:is(#app, [data-xftp-app]) .link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .link-row input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: .85rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* Upload link */
|
||||
:is(#app, [data-xftp-app]) .upload-link {
|
||||
margin-top: 12px;
|
||||
color: #3b82f6;
|
||||
font-size: .9rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .upload-link:not([hidden]) {
|
||||
display: inline-block;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .upload-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Messages */
|
||||
:is(#app, [data-xftp-app]) .success { color: #16a34a; font-weight: 600; }
|
||||
:is(#app, [data-xftp-app]) .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
|
||||
|
||||
/* Security note */
|
||||
:is(#app, [data-xftp-app]) .security-note {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: #f0fdf4;
|
||||
border-radius: 6px;
|
||||
font-size: .8rem;
|
||||
color: #555;
|
||||
text-align: left;
|
||||
}
|
||||
:is(#app, [data-xftp-app]) .security-note p + p { margin-top: 6px; }
|
||||
:is(#app, [data-xftp-app]) .security-note a { color: #3b82f6; text-decoration: none; }
|
||||
:is(#app, [data-xftp-app]) .security-note a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────── */
|
||||
.dark :is(#app, [data-xftp-app]) {
|
||||
color: #e5e7eb;
|
||||
--xftp-ring-bg: #374151;
|
||||
--xftp-ring-fg: #60a5fa;
|
||||
--xftp-ring-text: #e5e7eb;
|
||||
--xftp-ring-done: #4ade80;
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .card {
|
||||
background: #1f2937;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .drop-zone { border-color: #4b5563; }
|
||||
.dark :is(#app, [data-xftp-app]) .drop-zone.drag-over {
|
||||
border-color: #60a5fa;
|
||||
background: rgba(59,130,246,.15);
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .btn-secondary { background: #4b5563; }
|
||||
.dark :is(#app, [data-xftp-app]) .btn-secondary:hover { background: #374151; }
|
||||
.dark :is(#app, [data-xftp-app]) .hint { color: #9ca3af; }
|
||||
.dark :is(#app, [data-xftp-app]) #upload-status,
|
||||
.dark :is(#app, [data-xftp-app]) #dl-status { color: #9ca3af; }
|
||||
.dark :is(#app, [data-xftp-app]) .link-row input {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .success { color: #4ade80; }
|
||||
.dark :is(#app, [data-xftp-app]) .error { color: #f87171; }
|
||||
.dark :is(#app, [data-xftp-app]) .security-note {
|
||||
background: rgba(34,197,94,.1);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.dark :is(#app, [data-xftp-app]) .upload-link { color: #60a5fa; }
|
||||
.dark :is(#app, [data-xftp-app]) .security-note a { color: #60a5fa; }
|
||||
@@ -1,24 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// getentropy() shim for Windows, where it is absent from the CRT.
|
||||
// Follows the POSIX contract: fills `buffer` with `length` random bytes
|
||||
// (length must not exceed 256), returns 0 on success or -1 with errno set.
|
||||
#ifdef _WIN32
|
||||
#include <errno.h>
|
||||
#include <stddef.h>
|
||||
#include <windows.h>
|
||||
#include <bcrypt.h>
|
||||
|
||||
int getentropy(void *buffer, size_t length) {
|
||||
if (length > 256) {
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
NTSTATUS status = BCryptGenRandom(NULL, (PUCHAR)buffer, (ULONG)length,
|
||||
BCRYPT_USE_SYSTEM_PREFERRED_RNG);
|
||||
if (!BCRYPT_SUCCESS(status)) {
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
@@ -1,104 +0,0 @@
|
||||
# Coding and building
|
||||
|
||||
This file provides guidance on coding style and approaches and on building the code.
|
||||
|
||||
## Code Security
|
||||
|
||||
When designing code and planning implementations:
|
||||
- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious.
|
||||
- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances.
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
Haskell client and server code serves as system specification, not just implementation — we use type-driven design to reflect the business domain in types. Quality, conciseness, and clarity of Haskell code are critical.
|
||||
|
||||
## Code Style, Formatting and Approaches
|
||||
|
||||
The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`.
|
||||
|
||||
**Key formatting rules:**
|
||||
- 2-space indentation
|
||||
- Trailing function arrows, commas, and import/export style
|
||||
- Record brace without space: `{field = value}`
|
||||
- Single newline between declarations
|
||||
- Never use unicode symbols
|
||||
- Inline `let` style with right-aligned `in`
|
||||
|
||||
**Format code before committing:**
|
||||
|
||||
```bash
|
||||
# Format a single file
|
||||
fourmolu -i src/Simplex/Messaging/Protocol.hs
|
||||
```
|
||||
|
||||
Some files that use CPP language extension cannot be formatted as a whole, so individual code fragments need to be formatted.
|
||||
|
||||
**Follow existing code patterns:**
|
||||
- Match the style of surrounding code
|
||||
- Use qualified imports with short aliases (e.g., `import qualified Data.ByteString.Char8 as B`)
|
||||
- Use record syntax for types with multiple fields
|
||||
- Prefer explicit pattern matching over partial functions
|
||||
|
||||
**Comments policy:**
|
||||
- Avoid redundant comments that restate what the code already says
|
||||
- Only comment on non-obvious design decisions or tricky implementation details
|
||||
- Function names and type signatures should be self-documenting
|
||||
- Do not add comments like "wire format encoding" (Encoding class is always wire format) or "check if X" when the function name already says that
|
||||
- Assume a competent Haskell reader
|
||||
|
||||
**Diff and refactoring:**
|
||||
- Avoid unnecessary changes and code movements
|
||||
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring
|
||||
- Aim to minimize the code changes - do what is minimally required to solve users' problems
|
||||
|
||||
**Document and code structure:**
|
||||
- **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure.
|
||||
- When adding new sections to documents, continue the existing numbering scheme.
|
||||
- Minimize diff size - prefer small, targeted changes over reorganization.
|
||||
|
||||
**Code analysis and review:**
|
||||
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug.
|
||||
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication.
|
||||
- Do not save time on analysis. Read every function in the data flow even when the interface seems clear — wrong assumptions about internals are the main source of missed bugs.
|
||||
|
||||
### Haskell Extensions
|
||||
- `StrictData` enabled by default
|
||||
- Use STM for safe concurrency
|
||||
- Assume concurrency in PostgreSQL queries
|
||||
- Comprehensive warning flags with strict pattern matching
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Standard build
|
||||
cabal build
|
||||
|
||||
# Fast build
|
||||
cabal build --ghc-options -O0
|
||||
|
||||
# Build specific executables
|
||||
cabal build exe:smp-server exe:xftp-server exe:ntf-server exe:xftp
|
||||
|
||||
# Build with PostgreSQL server support
|
||||
cabal build -fserver_postgres
|
||||
|
||||
# Client-only library build (no server code)
|
||||
cabal build -fclient_library
|
||||
|
||||
# Find binary location
|
||||
cabal list-bin exe:smp-server
|
||||
```
|
||||
|
||||
### Cabal Flags
|
||||
|
||||
- `swift`: Enable Swift JSON format
|
||||
- `client_library`: Build without server code
|
||||
- `client_postgres`: Use PostgreSQL instead of SQLite for agent persistence
|
||||
- `server_postgres`: PostgreSQL support for server queue/notification store
|
||||
|
||||
## External Dependencies
|
||||
|
||||
Custom forks specified in `cabal.project`:
|
||||
- `aeson`, `hs-socks` (SimpleX forks)
|
||||
- `direct-sqlcipher`, `sqlcipher-simple` (encrypted SQLite)
|
||||
- `warp`, `warp-tls` (HTTP server)
|
||||
@@ -1,105 +0,0 @@
|
||||
# SimpleXMQ repository
|
||||
|
||||
This file provides guidance on the project structure to help working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SimpleXMQ is a Haskell message broker implementing unidirectional (simplex) queues for privacy-preserving messaging.
|
||||
|
||||
Key components:
|
||||
|
||||
- **SimpleX Messaging Protocol**: SMP protocol definition and encodings ([code](../src/Simplex/Messaging/Protocol.hs), [transport code](../src/Simplex/Messaging/Transport.hs), [spec](../protocol/simplex-messaging.md)).
|
||||
- **SMP Server**: Message broker with TLS, in-memory queues, optional persistence ([main code](../src/Simplex/Messaging/Server.hs), [all code files](../src/Simplex/Messaging/Server/), [executable](../apps/smp-server/)). For proxying SMP commands the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
|
||||
- **SMP Client**: Functional API with STM-based message delivery ([code](../src/Simplex/Messaging/Client.hs)).
|
||||
- **SMP Agent**: High-level duplex connections via multiple simplex queues with E2E encryption ([code](../src/Simplex/Messaging/Agent.hs)). Implements Agent-to-agent protocol ([code](../src/Simplex/Messaging/Agent/Protocol.hs), [spec](../protocol/agent-protocol.md)) via intermediary agent client ([code](../src/Simplex/Messaging/Agent/Client.hs)).
|
||||
- **XFTP**: SimpleX File Transfer Protocol, server and CLI client ([code](../src/Simplex/FileTransfer/), [spec](../protocol/xftp.md)).
|
||||
- **XRCP**: SimpleX Remote Control Protocol ([code](`../src/Simplex/RemoteControl/`), [spec](../protocol/xrcp.md)).
|
||||
- **Notifications**: Push notifications server requires PostgreSQL ([code](../src/Simplex/Messaging/Notifications), [executable](../apps/ntf-server/)). Client protocol is used for clients to communicate with the server ([code](../src/Simplex/Messaging/Notifications/Protocol.hs), [spec](../protocol/push-notifications.md)). For subscribing to SMP notifications the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
|
||||
|
||||
## Architecture
|
||||
|
||||
For general overview see `../protocol/overview-tjr.md`.
|
||||
|
||||
SMP Protocol Layers:
|
||||
|
||||
```
|
||||
TLS Transport → SMP Protocol → Agent Protocol → Application protocol
|
||||
```
|
||||
|
||||
XFTP Protocol Layers:
|
||||
|
||||
```
|
||||
TLS Transport (HTTP2 encoding) → XFTP Protocol → Out-of-band file descriptions
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Persistence**: All queue state managed via Software Transactional Memory or via PostgreSQL
|
||||
- `Simplex.Messaging.Server.MsgStore.STM` - in-memory messages
|
||||
- `Simplex.Messaging.Server.QueueStore.STM` - in-memory queue state
|
||||
- `Simplex.Messaging.Server.MsgStore.Postgres` - message storage
|
||||
- `Simplex.Messaging.Server.QueueStore.Postgres` - queue storage
|
||||
|
||||
2. **Append-Only Store Log**: Optional persistence via journal for in-memory storage
|
||||
- `Simplex.Messaging.Server.StoreLog` - queue creation log
|
||||
- Compacted on restart
|
||||
|
||||
3. **Agent Storage**:
|
||||
- SQLite (default) or PostgreSQL
|
||||
- Migrations in `src/Simplex/Messaging/Agent/Store/{SQLite,Postgres}/Migrations/`
|
||||
|
||||
4. **Protocol Versioning**: All layers support version negotiation
|
||||
- `Simplex.Messaging.Version` - version range utilities
|
||||
|
||||
5. **Double Ratchet E2E**: Per-connection encryption
|
||||
- `Simplex.Messaging.Crypto.Ratchet`
|
||||
- SNTRUP761 post-quantum KEM (`src/Simplex/Messaging/Crypto/SNTRUP761/`)
|
||||
|
||||
## Source Layout
|
||||
|
||||
```
|
||||
src/Simplex/
|
||||
├── Messaging/
|
||||
│ ├── Agent.hs # Main agent (~210KB)
|
||||
│ ├── Server.hs # SMP server (~130KB)
|
||||
│ ├── Client.hs # Client API (~65KB)
|
||||
│ ├── Protocol.hs # Protocol types (~77KB)
|
||||
│ ├── Crypto.hs # E2E encryption (~52KB)
|
||||
│ ├── Transport.hs # Transport encoding over TLS
|
||||
│ ├── Agent/Store/ # SQLite/Postgres persistence
|
||||
│ ├── Server/ # Server internals (QueueStore, MsgStore, Control)
|
||||
│ └── Notifications/ # Push notification system
|
||||
├── FileTransfer/ # XFTP implementation for file transfers
|
||||
└── RemoteControl/ # XRCP implementation for device discovery & control
|
||||
```
|
||||
|
||||
## Protocol Documentation
|
||||
|
||||
- `protocol/overview-tjr.md`: SMP protocols stack overview
|
||||
- `protocol/simplex-messaging.md`: SMP protocol spec (v19)
|
||||
- `protocol/agent-protocol.md`: Agent protocol spec (v7)
|
||||
- `protocol/xftp.md`: File transfer protocol
|
||||
- `protocol/xrcp.md`: Remote control protocol
|
||||
- `rfcs/`: Design RFCs for features
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cabal test --test-show-details=streaming
|
||||
|
||||
# Run specific test group (uses HSpec)
|
||||
cabal test --test-option=--match="/Core tests/Encryption tests/"
|
||||
|
||||
# Run single test
|
||||
cabal test --test-option=--match="/SMP client agent/functional API/"
|
||||
```
|
||||
|
||||
Tests require PostgreSQL running on `localhost:5432` when using `-fserver_postgres` or `-fclient_postgres`.
|
||||
|
||||
Test files are in `tests/` with structure:
|
||||
- `Test.hs`: Main runner
|
||||
- `AgentTests/`: Agent protocol and connection tests
|
||||
- `CoreTests/`: Crypto, encoding, storage tests
|
||||
- `ServerTests.hs`: SMP server tests
|
||||
- `XFTPServerTests.hs`: File transfer tests
|
||||
@@ -1,23 +0,0 @@
|
||||
# Contributing to SimpleX repositories
|
||||
|
||||
## Focus on user problems
|
||||
|
||||
We do not make code changes to improve code - any change must address a specific user problem or request.
|
||||
|
||||
## Discuss the plans as early as possible
|
||||
|
||||
Please discuss the problem you want to solve and your detailed implementation plan with the project team prior to contributing, to avoid wasted time and additional changes. Acceptance of your contribution depends on your willingness and ability to iterate the proposed contribution to achieve the required quality level, coding style, test coverage, and alignment with user requirements as they are understood by the project team.
|
||||
|
||||
## Follow project structure, coding style and approaches
|
||||
|
||||
./PROJECT.md has information about the structure of this `simplexmq` repository.
|
||||
|
||||
./CODE.md has details about general requirements common for `simplexmq` and `simplex-chat` repositories.
|
||||
|
||||
This files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files:
|
||||
|
||||
```markdown
|
||||
@README.md
|
||||
@contributing/PROJECT.md
|
||||
@contributing/CODE.md
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
common:
|
||||
corrId - random BS, used as CbNonce
|
||||
entityId - p2r tlsUniq
|
||||
|
||||
# setup
|
||||
s->p: "proxy", uri, auth?
|
||||
# unless connected
|
||||
p->r: "p_handshake"
|
||||
p<-r: "r_key", tls-signed dh pub
|
||||
s<-r: "r_key", tls-signed dh pub # reply entityId contains tlsUniq
|
||||
|
||||
# working
|
||||
s ; generate random dh priv, make shared secret
|
||||
s->p: s2r("forward", random dh pub, SEND command blob)
|
||||
p->r: p2r("forward", random dh pub, s2r("forward", ...)))
|
||||
r->c@ "msg", ...
|
||||
p<-r: p2r("r_res", s2r("ok" / "error", error))
|
||||
s<-p@ s2r("ok" / "error", error)
|
||||
|
||||
# expired
|
||||
p<-r@ p2r("error", "key expired")
|
||||
s<-p@ "error", "key expired"
|
||||
s ; reconnect
|
||||
@@ -1,472 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,648 +0,0 @@
|
||||
# XFTP PostgreSQL Backend — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add PostgreSQL backend support to xftp-server as an alternative to STM + StoreLog, with bidirectional migration.
|
||||
|
||||
**Architecture:** Introduce `FileStoreClass` typeclass (IO-based, following `QueueStoreClass` pattern). Extract current STM store into `Store/STM.hs`, make `Server.hs` polymorphic, then add `Store/Postgres.hs` behind `server_postgres` CPP flag. `usedStorage` moves from store to `XFTPEnv` so the server manages quota tracking externally.
|
||||
|
||||
**Tech Stack:** Haskell, postgresql-simple, STM, fourmolu, cabal with CPP flags
|
||||
|
||||
**Design spec:** `plans/2026-03-25-xftp-postgres-backend-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Existing files modified:**
|
||||
- `src/Simplex/FileTransfer/Server/Store.hs` — rewritten: becomes typeclass + shared types
|
||||
- `src/Simplex/FileTransfer/Server/Env.hs` — polymorphic `XFTPEnv s`, `XFTPStoreConfig` GADT
|
||||
- `src/Simplex/FileTransfer/Server.hs` — polymorphic over `FileStoreClass s`
|
||||
- `src/Simplex/FileTransfer/Server/StoreLog.hs` — update for IO store functions
|
||||
- `src/Simplex/FileTransfer/Server/Main.hs` — INI config, dispatch, CLI commands
|
||||
- `simplexmq.cabal` — new modules
|
||||
- `tests/XFTPClient.hs` — Postgres test fixtures
|
||||
- `tests/Test.hs` — Postgres test group
|
||||
|
||||
**New files created:**
|
||||
- `src/Simplex/FileTransfer/Server/Store/STM.hs` — `STMFileStore` (extracted from current `Store.hs`)
|
||||
- `src/Simplex/FileTransfer/Server/Store/Postgres.hs` — `PostgresFileStore` [CPP-guarded]
|
||||
- `src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs` — `PostgresFileStoreCfg` [CPP-guarded]
|
||||
- `src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs` — schema SQL [CPP-guarded]
|
||||
- `tests/CoreTests/XFTPStoreTests.hs` — Postgres store unit tests [CPP-guarded]
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Move `usedStorage` from `FileStore` to `XFTPEnv`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
- [ ] **Step 1: Remove `usedStorage` from `FileStore` in `Store.hs`**
|
||||
|
||||
1. Remove `usedStorage :: TVar Int64` field from `FileStore` record (line 47).
|
||||
2. Remove `usedStorage <- newTVarIO 0` from `newFileStore` (line 75) and drop the field from the record construction (line 76).
|
||||
3. In `setFilePath` (line 92-97): remove `modifyTVar' (usedStorage st) (+ fromIntegral (size fileInfo))` — keep only `writeTVar filePath (Just fPath)`. Change pattern from `\FileRec {fileInfo, filePath}` to `\FileRec {filePath}` (fileInfo is now unused — `-Wunused-matches` error).
|
||||
4. In `deleteFile` (line 112-119): remove `modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)`. Change outer pattern match from `FileStore {files, recipients, usedStorage}` to `FileStore {files, recipients}`. Change inner pattern from `Just FileRec {fileInfo, recipientIds}` to `Just FileRec {recipientIds}` (`fileInfo` is now unused — `-Wunused-matches` error).
|
||||
5. In `blockFile` (line 122-127): remove `when deleted $ modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)`. Change pattern match from `st@FileStore {usedStorage}` to `st`. The `deleted` parameter and `fileInfo` in the inner pattern become unused — prefix with `_` or remove from pattern to avoid `-Wunused-matches`.
|
||||
|
||||
- [ ] **Step 2: Add `usedStorage` to `XFTPEnv` in `Env.hs`**
|
||||
|
||||
1. Add `usedStorage :: TVar Int64` field to `XFTPEnv` record (between `store` and `storeLog`, line 93).
|
||||
2. In `newXFTPServerEnv` (line 112-126): replace lines 117-118:
|
||||
```
|
||||
used <- countUsedStorage <$> readTVarIO (files store)
|
||||
atomically $ writeTVar (usedStorage store) used
|
||||
```
|
||||
with:
|
||||
```
|
||||
usedStorage <- newTVarIO =<< countUsedStorage <$> readTVarIO (files store)
|
||||
```
|
||||
3. Add `usedStorage` to the `pure XFTPEnv {..}` construction.
|
||||
|
||||
- [ ] **Step 3: Update all `usedStorage` access sites in `Server.hs`**
|
||||
|
||||
1. Line 552: `us <- asks $ usedStorage . store` → `us <- asks usedStorage`.
|
||||
2. Line 569: `us <- asks $ usedStorage . store` → `us <- asks usedStorage`.
|
||||
3. Line 639: `usedStart <- readTVarIO $ usedStorage st` → `usedStart <- readTVarIO =<< asks usedStorage`.
|
||||
4. Line 647: `usedEnd <- readTVarIO $ usedStorage st` → `usedEnd <- readTVarIO =<< asks usedStorage`.
|
||||
5. Line 694: `FileStore {files, usedStorage} <- asks store` → split into `FileStore {files} <- asks store` and `usedStorage <- asks usedStorage`.
|
||||
6. In `deleteOrBlockServerFile_` (line 620): after `void $ atomically $ storeAction st`, add usedStorage adjustment — `us <- asks usedStorage` then `atomically $ modifyTVar' us $ subtract (fromIntegral $ size fileInfo)` when file had a path (check `path` from `readTVarIO filePath` earlier in the function).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git commit -m "refactor(xftp): move usedStorage from FileStore to XFTPEnv"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `getUsedStorage`, `getFileCount`, `expiredFiles` functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
- [ ] **Step 1: Add three new functions to `Store.hs`**
|
||||
|
||||
1. Add to exports: `getUsedStorage`, `getFileCount`, `expiredFiles`.
|
||||
2. Remove `expiredFilePath` from exports AND delete the function definition (dead code → `-Wunused-binds` error). Also remove `($>>=)` from import `Simplex.Messaging.Util (ifM, ($>>=))` → `Simplex.Messaging.Util (ifM)` — `$>>=` was only used by `expiredFilePath`.
|
||||
3. Add import: `qualified Data.Map.Strict as M` (needed for `M.foldl'` in `getUsedStorage` and `M.toList` in `expiredFiles`).
|
||||
4. Implement:
|
||||
```haskell
|
||||
getUsedStorage :: FileStore -> IO Int64
|
||||
getUsedStorage FileStore {files} =
|
||||
M.foldl' (\acc FileRec {fileInfo = FileInfo {size}} -> acc + fromIntegral size) 0 <$> readTVarIO files
|
||||
|
||||
getFileCount :: FileStore -> IO Int
|
||||
getFileCount FileStore {files} = M.size <$> readTVarIO files
|
||||
|
||||
expiredFiles :: FileStore -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
|
||||
expiredFiles FileStore {files} old _limit = do
|
||||
fs <- readTVarIO files
|
||||
fmap catMaybes . forM (M.toList fs) $ \(sId, FileRec {fileInfo = FileInfo {size}, filePath, createdAt = RoundedSystemTime createdAt}) ->
|
||||
if createdAt + fileTimePrecision < old
|
||||
then do
|
||||
path <- readTVarIO filePath
|
||||
pure $ Just (sId, path, size)
|
||||
else pure Nothing
|
||||
```
|
||||
5. Add imports: `Data.Maybe (catMaybes)`, `Data.Word (Word32)` (note: `qualified Data.Map.Strict as M` already added in item 3).
|
||||
|
||||
- [ ] **Step 2: Replace `countUsedStorage` in `Env.hs`**
|
||||
|
||||
1. Replace `countUsedStorage <$> readTVarIO (files store)` with `getUsedStorage store` in `newXFTPServerEnv`.
|
||||
2. Remove `countUsedStorage` function definition and its export.
|
||||
3. Remove `qualified Data.Map.Strict as M` import if no longer used.
|
||||
|
||||
- [ ] **Step 3: Update `restoreServerStats` in `Server.hs` to use `getFileCount`**
|
||||
|
||||
In `restoreServerStats` (line 694-696): replace `FileStore {files} <- asks store` and `_filesCount <- M.size <$> readTVarIO files` with `st <- asks store` and `_filesCount <- liftIO $ getFileCount st` (eliminates the `FileStore` pattern match — `files` binding no longer needed).
|
||||
|
||||
- [ ] **Step 4: Replace `expireServerFiles` iteration in `Server.hs`**
|
||||
|
||||
1. Replace the body of `expireServerFiles` (lines 636-660). Remove `files' <- readTVarIO (files st)` and the `forM_ (M.keys files')` loop.
|
||||
2. New body: call `expiredFiles st old 10000` in a loop. For each `(sId, filePath_, fileSize)` in returned list: apply `itemDelay`, remove disk file if present, call `atomically $ deleteFile st sId`, adjust `usedStorage` TVar by `fileSize`, increment `filesExpired` stat. Loop until `expiredFiles` returns `[]`.
|
||||
3. Remove `Data.Map.Strict` import from Server.hs if no longer needed (was used for `M.size` and `M.keys` — now replaced by `getFileCount` and `expiredFiles`).
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 6: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
|
||||
git commit -m "refactor(xftp): add getUsedStorage, getFileCount, expiredFiles store functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Change `Store.hs` functions from STM to IO
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/StoreLog.hs`
|
||||
|
||||
- [ ] **Step 1: Change all Store.hs function signatures from STM to IO**
|
||||
|
||||
For each of: `addFile`, `setFilePath`, `addRecipient`, `getFile`, `deleteFile`, `blockFile`, `deleteRecipient`, `ackFile`:
|
||||
1. Change return type from `STM (Either XFTPErrorType ...)` to `IO (Either XFTPErrorType ...)` (or `STM ()` to `IO ()` for `deleteRecipient`).
|
||||
2. Wrap the function body in `atomically $ do ...`.
|
||||
3. Keep `withFile` and `newFileRec` as internal STM helpers (called inside the `atomically` blocks).
|
||||
|
||||
- [ ] **Step 2: Update Server.hs call sites — remove `atomically` wrappers**
|
||||
|
||||
1. Line 563 (`receiveServerFile`): change `atomically $ writeTVar filePath (Just fPath)` → add `st <- asks store` then `void $ liftIO $ setFilePath st senderId fPath` (design call site #1 — `store` is not in scope in `receiveServerFile`'s `receive` helper, so bind via `asks`; `void` avoids `-Wunused-do-bind` warning on the `Either` result).
|
||||
2. Line 453 (`verifyXFTPTransmission`): split `atomically $ verify =<< getFile st party fId` into: `liftIO (getFile st party fId)` (IO→M lift), then pattern match on result, use `readTVarIO (fileStatus fr)` instead of `readTVar`.
|
||||
3. Lines 371, 377 (control port `CPDelete`/`CPBlock`): change `ExceptT $ atomically $ getFile fs SFRecipient fileId` → `ExceptT $ liftIO $ getFile fs SFRecipient fileId` (inside `unliftIO u $ do` block which runs in M monad — `liftIO` required to lift IO into M).
|
||||
4. Line 508 (`addFile` in `createFile`): the `ExceptT $ addFile st sId file ts EntityActive` — `addFile` is now IO, `ExceptT` wraps IO directly. Remove any `atomically`.
|
||||
5. Line 514 (`addRecipient`): same — `ExceptT . addRecipient st sId` works directly in IO.
|
||||
6. Line 516 (`retryAdd`): change parameter type from `(XFTPFileId -> STM (Either XFTPErrorType a))` to `(XFTPFileId -> IO (Either XFTPErrorType a))`. Line 520: change `atomically (add fId)` to `liftIO (add fId)`.
|
||||
7. Line 605 (`ackFileReception`): change `atomically $ deleteRecipient st rId fr` to `liftIO $ deleteRecipient st rId fr`.
|
||||
8. Line 620 (`deleteOrBlockServerFile_`): change third parameter type from `(FileStore -> STM (Either XFTPErrorType ()))` to `(FileStore -> IO (Either XFTPErrorType ()))`. Line 626: change `void $ atomically $ storeAction st` to `void $ liftIO $ storeAction st`.
|
||||
9. `expireServerFiles` `delete` helper: change `atomically $ deleteFile st sId` to `liftIO $ deleteFile st sId` (deleteFile is now IO; `liftIO` required because the helper runs in M monad, not IO).
|
||||
|
||||
- [ ] **Step 3: Update `StoreLog.hs` — remove `atomically` from replay**
|
||||
|
||||
In `readFileStore` (line 93), function `addToStore`:
|
||||
1. Change `atomically (addToStore lr)` to `addToStore lr` — store functions are now IO.
|
||||
2. The `addToStore` body calls `addFile`, `setFilePath`, `deleteFile`, `blockFile`, `ackFile` — all IO now, no `atomically` needed.
|
||||
3. For `AddRecipients`: `runExceptT $ mapM_ (ExceptT . addRecipient st sId) rcps` — `addRecipient` returns `IO (Either ...)`, so `ExceptT . addRecipient st sId` works directly.
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
|
||||
git commit -m "refactor(xftp): change file store operations from STM to IO"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extract `FileStoreClass` typeclass, move STM impl to `Store/STM.hs`
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/Simplex/FileTransfer/Server/Store.hs`
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/STM.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/StoreLog.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
- Modify: `simplexmq.cabal`
|
||||
|
||||
- [ ] **Step 1: Create `Store/STM.hs` — move all implementation code**
|
||||
|
||||
1. Create directory `src/Simplex/FileTransfer/Server/Store/`.
|
||||
2. Create `src/Simplex/FileTransfer/Server/Store/STM.hs`.
|
||||
3. Move from `Store.hs`: `FileStore` data type (rename to `STMFileStore`), all function implementations, internal helpers (`withFile`, `newFileRec`), all STM-specific imports.
|
||||
4. Rename all `FileStore` references to `STMFileStore` in the new file.
|
||||
5. Module declaration: `module Simplex.FileTransfer.Server.Store.STM` exporting only `STMFileStore (..)` — do NOT export standalone functions (`addFile`, `setFilePath`, etc.) to avoid name collisions with the typeclass methods from `Store.hs`.
|
||||
|
||||
- [ ] **Step 2: Rewrite `Store.hs` as the typeclass module**
|
||||
|
||||
1. Add `{-# LANGUAGE TypeFamilies #-}` pragma to `Store.hs` (required for `type FileStoreConfig s` associated type).
|
||||
2. Keep in `Store.hs`: `FileRec (..)`, `FileRecipient (..)`, `RoundedFileTime`, `fileTimePrecision` definitions and their `StrEncoding` instance.
|
||||
3. Add `FileStoreClass` typeclass:
|
||||
```haskell
|
||||
class FileStoreClass s where
|
||||
type FileStoreConfig s
|
||||
|
||||
-- Lifecycle
|
||||
newFileStore :: FileStoreConfig s -> IO s
|
||||
closeFileStore :: s -> IO ()
|
||||
|
||||
-- File operations
|
||||
addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ())
|
||||
setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ())
|
||||
addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ())
|
||||
getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey))
|
||||
deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ())
|
||||
blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ())
|
||||
deleteRecipient :: s -> RecipientId -> FileRec -> IO ()
|
||||
ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ())
|
||||
|
||||
-- Expiration
|
||||
expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
|
||||
|
||||
-- Stats
|
||||
getUsedStorage :: s -> IO Int64
|
||||
getFileCount :: s -> IO Int
|
||||
```
|
||||
4. Do NOT re-export from `Store/STM.hs` — this would create a circular module dependency (Store.hs imports Store/STM.hs, Store/STM.hs imports Store.hs). Consumers must import `Store.STM` directly where they need `STMFileStore`.
|
||||
5. Remove all STM-specific imports that are no longer needed.
|
||||
|
||||
- [ ] **Step 3: Add `FileStoreClass` instance in `Store/STM.hs`**
|
||||
|
||||
1. Import `FileStoreClass` from `Simplex.FileTransfer.Server.Store`.
|
||||
2. Inline all implementations directly in the instance body (do NOT delegate to standalone functions — the standalone names collide with typeclass method names, causing ambiguous occurrences for importers):
|
||||
```haskell
|
||||
instance FileStoreClass STMFileStore where
|
||||
type FileStoreConfig STMFileStore = ()
|
||||
newFileStore () = do
|
||||
files <- TM.emptyIO
|
||||
recipients <- TM.emptyIO
|
||||
pure STMFileStore {files, recipients}
|
||||
closeFileStore _ = pure ()
|
||||
addFile st sId fileInfo createdAt status = atomically $ ...
|
||||
setFilePath st sId fPath = atomically $ ...
|
||||
-- ... (each method's body is the existing function body, inlined)
|
||||
```
|
||||
3. Remove the standalone top-level function definitions — they are now instance methods. Keep only `withFile` and `newFileRec` as internal helpers used by the instance methods.
|
||||
|
||||
- [ ] **Step 4: Update importers**
|
||||
|
||||
1. `Env.hs`: add `import Simplex.FileTransfer.Server.Store.STM (STMFileStore (..))`. Change `FileStore` → `STMFileStore` in `XFTPEnv` type and `newXFTPServerEnv`. Change `store <- newFileStore` to `store <- newFileStore ()` (typeclass method now takes `FileStoreConfig STMFileStore` which is `()`). Keep `import Simplex.FileTransfer.Server.Store` for `FileRec`, `FileRecipient`, `FileStoreClass`, etc.
|
||||
2. `Server.hs`: add `import Simplex.FileTransfer.Server.Store.STM`. Change `FileStore` → `STMFileStore` in any explicit type annotations. Import `FileStoreClass` from `Simplex.FileTransfer.Server.Store`.
|
||||
3. `StoreLog.hs`: add `import Simplex.FileTransfer.Server.Store.STM` to access concrete `STMFileStore` type and store functions used during log replay. Change `FileStore` → `STMFileStore` in `readWriteFileStore` and `writeFileStore` parameter types.
|
||||
|
||||
- [ ] **Step 5: Update cabal file**
|
||||
|
||||
Add `Simplex.FileTransfer.Server.Store.STM` to `exposed-modules` in the `!flag(client_library)` section, alongside existing XFTP server modules.
|
||||
|
||||
- [ ] **Step 6: Build and verify**
|
||||
|
||||
Run: `cabal build`
|
||||
|
||||
- [ ] **Step 7: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 8: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs simplexmq.cabal
|
||||
git commit -m "refactor(xftp): extract FileStoreClass typeclass, move STM impl to Store.STM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Make `XFTPEnv` and `Server.hs` polymorphic over `FileStoreClass`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
- Modify: `tests/XFTPClient.hs` (if it calls `runXFTPServerBlocking` directly)
|
||||
|
||||
- [ ] **Step 1: Make `XFTPEnv` polymorphic in `Env.hs`**
|
||||
|
||||
1. Add `XFTPStoreConfig` GADT: `data XFTPStoreConfig s where XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore`.
|
||||
2. Change `data XFTPEnv` to `data XFTPEnv s` — field `store :: FileStore` becomes `store :: s`.
|
||||
3. Change `newXFTPServerEnv :: XFTPServerConfig -> IO XFTPEnv` to `newXFTPServerEnv :: FileStoreClass s => XFTPStoreConfig s -> XFTPServerConfig -> IO (XFTPEnv s)`.
|
||||
4. Pattern match on `XSCMemory storeLogPath` in `newXFTPServerEnv` body. Create store via `newFileStore ()`, storeLog via `mapM (`readWriteFileStore` st) storeLogPath`.
|
||||
|
||||
- [ ] **Step 2: Make `Server.hs` polymorphic**
|
||||
|
||||
1. Change `type M a = ReaderT XFTPEnv IO a` to `type M s a = ReaderT (XFTPEnv s) IO a`.
|
||||
2. Add `FileStoreClass s =>` constraint to all functions using `M s a`. Use `forall s.` in signatures of functions that have `where`-block bindings with `M s` type annotations — `ScopedTypeVariables` requires explicit `forall` to bring `s` into scope for inner type signatures (matching SMP's `smpServer :: forall s. MsgStoreClass s => ...` pattern). Full list: `xftpServer`, `processRequest`, `verifyXFTPTransmission`, `processXFTPRequest` and all its `where`-bound functions (`createFile`, `addRecipients`, `receiveServerFile`, `sendServerFile`, `deleteServerFile`, `ackFileReception`, `retryAdd`, `addFileRetry`, `addRecipientRetry`), `deleteServerFile_`, `blockServerFile`, `deleteOrBlockServerFile_`, `expireServerFiles`, `randomId`, `getFileId`, `withFileLog`, `incFileStat`, `saveServerStats`, `restoreServerStats`, `randomDelay` (inside `#ifdef slow_servers` CPP block). Also update `encodeXftp` (line 236) and `runCPClient` (line 339) which use explicit `ReaderT XFTPEnv IO` instead of the `M` alias — change to `ReaderT (XFTPEnv s) IO`.
|
||||
3. Change `runXFTPServerBlocking` and `runXFTPServer` to take `XFTPStoreConfig s` parameter.
|
||||
4. Add `closeFileStore store` call to the server shutdown path (in the `finally` block or `stopServer` equivalent — after saving stats, before logging "Server stopped"). This ensures Postgres connection pool and `dbStoreLog` are properly closed. For STM this is a no-op.
|
||||
|
||||
- [ ] **Step 3: Update `Main.hs` dispatch**
|
||||
|
||||
1. In `runServer`: construct `XSCMemory (enableStoreLog $> storeLogFilePath)`.
|
||||
2. Add dispatch function that calls the updated `runXFTPServer` (which creates `started` internally):
|
||||
```haskell
|
||||
run :: FileStoreClass s => XFTPStoreConfig s -> IO ()
|
||||
run storeCfg = runXFTPServer storeCfg serverConfig
|
||||
```
|
||||
3. Call `run` with the `XSCMemory` config.
|
||||
|
||||
- [ ] **Step 4: Update test helper if needed**
|
||||
|
||||
If `tests/XFTPClient.hs` calls `runXFTPServerBlocking` directly, update the call to pass an `XSCMemory` config. Check the `withXFTPServer` / `serverBracket` helper.
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `cabal build && cabal build test:simplexmq-test`
|
||||
|
||||
- [ ] **Step 6: Run existing tests**
|
||||
|
||||
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs
|
||||
git add src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs tests/XFTPClient.hs simplexmq.cabal
|
||||
git commit -m "refactor(xftp): make XFTPEnv and server polymorphic over FileStoreClass"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Postgres config, migrations, and store skeleton
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs`
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs`
|
||||
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
- Modify: `simplexmq.cabal`
|
||||
|
||||
- [ ] **Step 1: Create `Store/Postgres/Config.hs`**
|
||||
|
||||
```haskell
|
||||
module Simplex.FileTransfer.Server.Store.Postgres.Config
|
||||
( PostgresFileStoreCfg (..),
|
||||
defaultXFTPDBOpts,
|
||||
)
|
||||
where
|
||||
|
||||
import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..))
|
||||
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation)
|
||||
|
||||
data PostgresFileStoreCfg = PostgresFileStoreCfg
|
||||
{ dbOpts :: DBOpts,
|
||||
dbStoreLogPath :: Maybe FilePath,
|
||||
confirmMigrations :: MigrationConfirmation
|
||||
}
|
||||
|
||||
defaultXFTPDBOpts :: DBOpts
|
||||
defaultXFTPDBOpts =
|
||||
DBOpts
|
||||
{ connstr = "postgresql://xftp@/xftp_server_store",
|
||||
schema = "xftp_server",
|
||||
poolSize = 10,
|
||||
createSchema = False
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `Store/Postgres/Migrations.hs`**
|
||||
|
||||
Full migration module with `xftpServerMigrations :: [Migration]` and `m20260325_initial` containing CREATE TABLE SQL for `files` and `recipients` tables plus indexes. Follow SMP's `QueueStore/Postgres/Migrations.hs` pattern exactly: tuple list → `sortOn name . map migration`.
|
||||
|
||||
- [ ] **Step 3: Create `Store/Postgres.hs` with stub instance**
|
||||
|
||||
1. Define `PostgresFileStore` with `dbStore :: DBStore` and `dbStoreLog :: Maybe (StoreLog 'WriteMode)`.
|
||||
2. `instance FileStoreClass PostgresFileStore` with `error "not implemented"` for all methods except `newFileStore` (calls `createDBStore` + opens `dbStoreLog`) and `closeFileStore` (closes both). `type FileStoreConfig PostgresFileStore = PostgresFileStoreCfg`.
|
||||
3. Add `withDB`, `handleDuplicate`, `assertUpdated`, `withLog` helpers.
|
||||
|
||||
- [ ] **Step 4: Add `XSCDatabase` GADT constructor in `Env.hs` (CPP-guarded)**
|
||||
|
||||
```haskell
|
||||
#if defined(dbServerPostgres)
|
||||
import Simplex.FileTransfer.Server.Store.Postgres (PostgresFileStore)
|
||||
import Simplex.FileTransfer.Server.Store.Postgres.Config (PostgresFileStoreCfg)
|
||||
#endif
|
||||
|
||||
data XFTPStoreConfig s where
|
||||
XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore
|
||||
#if defined(dbServerPostgres)
|
||||
XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update cabal**
|
||||
|
||||
Add to existing `if flag(server_postgres)` block:
|
||||
```
|
||||
Simplex.FileTransfer.Server.Store.Postgres
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Config
|
||||
Simplex.FileTransfer.Server.Store.Postgres.Migrations
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build both ways**
|
||||
|
||||
Run: `cabal build && cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Env.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs src/Simplex/FileTransfer/Server/Env.hs simplexmq.cabal
|
||||
git commit -m "feat(xftp): add PostgreSQL store skeleton with schema migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Implement `PostgresFileStore` operations
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Store/Postgres.hs`
|
||||
|
||||
- [ ] **Step 1: Implement `addFile`**
|
||||
|
||||
`INSERT INTO files (sender_id, file_size, file_digest, sender_key, file_path, created_at, status) VALUES (?,?,?,?,NULL,?,?)`. Catch unique violation with `handleDuplicate` → `DUPLICATE_`. Call `withLog "addFile"` after.
|
||||
|
||||
- [ ] **Step 2: Implement `getFile`**
|
||||
|
||||
For `SFSender`: `SELECT ... FROM files WHERE sender_id = ?`. Construct `FileRec` with `newTVarIO` per TVar field. `recipientIds = S.empty`.
|
||||
For `SFRecipient`: `SELECT f.*, r.recipient_key FROM recipients r JOIN files f ON r.sender_id = f.sender_id WHERE r.recipient_id = ?`.
|
||||
|
||||
- [ ] **Step 3: Implement `setFilePath`**
|
||||
|
||||
`UPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL`. Use `assertUpdated`. Call `withLog "setFilePath"`.
|
||||
|
||||
- [ ] **Step 4: Implement `addRecipient`**
|
||||
|
||||
`INSERT INTO recipients (recipient_id, sender_id, recipient_key) VALUES (?,?,?)`. `handleDuplicate` → `DUPLICATE_`. Call `withLog "addRecipient"`.
|
||||
|
||||
- [ ] **Step 5: Implement `deleteFile`, `blockFile`**
|
||||
|
||||
`deleteFile`: `DELETE FROM files WHERE sender_id = ?` (CASCADE). `withLog "deleteFile"`.
|
||||
`blockFile`: `UPDATE files SET status = ? WHERE sender_id = ?`. `assertUpdated`. `withLog "blockFile"`.
|
||||
|
||||
- [ ] **Step 6: Implement `deleteRecipient`, `ackFile`**
|
||||
|
||||
`deleteRecipient`: `DELETE FROM recipients WHERE recipient_id = ?`. `withLog "deleteRecipient"`.
|
||||
`ackFile`: same + return `Left AUTH` if 0 rows.
|
||||
|
||||
- [ ] **Step 7: Implement `expiredFiles`, `getUsedStorage`, `getFileCount`**
|
||||
|
||||
`expiredFiles`: `SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? LIMIT ?`.
|
||||
`getUsedStorage`: `SELECT COALESCE(SUM(file_size), 0) FROM files`.
|
||||
`getFileCount`: `SELECT COUNT(*) FROM files`.
|
||||
|
||||
- [ ] **Step 8: Add `ToField`/`FromField` instances**
|
||||
|
||||
For `RoundedFileTime` (Int64 wrapper), `ServerEntityStatus` (Text via StrEncoding), `C.APublicAuthKey` (Binary via `encodePubKey`/`decodePubKey`). Check SMP's `QueueStore/Postgres.hs` for existing instances to import.
|
||||
|
||||
- [ ] **Step 9: Wrap mutation operations in `uninterruptibleMask_`**
|
||||
|
||||
Operations that combine a DB write with a TVar update (e.g., `getFile` constructs `FileRec` with `newTVarIO`) must be wrapped in `E.uninterruptibleMask_` to prevent async exceptions from leaving inconsistent state. Follow SMP's `addQueue_`, `deleteStoreQueue` pattern.
|
||||
|
||||
- [ ] **Step 10: Build**
|
||||
|
||||
Run: `cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 11: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs
|
||||
git add src/Simplex/FileTransfer/Server/Store/Postgres.hs
|
||||
git commit -m "feat(xftp): implement PostgresFileStore operations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Add INI config, Main.hs dispatch, startup validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
|
||||
- [ ] **Step 1: Update `iniFileContent` in `Main.hs`**
|
||||
|
||||
Add to `[STORE_LOG]` section: `store_files: memory`, commented-out `db_connection`, `db_schema`, `db_pool_size`, `db_store_log` keys. Follow SMP's `optDisabled'` pattern for commented defaults.
|
||||
|
||||
- [ ] **Step 2: Add `StartOptions` and `--confirm-migrations` flag**
|
||||
|
||||
```haskell
|
||||
data StartOptions = StartOptions
|
||||
{ confirmMigrations :: MigrationConfirmation
|
||||
}
|
||||
```
|
||||
Add to `Start` command parser with default `MCConsole`. Thread through to `runServer`.
|
||||
|
||||
- [ ] **Step 3: Add store_files INI parsing and CPP-guarded Postgres dispatch**
|
||||
|
||||
In `runServer`: read `store_files` from INI (`fromRight "memory" $ lookupValue "STORE_LOG" "store_files" ini`). Add `"database"` branch (CPP-guarded) that constructs `PostgresFileStoreCfg` using `iniDBOptions ini defaultXFTPDBOpts` and `enableDbStoreLog'` pattern. Non-postgres build: `exitError`.
|
||||
|
||||
- [ ] **Step 4: Add `XSCDatabase` branch in `newXFTPServerEnv` (`Env.hs`)**
|
||||
|
||||
CPP-guarded pattern match on `XSCDatabase dbCfg`: `newFileStore dbCfg`, `storeLog = Nothing`.
|
||||
|
||||
- [ ] **Step 5: Add startup config validation**
|
||||
|
||||
Add `checkFileStoreMode` (CPP-guarded) before `run`: validate conflicting storeLog file + database mode, missing schema, etc. per design doc.
|
||||
|
||||
- [ ] **Step 6: Build both ways**
|
||||
|
||||
Run: `cabal build && cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 7: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs
|
||||
git add src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs
|
||||
git commit -m "feat(xftp): add PostgreSQL INI config, store dispatch, startup validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add database import/export CLI commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
|
||||
- [ ] **Step 1: Add `Database` CLI command (CPP-guarded)**
|
||||
|
||||
Add `Database StoreCmd DBOpts` constructor to `CliCommand`. Add `database` subcommand parser with `import`/`export` subcommands + `dbOptsP defaultXFTPDBOpts`.
|
||||
|
||||
- [ ] **Step 2: Implement `importFileStoreToDatabase`**
|
||||
|
||||
1. `confirmOrExit` with database details.
|
||||
2. Create temporary `STMFileStore`, replay StoreLog via `readWriteFileStore`.
|
||||
3. Create `PostgresFileStore` with `createSchema = True`, `confirmMigrations = MCYesUp`.
|
||||
4. Batch-insert files using PostgreSQL COPY protocol. Progress every 10k.
|
||||
5. Batch-insert recipients using COPY protocol.
|
||||
6. Verify counts: `SELECT COUNT(*)` — warn on mismatch.
|
||||
7. Rename StoreLog to `.bak`.
|
||||
8. Report counts.
|
||||
|
||||
- [ ] **Step 3: Implement `exportDatabaseToStoreLog`**
|
||||
|
||||
1. `confirmOrExit`. Fail if output file exists.
|
||||
2. Create `PostgresFileStore` from config.
|
||||
3. Open StoreLog for writing.
|
||||
4. Fold over file records: write `AddFile` (with status), `AddRecipients`, `PutFile` per file.
|
||||
5. Close StoreLog, report counts.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cabal build -fserver_postgres`
|
||||
|
||||
- [ ] **Step 5: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs
|
||||
git add src/Simplex/FileTransfer/Server/Main.hs
|
||||
git commit -m "feat(xftp): add database import/export CLI commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Add Postgres tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/XFTPClient.hs`
|
||||
- Modify: `tests/Test.hs`
|
||||
- Create: `tests/CoreTests/XFTPStoreTests.hs`
|
||||
|
||||
- [ ] **Step 1: Add test fixtures in `tests/XFTPClient.hs`**
|
||||
|
||||
```haskell
|
||||
testXFTPStoreDBOpts :: DBOpts
|
||||
testXFTPStoreDBOpts =
|
||||
DBOpts
|
||||
{ connstr = "postgresql://test_xftp_server_user@/test_xftp_server_db",
|
||||
schema = "xftp_server_test",
|
||||
poolSize = 10,
|
||||
createSchema = True
|
||||
}
|
||||
```
|
||||
Add `testXFTPDBConnectInfo :: ConnectInfo` matching the connection string.
|
||||
|
||||
- [ ] **Step 2: Add Postgres server test group in `tests/Test.hs`**
|
||||
|
||||
CPP-guarded block that runs existing `xftpServerTests` with Postgres store config, wrapped in `postgressBracket testXFTPDBConnectInfo`. Parameterize `withXFTPServer` to accept store config if needed.
|
||||
|
||||
- [ ] **Step 3: Create `tests/CoreTests/XFTPStoreTests.hs` — unit tests**
|
||||
|
||||
Test `PostgresFileStore` operations directly:
|
||||
- `addFile` + `getFile SFSender` round-trip.
|
||||
- `addFile` duplicate → `DUPLICATE_`.
|
||||
- `getFile` nonexistent → `AUTH`.
|
||||
- `setFilePath` + verify `WHERE file_path IS NULL` guard.
|
||||
- `addRecipient` + `getFile SFRecipient` round-trip.
|
||||
- `deleteFile` cascades recipients.
|
||||
- `blockFile` + verify status.
|
||||
- `expiredFiles` batch semantics.
|
||||
- `getUsedStorage`, `getFileCount` correctness.
|
||||
|
||||
- [ ] **Step 4: Add migration round-trip test**
|
||||
|
||||
Create `STMFileStore` with test data (files + recipients + blocked status) → export to StoreLog → import to Postgres → export back → compare StoreLog files byte-for-byte.
|
||||
|
||||
- [ ] **Step 5: Build and run tests**
|
||||
|
||||
```bash
|
||||
cabal build -fserver_postgres test:simplexmq-test
|
||||
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -fserver_postgres
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
fourmolu -i tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs
|
||||
git add tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs tests/Test.hs
|
||||
git commit -m "test(xftp): add PostgreSQL backend tests"
|
||||
```
|
||||
@@ -1,126 +0,0 @@
|
||||
# BBS+ Bindings for simplexmq
|
||||
|
||||
Haskell FFI bindings to libbbs for BBS+ signatures. General-purpose - the module knows nothing about specific applications.
|
||||
|
||||
## How BBS+ works
|
||||
|
||||
BBS+ signs a fixed list of N messages. Each message is an arbitrary byte array. The signer signs all N messages at once with one signature.
|
||||
|
||||
The holder of the signature can then generate a proof that selectively discloses some messages and hides others. The verifier learns the disclosed messages and confirms they were signed by the signer, but learns nothing about the hidden messages. Different proofs from the same signature are unlinkable.
|
||||
|
||||
Key constraint: the total number of messages N is fixed at signing time. The verifier must know N. A proof generated from a 3-message signature cannot be verified as a 2-message proof.
|
||||
|
||||
## Types
|
||||
|
||||
```haskell
|
||||
newtype BBSSecretKey = BBSSecretKey ByteString -- 32 bytes
|
||||
newtype BBSPublicKey = BBSPublicKey ByteString -- 96 bytes (BLS12-381 G2 point)
|
||||
newtype BBSSignature = BBSSignature ByteString -- 80 bytes
|
||||
newtype BBSProof = BBSProof ByteString -- 272 + 32 * numUndisclosed bytes
|
||||
newtype BBSHeader = BBSHeader ByteString -- always-disclosed context (e.g. protocol identifier)
|
||||
newtype BBSPresHeader = BBSPresHeader ByteString -- random nonce for proof unlinkability
|
||||
```
|
||||
|
||||
All newtypes get StrEncoding (base64url), ToJSON/FromJSON (via strToJSON/strParseJSON), Eq, Show.
|
||||
|
||||
## Functions
|
||||
|
||||
```haskell
|
||||
bbsKeyGen :: IO (Either String BBSKeyPair) -- BBSKeyPair = (BBSPublicKey, BBSSecretKey)
|
||||
|
||||
-- pk is derived from sk internally, so it is not a parameter
|
||||
bbsSign
|
||||
:: BBSSecretKey
|
||||
-> BBSHeader -- always-disclosed context
|
||||
-> [ByteString] -- all N messages
|
||||
-> IO (Either String BBSSignature)
|
||||
|
||||
-- C order: pk, signature, header, presentation_header, disclosed_indexes, messages
|
||||
bbsProofGen
|
||||
:: BBSPublicKey
|
||||
-> BBSSignature
|
||||
-> BBSHeader -- must match what was signed
|
||||
-> BBSPresHeader -- random nonce bound into the proof
|
||||
-> [Int] -- disclosed indexes (0-based)
|
||||
-> [ByteString] -- all N messages (needed internally, hidden ones not revealed in proof)
|
||||
-> IO (Either String BBSProof)
|
||||
|
||||
-- C order: pk, proof, header, presentation_header, disclosed_indexes, n, messages
|
||||
bbsProofVerify
|
||||
:: BBSPublicKey
|
||||
-> BBSProof
|
||||
-> BBSHeader -- must match what was signed
|
||||
-> BBSPresHeader -- must match what was used in bbsProofGen
|
||||
-> [Int] -- disclosed indexes
|
||||
-> Int -- total message count N
|
||||
-> [ByteString] -- disclosed messages only
|
||||
-> IO Bool
|
||||
```
|
||||
|
||||
## How applications use it
|
||||
|
||||
An application defines:
|
||||
- A message layout: which index means what
|
||||
- Which indexes are disclosed vs hidden
|
||||
- How to encode application values as ByteString messages
|
||||
|
||||
### Badge example (in simplex-chat, not in this module)
|
||||
|
||||
Message layout (always 3 messages):
|
||||
- Index 0: master secret (32 random bytes) - HIDDEN
|
||||
- Index 1: expiry (UTF-8 encoded timestamp string) - DISCLOSED
|
||||
- Index 2: badge type (UTF-8 encoded, e.g. "supporter") - DISCLOSED
|
||||
|
||||
Signing (v2, on the server):
|
||||
```
|
||||
bbsSign sk header [ms, encodeUtf8 "2026-07-31", encodeUtf8 "supporter"]
|
||||
```
|
||||
|
||||
Proof generation (v2, on the client):
|
||||
```
|
||||
bbsProofGen pk sig header presHeader [1, 2] [ms, encodeUtf8 "2026-07-31", encodeUtf8 "supporter"]
|
||||
```
|
||||
|
||||
Proof verification (v1, on the recipient):
|
||||
```
|
||||
bbsProofVerify pk proof header presHeader 3 [1, 2] [encodeUtf8 "2026-07-31", encodeUtf8 "supporter"]
|
||||
```
|
||||
|
||||
The recipient only sees the proof, presentationHeader, expiry string, and badge type string. They verify these were signed by the server (pk is hardcoded). They never see the master secret.
|
||||
|
||||
Expiry is always present as a string. Monthly badges use a date like `"2026-07-31"`, lifetime badges use `"lifetime"`. BBS+ doesn't interpret the bytes - expiry semantics are the application's responsibility. This keeps the message count fixed at 3 for all badge types.
|
||||
|
||||
## libbbs C API mapping
|
||||
|
||||
```c
|
||||
int bbs_keygen_full(ciphersuite, sk, pk)
|
||||
int bbs_sign(ciphersuite, sk, pk, signature, header, header_len, n, messages, message_lens)
|
||||
int bbs_proof_gen(ciphersuite, pk, signature, proof, header, header_len, presentation_header, presentation_header_len, disclosed_indexes, disclosed_indexes_len, n, messages, message_lens)
|
||||
int bbs_proof_verify(ciphersuite, pk, proof, proof_len, header, header_len, presentation_header, presentation_header_len, disclosed_indexes, disclosed_indexes_len, n, messages, message_lens)
|
||||
```
|
||||
|
||||
We use `bbs_sha256_ciphersuite`. The header parameter is exposed in all Haskell functions - the application decides what to put there. Tests use `"SimpleX"` as header.
|
||||
|
||||
The `presentation_header` parameter is what we call `presentationHeader`.
|
||||
|
||||
In `bbs_proof_verify`, the `n` parameter is the total number of messages (not the number of disclosed messages). The `messages` array contains only the disclosed messages, and `disclosed_indexes` maps each to its position in the original message list.
|
||||
|
||||
## Build
|
||||
|
||||
Submodules in cbits/:
|
||||
- `cbits/libbbs` - https://github.com/Fraunhofer-AISEC/libbbs
|
||||
- `cbits/blst` - https://github.com/supranational/blst (libbbs dependency)
|
||||
|
||||
C sources in cabal: `cbits/blst/src/server.c`, `cbits/blst/build/assembly.S`, libbbs source files.
|
||||
Include dirs: `cbits/blst/bindings/`, `cbits/blst/src/`, `cbits/libbbs/include/`, `cbits/libbbs/src/`.
|
||||
C flags: `-D__BLST_PORTABLE__` for cross-CPU-generation compatibility.
|
||||
|
||||
## Tests
|
||||
|
||||
- Keygen produces keys of correct size
|
||||
- Sign + proofGen + proofVerify roundtrip succeeds
|
||||
- Tampered proof fails verification
|
||||
- Tampered disclosed message fails verification
|
||||
- Wrong public key fails verification
|
||||
- Two proofs from same credential with different nonces both verify
|
||||
- Proof size matches expected (272 + 32 * numUndisclosed)
|
||||
@@ -1,57 +0,0 @@
|
||||
## Root cause: orphaned `Sub` entries in the service client's `subscriptions` map
|
||||
|
||||
**The leak is service-specific and was introduced by PR #1667 "messaging services" (`f0b7a4be`).** A long-lived messaging-service connection accumulates per-queue `Sub` records in its `Client.subscriptions` map that are **never removed** when the associated queues are deleted or unassociated — only the counter is decremented. Over normal queue churn the map grows monotonically for the entire lifetime of the service connection.
|
||||
|
||||
### The proof — an asymmetry between two handlers in `serverThread`
|
||||
|
||||
Both individual queue subscriptions and service subscriptions store a `Sub` per queue in `Client.subscriptions` (= `clientSubs` for the SMP subscriber thread, wired at `Server.hs:189`). When a queue ends/is deleted, the two paths diverge:
|
||||
|
||||
**Individual subscriber — entry IS removed** (`Server.hs:332`, `346`):
|
||||
```haskell
|
||||
CSAEndSub qId -> atomically (endSub c qId) >>= a unsub_ -- :332
|
||||
...
|
||||
endSub c qId = TM.lookupDelete qId (clientSubs c) >>= (removeWhenNoSubs c $>) -- :346
|
||||
```
|
||||
|
||||
**Service subscriber — entry is NOT removed** (`Server.hs:336-340`):
|
||||
```haskell
|
||||
CSAEndServiceSub qId -> atomically $ do
|
||||
modifyTVar' (clientServiceSubs c) decrease -- decrements serviceSubsCount
|
||||
modifyTVar' totalServiceSubs decrease -- decrements global count
|
||||
where decrease = subtractServiceSubs (1, queueIdHash qId)
|
||||
-- never touches (clientSubs c) — the Sub for qId stays forever
|
||||
```
|
||||
|
||||
### Where the orphaned entries are added (both new in this PR)
|
||||
- `Server.hs:1860-1862` — on service subscribe (`SSUB`), one `Sub` inserted per queue that has a pending message.
|
||||
- `Server.hs:2039-2043` (`newServiceDeliverySub`) — on **every** `SEND` to a service-associated queue with no existing sub, a `Sub` is inserted into the service client's `subscriptions`. After delivery the thread state resets to `NoSub` (`:2069`) but the map entry remains as a "already delivering" marker (`:1856-1859`).
|
||||
|
||||
### Why they leak
|
||||
The only places the service client's `subscriptions` map is cleared are:
|
||||
- `clientDisconnected` — `swapTVar subscriptions M.empty` (`Server.hs:1097`) — only on disconnect.
|
||||
- `CSADecreaseSubs` — `swapTVar (clientSubs c) M.empty` (`Server.hs:343`) — only on full service takeover by another connection.
|
||||
- `delQueueAndMsgs` — `TM.lookupDelete entId $ subscriptions clnt` (`Server.hs:2164`) — but `clnt` here is **the recipient deleting its own queue, not the service client**. The service's entry for that queue is reached only via the `CSDeleted → endServiceSub → CSAEndServiceSub` path (`Server.hs:306, 313, 336`), which decrements the counter but leaves the map entry.
|
||||
|
||||
**Concrete scenario (fully traced):** Service `S` subscribes (`SSUB`) and stays connected for days. Recipient `R` owns service-associated queue `Q`. A `SEND` to `Q` inserts a `Sub` into `S.subscriptions[Q]` (`:2042`). `R` later deletes `Q` → `delQueueAndMsgs` runs on `R`'s connection, removes `Q` from `R.subscriptions`, decrements counters, enqueues `CSDeleted Q (Just S)` (`:2167`) → `serverThread` runs `CSAEndServiceSub Q` for `S` (`:336`), decrementing `S.serviceSubsCount` but **leaving `S.subscriptions[Q]` in place**. Net: one orphaned `Sub` (record + 2 TVars) per service-associated queue ever deleted/unassociated, never reclaimed until `S` disconnects. The logical counter `serviceSubsCount` correctly drops, so the map size diverges from the counter — making the leak invisible to the existing service-sub metric.
|
||||
|
||||
### Verdict
|
||||
This is a deterministic, static-provable memory leak — no production logging needed to confirm the existence; the asymmetry between `CSAEndSub` (removes) and `CSAEndServiceSub` (doesn't) is the smoking gun. It is specific to messaging-service certificate clients, which is exactly the population added by the services/certificate PR.
|
||||
|
||||
### Secondary findings (lower impact, same PR area, not the primary cause)
|
||||
- **`forkClient` register-after-fork race** (`Server.hs:1356-1359`): if the forked action's `finally` delete (`:1358`) runs before the parent's `IM.insert` (`:1359`), a `Weak ThreadId` of a dead thread is left in `endThreads` until disconnect. Pre-existing, tiny per-entry, but exercised far more by the PR's higher END/DELD volume.
|
||||
- **Wrong-client counter decrement** (`Server.hs:2166`): `delQueueAndMsgs` decrements `serviceSubsCount` of the *deleting* client, not the service; harmless for non-service deleters (floored at 0) but corrupts accounting if a service deletes its own queue.
|
||||
|
||||
---
|
||||
|
||||
### Recommended fix (mirror `endSub` in the service path)
|
||||
Make `CSAEndServiceSub` also delete the per-queue `Sub` and cancel its delivery thread, exactly as `CSAEndSub`/`endSub` do for individual subscribers. Roughly:
|
||||
|
||||
```haskell
|
||||
CSAEndServiceSub qId -> do
|
||||
s_ <- atomically $ do
|
||||
modifyTVar' (clientServiceSubs c) decrease
|
||||
modifyTVar' totalServiceSubs decrease
|
||||
TM.lookupDelete qId (clientSubs c) <* removeWhenNoSubs c
|
||||
forM_ unsub_ $ \unsub -> mapM_ unsub s_
|
||||
where decrease = subtractServiceSubs (1, queueIdHash qId)
|
||||
```
|
||||
@@ -1,135 +0,0 @@
|
||||
# Service RPC implementation plan
|
||||
|
||||
RFC: [../rfcs/2026-07-11-service-rpc.md](../rfcs/2026-07-11-service-rpc.md)
|
||||
|
||||
Depends on: [2026-07-12-address-dr-implementation.md](2026-07-12-address-dr-implementation.md). RPC establishes the double ratchet from the address exactly as address-DR does; this is the RPC layer on top of it.
|
||||
|
||||
**Status: implemented and tested in this repo.** Service-side idempotency (single execution by request hash) is deferred. The `simplex-chat` integration is a separate repo.
|
||||
|
||||
**Scope.** One request, one response — no continuation, no streaming.
|
||||
|
||||
One DR-advertising contact address serves both flows: the owner branches per incoming request on the decrypted inner message — `AgentConnInfoReply` opens a connection (`REQ`), `AgentServiceRequest` answers an RPC (`SREQ`). A request gets exactly one reply, a response or a rejection; both are the single confirming message on the requester's reply queue Q_A, after which the ephemeral reply connection is torn down. Response and rejection are the same operation parameterized by the inner message (`AgentServiceResponse` payload vs `AgentRejection` reason) and outcome (the call returns the payload vs throws an agent error).
|
||||
|
||||
## RPC messages
|
||||
|
||||
No new outer envelope: the request reuses `AgentContactRequest` (tag `'A'`); the reply reuses `AgentConfirmation` (the only message on Q_A).
|
||||
|
||||
Inner `AgentMessage` (ratchet-encrypted, parsed by `parseMessage`), siblings of `AgentConnInfoReply`:
|
||||
|
||||
```haskell
|
||||
| AgentServiceRequest (NonEmpty SMPQueueInfo) MsgBody -- 'A': reply queue Q_A + opaque payload
|
||||
| AgentServiceResponse MsgBody -- 'P': response payload (single, terminal)
|
||||
| AgentRejection ByteString -- 'J': refusal reason (single, terminal)
|
||||
```
|
||||
|
||||
`AgentServiceRequest` carries Q_A (as `AgentConnInfoReply` does); its constructor is the only thing that distinguishes `REQ` from `SREQ`. Delivery `msgType`: `AM_SRV_RESP` routes to `sendConfirmation` (the reply is the confirming first message on Q_A). `AM_SRV_REQ` is never stored — the request is sent synchronously inside `joinConnSrv'` via `sendInvitation`, so its arms in the delivery worker are unreachable and assert (`logError`).
|
||||
|
||||
## Ratchet establishment — reuse of the address-DR flow
|
||||
|
||||
`joinConnSrv'` takes `mkInner :: SMPQueueInfo -> AgentMessage`; `joinConnSrv` is the one-line wrapper passing `AgentConnInfoReply`. `sendServiceRequest'` passes `AgentServiceRequest`.
|
||||
|
||||
**Request (client).** `serviceRequest_` fails fast with `A_SERVICE ASENotDRAddress` if the address carries no ratchet keys, then creates the client connection via `newConnToJoin` with `serviceRequestExpiresAt = Just (now + reqTimeout)` (the persisted per-request deadline), registers a one-shot `TMVar` in `serviceRequests`, sends the request, and blocks on the `TMVar` up to `reqTimeout`. The connection is `RcvConnection` (Q_A) with the send ratchet.
|
||||
|
||||
**Request (service).** `smpContactRequest` decrypts `encConnInfo` and branches on the inner message; both branches call the same `storeInvitation` → `conn_invitations`, differing only in the kind column and event:
|
||||
|
||||
- `AgentConnInfoReply` → `REQ` (`service_request = 0`).
|
||||
- `AgentServiceRequest _ payload` → `SREQ invId payload` (`service_request = 1`).
|
||||
|
||||
Before storing, it **deduplicates** by the sender's ratchet-key hash (`checkRatchetKeyHashExists`/`addProcessedRatchetKeyHash`, the mechanism `newRatchetKey` uses): a redelivered/retried request reuses the same Q_A and the same `e2eSndParams`, so the hash matches and the duplicate is dropped — one invitation and one `REQ`/`SREQ` per request. Receive-time establishment on unauthenticated input — the address-DR abuse bound applies.
|
||||
|
||||
**The reply (service).** `prepareReply` fetches the invitation, enforces the kind (`CMD PROHIBITED` on the wrong one), and rejects a stale request (`A_SERVICE ASETimeout` + delete) older than `serviceResponseTimeout`; then `newConnToAccept` + `startJoinInvitationDR` build the one-directional `SndQueue` to Q_A (no reply queue back), and `storeConfirmation` queues the inner message. `sendReplySync` secures Q_A, submits the message, and deletes the connection with wait-for-delivery — **deleting the connection on failure too** (`catchAllErrors`), so a failed secure/submit does not orphan it. `sendServiceReplyAsync` defers secure+deliver+delete to the `ICReplyDel` command (retried, survives a down server). `sendServiceReply`/`Async` and `replyRequest_` return the reply `ConnId` so the caller can correlate the `SENT` event on that throwaway connection.
|
||||
|
||||
**The response (client).** The single `AgentConfirmation` on Q_A reaches `processConnInfo` (the `RcvConnection … New` branch). Dispatch is gated on `serviceRequestExpiresAt` and the kinds are mutually exclusive:
|
||||
|
||||
- `AgentConnInfoReply` **only when `isNothing serviceRequestExpiresAt`** (a contact connection) → `processConf`.
|
||||
- `AgentServiceResponse` only when `isJust` → the request `TMVar` gets `Right payload`.
|
||||
- `AgentRejection` when `isJust` → `Left (A_SERVICE (ASERejected reason))`; when `isNothing` → contact `RJCT`.
|
||||
- anything else → `prohibited`.
|
||||
|
||||
The `isNothing` guard on `AgentConnInfoReply` is a security boundary: without it a malicious service could send `AgentConnInfoReply` on an RPC reply queue and drive it into the contact-`CONF` path. `dispatchServiceReply` puts the result into the `serviceRequests` `TMVar`; a reply with no pending request (e.g. post-restart) is `ERR (A_SERVICE ASENoPendingRequest)`.
|
||||
|
||||
## Rejection
|
||||
|
||||
A rejection is `AgentRejection reason` — the same single confirming message on Q_A as a response.
|
||||
|
||||
- **Kind guard.** `rejectContact` only on a contact invitation, `rejectServiceRequest`/`sendServiceReply` only on a request; wrong kind is `CMD PROHIBITED`. `rejectRequest_` enforces the kind **even on a `Nothing` (silent-drop) reject** — it fetches the invitation and checks before deleting, so `rejectContact … Nothing` cannot delete a service request (or vice versa).
|
||||
- `reject*` take `Maybe ByteString`: `Nothing` → delete the invitation, send nothing (the requester times out); `Just reason` → the reply path with `AgentRejection`.
|
||||
- Requester side: `AgentRejection` on a contact reply queue → `RJCT`; on an RPC reply queue → a thrown `A_SERVICE (ASERejected reason)`.
|
||||
|
||||
## Reply connections and cleanup
|
||||
|
||||
No reply-queue table and no new connection type.
|
||||
|
||||
- **Requester reply queue** (`RcvConnection` on Q_A): `connections.service_request_expires_at` is non-null only here; it is the persisted request deadline, used both to gate CONF dispatch and to reap the connection. In-memory routing is `serviceRequests :: TMap ConnId (TMVar (Either AgentErrorType MsgBody))`.
|
||||
- **Timeout race.** The async `JOIN` worker holds `withConnLock c connId` around `joinConnSrv'`, and `serviceRequest_`'s cleanup holds the same lock around `TM.delete` + `deleteConnectionAsync'`. This serializes the send with the timeout teardown, so a timing-out call cannot delete the connection mid-send; after cleanup the worker's re-check of `serviceRequests` finds nothing and skips.
|
||||
- **Service reply connection** (`SndConnection` to Q_A): ephemeral — created, sends the one reply, deleted with wait-for-delivery in the same operation.
|
||||
- **Cleanup** (`cleanupManager`, `deleteExpiredServiceReqs`): `deleteExpiredServiceRequests` reaps unanswered `conn_invitations` (service side) older than `serviceResponseTimeout`; `getExpiredServiceConns` (`service_request_expires_at < now`) → `deleteConnectionsAsync'` reaps orphaned requester reply queues.
|
||||
|
||||
## Database schema
|
||||
|
||||
`M20260712_address_dr_rpc` (SQLite + PostgreSQL) creates `address_ratchet_keys` (address-DR) and adds:
|
||||
|
||||
```sql
|
||||
ALTER TABLE conn_invitations ADD COLUMN service_request INTEGER NOT NULL DEFAULT 0; -- service side: 1 = RPC request
|
||||
ALTER TABLE connections ADD COLUMN service_request_expires_at TEXT; -- client side: request deadline; gating + cleanup (nullable)
|
||||
```
|
||||
|
||||
The down migration drops the columns then the table/index. Schema dump tests pass (up, down, STRICT).
|
||||
|
||||
## Agent API — `Simplex.Messaging.Agent`
|
||||
|
||||
```haskell
|
||||
-- service: send the one response, return the reply ConnId, then tear the reply connection down.
|
||||
sendServiceReply :: AgentClient -> NetworkRequestMode -> UserId -> InvitationId -> MsgBody -> AE ConnId
|
||||
sendServiceReplyAsync :: AgentClient -> ACorrId -> UserId -> InvitationId -> MsgBody -> AE ConnId
|
||||
|
||||
-- refuse a request (Just reason = AgentRejection; Nothing = silent drop). PROHIBITED on wrong kind.
|
||||
rejectServiceRequest :: AgentClient -> NetworkRequestMode -> UserId -> InvitationId -> Maybe ByteString -> AE ()
|
||||
rejectServiceRequestAsync :: AgentClient -> ACorrId -> UserId -> InvitationId -> Maybe ByteString -> AE ()
|
||||
rejectContact :: AgentClient -> NetworkRequestMode -> UserId -> ConfirmationId -> Maybe ByteString -> AE ()
|
||||
rejectContactAsync :: AgentClient -> ACorrId -> UserId -> ConfirmationId -> Maybe ByteString -> AE ()
|
||||
|
||||
-- client: establish the ratchet from the address, send the request, block on the reply TMVar up to the timeout
|
||||
-- (Nothing = serviceRequestTimeout; Just t overrides per request), returning the payload. Sync fails fast if the
|
||||
-- server is down; async enqueues a retried JOIN command that survives an outage.
|
||||
sendServiceRequest :: AgentClient -> NetworkRequestMode -> UserId -> ConnectionRequestUri 'CMContact -> Maybe NominalDiffTime -> MsgBody -> AE MsgBody
|
||||
sendServiceRequestAsync :: AgentClient -> UserId -> ConnectionRequestUri 'CMContact -> Maybe NominalDiffTime -> MsgBody -> AE MsgBody
|
||||
```
|
||||
|
||||
Both client calls share `serviceRequest_`; the async `JOIN` worker branches on the `JRServiceReq` command to send `AgentServiceRequest`. The call blocks on the `TMVar` and returns synchronously — no events, no correlation for the app.
|
||||
|
||||
Events (`AEvent`, entity is the address connection):
|
||||
|
||||
```haskell
|
||||
SREQ :: InvitationId -> MsgBody -> AEvent AEConn -- payload = the request; mirrors REQ.
|
||||
RJCT :: ConnInfo -> AEvent AEConn -- contact-request rejection reason.
|
||||
```
|
||||
|
||||
Errors (`SMPAgentError`):
|
||||
|
||||
```haskell
|
||||
| A_SERVICE {serviceError :: AgentServiceError}
|
||||
|
||||
data AgentServiceError
|
||||
= ASERejected {rejectReason :: Text} -- service refused (Text: JSON-serializable, UTF-8-decoded from the reason bytes)
|
||||
| ASETimeout -- no reply within the timeout
|
||||
| ASENoPendingRequest -- a reply arrived with no pending request (e.g. post-restart)
|
||||
| ASENotDRAddress -- the target address advertises no ratchet keys (fail fast, no send)
|
||||
```
|
||||
|
||||
Config (`AgentConfig`): `serviceRequestTimeout` (30 s, client wait, overridable per request) and `serviceResponseTimeout` (180 s, service reply window and cleanup TTL; must exceed `serviceRequestTimeout`).
|
||||
|
||||
## Idempotency (deferred)
|
||||
|
||||
Not built. When built, the service will key a request by hash and cache the one response for a retention period, answering a repeat from storage without reaching the bot — single execution over at-least-once delivery, with its own tables.
|
||||
|
||||
## Tests
|
||||
|
||||
In `FunctionalAPITests` (plus the encoding roundtrip in `ConnectionRequestTests`), passing with `-O0`:
|
||||
|
||||
- Request → one response, sync (`sendServiceReply`) and async (`sendServiceReplyAsync`).
|
||||
- Request → rejection (`rejectServiceRequest (Just reason)` → thrown `A_SERVICE (ASERejected …)`).
|
||||
- Resilience: `server down → send → up → receive → down → reply → up → receive response`.
|
||||
- No regression: the contact rejection and DR-join suites still pass.
|
||||
|
||||
The `simplex-chat` end-to-end tests (happy path, drop-when-off, non-DR fail-fast) live in that repo.
|
||||
@@ -1,279 +0,0 @@
|
||||
# Establishing the double ratchet from address data - implementation plan
|
||||
|
||||
RFC: [../rfcs/2026-07-12-address-pqdr-keys.md](../rfcs/2026-07-12-address-pqdr-keys.md)
|
||||
|
||||
All references are to the current tree. Names of new constructors, fields, tables and functions are provisional.
|
||||
|
||||
Goal: a contact address advertises the owner's X3DH parameters in link data; a requester establishes the double ratchet in its first message, so that message and the profile in it are under the ratchet with post-quantum protection. The change reuses the invitation/confirmation machinery, with the requester in the joiner role and the owner in the initiator role - opposite to today's contact flow, but every message and code path below is reused.
|
||||
|
||||
Version: `addressDRVersion = VersionSMPA 8`, a plain agent-layer bump; `currentSMPAgentVersion` goes 7 → 8 (Agent/Protocol.hs:317-324). It gates the `AgentConfirmation.ratchetKeyId` field and the DR-from-address behavior. The receive-at-address path relies on ratchet-on-confirmation, already present since `ratchetOnConfSMPAgentVersion = 7` (Agent/Protocol.hs:317), so there is no cross-layer version dependency; the SMP and e2e-encryption versions are unchanged.
|
||||
|
||||
Scope of this change: the **synchronous** DR handshake in join, gated on the address advertising `ratchetKeys`. `joinConnection`/`joinConn`/`joinConnSrv` gain an optional `Maybe AddressRatchetKeys` (the advertised `RcvE2ERatchetParamsUri` + `ratchetKeyId`), passed in from the link data the caller fetched at plan time (`LGET`); present → DR path (R2'/R3'), absent → the classic `AgentInvitation`. Chat wires that argument later (a chat change); the agent supports it now and tests pass it directly. Making the send **async** (worker retry, a "connecting" UX, the `CreatedConnLink` LGET-gate) is **deferred** - kept below under "Deferred" as future work, not part of this change.
|
||||
|
||||
### Implementation status (as built; `lib:simplexmq` compiles)
|
||||
|
||||
**Done** (compiles): version bump; `RatchetKeyId`/`AddressRatchetKeys` types + `Encoding`, `UserContactData.ratchetKeys` (appended, backward-compatible); `AgentConfirmation.ratchetKeyId` (version-gated encode/decode); `ContactRequest`/`DRRequest` sum with tagged `Encoding` + `cr_invitation` `ToField`/`FromField` (legacy-URI fallback); `address_ratchet_keys` table + `createAddressRatchetKeys`/`getAddressRatchetKeys` (SQLite + Postgres migrations `M20260712_address_dr`); join threading (`Maybe AddressRatchetKeys`); requester R2'/R3' (`joinAddressDR` + `sendConfirmationToAddress`); owner O1' dispatch, O2' `smpAddressConfirmation`, O3' (`acceptContact'` continue-ratchet branch), all three `connReq` readers (`acceptContact'`, `acceptContactAsync'` → `CMD PROHIBITED` for DR, `newConnToAccept` → shell from `drAgentVersion`/`drPQSupport`); requester R5' (`smpConfirmation` `RcvConnection … Nothing` branch, guarded on a ratchet existing); address-creation bundle generation (`mkAddressRatchetKeys`) wired into `createConnectionForLink'` (`IKUsePQ`-for-`SCMContact` prohibition lifted there).
|
||||
|
||||
**Deltas from the plan discovered while building:**
|
||||
- **R5' emits `CONF` and reuses the allow step** (not auto-complete). The DR requester is a `RcvConnection` receiving the owner's reply - the same position as the classic contact requester, which goes `CONF` → `allowConnection'` → `connectReplyQueues` (msg 3). R5' mirrors that (differing only in that the ratchet already exists, so it `getRatchet` + `rcDecrypt` instead of building it), so the app supplies `ownConnInfo` for msg 3 at allow, exactly as today. No new storage.
|
||||
- **`DRRequest` carries `drAgentVersion` + `drPQSupport`** (Part 3): the sync accept creates the connection shell via `newConnToAccept`→`newConnToJoin` before O3', and there is no URI to derive the version/PQ from.
|
||||
- `cr_invitation` serialization is downgrade-safe: `CRInvitation` keeps the legacy URI (`strEncode`, byte-identical to before), so an older agent still reads classic invitations; `CRConfirmation` is JSON (`DRRequest` has manual `ToJSON`/`FromJSON`), told apart on read by the leading `{` (a URI never starts with it). JSON keeps `DRRequest` extensible. `SMPQueueInfo` gained a base64 `StrEncoding` + JSON (it only had `Encoding`) so it can sit in the JSON.
|
||||
- **DR is opt-in per address**: `createConnectionForLink'`/`createConnectionForLink` gain a `Maybe InitialKeys` DR parameter (separate from the existing connection-PQ `InitialKeys`) - `Nothing` = no DR (old behavior, existing callers), `Just ik` = advertise the bundle with `ik`. The `IKUsePQ`-for-`SCMContact` prohibition stays on the connection-PQ parameter and is lifted only for the DR bundle.
|
||||
|
||||
**Test-matrix consequence of the version bump:** `currentSMPAgentVersion` 7 → 8 moves the version-matrix "prev" (`current − 1`) from v6 to v7. v7 ≥ `ratchetOnConfSMPAgentVersion (7)`, so a joiner/acceptor at "prev" now secures the send queue on confirmation - the `sqSecured` expectation for the prev variants in `testMatrix2`/`testMatrix2Stress`/`testBasicMatrix2` flips `False → True`. (Standard version-bump maintenance; the pre-`ratchetOnConf` unsecured path is now two versions back and no longer exercised by these matrices.)
|
||||
|
||||
**Not yet done:** rotation (`rotateRatchetKeys`, Part 4), cleanup step (Part 4), the app-driven `LSET` upgrade API (Part 5), wiring the DR parameter into the non-prepared-link `newRcvConnSrv` path, DR-specific tests (Part 6), regenerating `agent_schema.sql` if a schema-consistency test requires it, and chat wiring (deferred by design).
|
||||
|
||||
## Part 1 - the current contact-address handshake, step by step
|
||||
|
||||
Requester Alice connects to owner Bob's contact address. Q_A is Alice's receive queue (Bob to Alice), Q_B is Bob's receive queue (Alice to Bob).
|
||||
|
||||
Requester side, in `joinConnSrv … CRContactUri` (Agent.hs:1398-1428):
|
||||
|
||||
- R1. `compatibleContactUri` (Agent.hs:1370) - version check, yields the address queue `SMPQueueInfo`.
|
||||
- R2. `mkJoinInvitation` (Agent.hs:1411): creates or reuses the receive queue Q_A; `getRatchetX3dhKeys` or `generateRcvE2EParams` produces Alice's Rcv X3DH parameters, stored by `createRatchetX3dhKeys` (Agent.hs:1424); builds `cReq = CRInvitationUri crData aliceRcvParams` (Agent.hs:1426).
|
||||
- R3. `sendInvitation` (Agent.hs:1408; Agent/Client.hs:1924-1934): sends `AgentInvitation {connReq = cReq, connInfo = aliceProfile}` to the address queue, per-queue encrypted with a fresh ephemeral key by `agentCbEncryptOnce` (Agent/Client.hs:1929-1934), unauthenticated. **`connInfo` (Alice's profile) is under the per-queue X25519 layer only - the gap this plan closes.**
|
||||
|
||||
Owner side, receiving on the contact address:
|
||||
|
||||
- O1. `processClientMsg` dispatch (Agent.hs:3185): state `(Nothing, Just e2ePubKey)`, `(PHEmpty, AgentInvitation {connReq, connInfo})` -> `smpInvitation` (Agent.hs:3186).
|
||||
- O2. `smpInvitation` (Agent.hs:3610): stores an `Invitation`, emits `REQ` with Alice's `connInfo`.
|
||||
- O3. `acceptContact'` (Agent.hs:1477): `getInvitation`, then `joinConn` with Alice's `connReq` (Agent.hs:1480).
|
||||
- O4. `joinConnSrv … CRInvitationUri` (Agent.hs:1383) -> `startJoinInvitation` (Agent.hs:1395).
|
||||
- O5. `startJoinInvitation` (Agent.hs:1310-1350): creates Bob's send queue to Q_A (`newSndQueue`, Agent.hs:1335); `createRatchet_` (Agent.hs:1343-1350) runs `generateSndE2EParams`, `pqX3dhSnd` against Alice's Rcv parameters, `initSndRatchet`, `createSndRatchet`.
|
||||
- O6. `secureConfirmQueue` (Agent.hs:1396, 3747-3765): `agentSecureSndQueue` secures Q_A with `SKEY` (Agent.hs:3749); `mkAgentConfirmation` (Agent.hs:3780-3785) calls `createReplyQueue` to create Bob's receive queue Q_B and returns `AgentConnInfoReply (Q_B :| []) bobInfo`; `mkConfirmation` ratchet-encrypts it and wraps `AgentConfirmation {e2eEncryption_ = Just bobSndParams, encConnInfo}`; `sendConfirmation` sends it to Q_A. This is confirmation #1.
|
||||
|
||||
Requester side, receiving confirmation #1 on Q_A:
|
||||
|
||||
- R4. dispatch (Agent.hs:3181-3183): state `(Nothing, Just e2ePubKey)`, `AgentConfirmation` -> `smpConfirmation`.
|
||||
- R5. `smpConfirmation`, initiating-party branch `RcvConnection … Just e2eEncryption` (Agent.hs:3405-3444): `getRatchetX3dhKeys`, `pqX3dhRcv` (Agent.hs:3408), `initRcvRatchet` (Agent.hs:3411), `createRatchet` (Agent.hs:3436), `setRcvQueueConfirmedE2E` (Agent.hs:3440); decrypts `AgentConnInfoReply` (Agent.hs:3420); `processConf` emits `CONF` (Agent.hs:3444).
|
||||
- R6. `allowConnection'` (Agent.hs:1467-1474): `acceptConfirmation`, then `ICAllowSecure` secures Q_A with Bob's sender key.
|
||||
- R7. `connectReplyQueues` (Agent.hs:3724-3737): `upgradeConn` creates Alice's send queue to Q_B; `agentSecureSndQueue` secures Q_B; `enqueueConfirmation … Nothing` (Agent.hs:3733) stores `AgentConnInfo aliceInfo` and sends `AgentConfirmation {e2eEncryption_ = Nothing, encConnInfo}` to Q_B. This is confirmation #2.
|
||||
|
||||
Owner side, receiving confirmation #2 on Q_B:
|
||||
|
||||
- O7. dispatch (Agent.hs:3182): `AgentConfirmation` -> `smpConfirmation`.
|
||||
- O8. `smpConfirmation`, accepting-party branch `DuplexConnection … Nothing` (Agent.hs:3447-3462): `agentRatchetDecrypt` with the established ratchet; `AgentConnInfo` -> `INFO` (Agent.hs:3452); `ICDuplexSecure` or `CON`.
|
||||
|
||||
Completion is direct `CON` on `senderCanSecure` (SKEY) messaging-mode queues (the sender on `AgentConnInfo`, Agent.hs:2252; the receiver with no `senderKey`, Agent.hs:3459-3461); the separate `HELLO` via `helloMsg` (Agent.hs:3466) is the older non-`senderCanSecure` (duplexHandshake v2, in-band-securing) path.
|
||||
|
||||
## Part 2 - the DR-from-address handshake, mapped to Part 1
|
||||
|
||||
The address advertises Bob's Rcv X3DH parameters in link data (Part 3). Alice, when the address advertises them and versions are compatible, takes the joiner role; Bob takes the initiator role.
|
||||
|
||||
Requester side - a new branch in `joinConnSrv … CRContactUri`, taken when the passed `Maybe AddressRatchetKeys` is present (the caller's plan-time `LGET`):
|
||||
|
||||
- R2'. Replaces R2/R3. Read the passed bundle - `ratchetKeyId` and `e2eParams :: RcvE2ERatchetParamsUri 'C.X448` - and negotiate the concrete version with `compatibleVersion` against the client e2e range, as `compatibleInvitationUri` does (Agent.hs:1362-1368). Create the receive queue Q_A subscribed (`newRcvQueue` with `subMode`), messaging mode so Bob can secure it. Choose the requester's KEM with `replyKEM_ v ownerKem_ pqSup` (Ratchet.hs:839): if the bundle advertises a KEM (owner `IKUsePQ`) the requester `AcceptKEM` - a **double KEM**: it both encapsulates to the address KEM (ciphertext) and includes its own new KEM public key (`generateSndE2EParams` → `sntrup761Enc` + a fresh keypair, Ratchet.hs:433-435), so PQ is bidirectional from message 1; if the bundle has no KEM and the requester wants PQ, it `ProposeKEM` (its own key only, PQ from message 2 if the owner supports it). Run `generateSndE2EParams g v (replyKEM_ …)`, `pqX3dhSnd` against the negotiated parameters, `initSndRatchet`, `createSndRatchet` - the body of `createRatchet_` (Agent.hs:1343-1350), with parameters from the passed bundle rather than a received invitation.
|
||||
- R3'. Build `AgentConfirmation {e2eEncryption_ = Just aliceSndParams, ratchetKeyId = Just ratchetKeyId, encConnInfo = ratchetEncrypt(AgentConnInfoReply (Q_A :| []) aliceProfile)}` - the `mkAgentConfirmation`/`mkConfirmation` bodies (Agent.hs:3780-3765) with the reply queue being Alice's own Q_A. Send it to the address queue unauthenticated with `agentCbEncryptOnce`, one-shot (as `sendInvitation` sends, Agent/Client.hs:1929-1934) - **synchronous**, with the same send-failure UX as today's classic contact join. Nothing is stored: a retry (chat re-invokes the join → `mkJoinInvitation` reuses Q_A + keys, 1418) re-builds the confirmation, advancing the send ratchet, and the owner absorbs the advance - a **failed send** is skipped when the owner establishes the ratchet (`maxSkip = 512`, Ratchet.hs:988), and a **lost reply** carries the current content and updates the owner's request by `XContactId` (ContactRequest.hs:99-101, 269); both testable. The requester does **not** SKEY the address (`QMContact`, not `senderCanSecure`); rotation is handled because the passed params are the current advertised keys. **Alice's profile is now inside `encConnInfo`, under the ratchet.** Alice's connection is `RcvConnection` (Q_A) with a send ratchet, until she receives Q_B. This "New `RcvConnection` + `ratchets` row" is a new state (today a New `RcvConnection` holds x3dh keys but no ratchet - the classic initiator builds the ratchet only at R5, `createRatchet` Agent.hs:3436), and it composes: connection type is derived from queue rows alone while the `ratchets` table is keyed independently by `conn_id`, so subscription (Agent.hs:1551), `connectionStats` (2658), and `allowConnectionAsync'` (888) never read the ratchet for a `RcvConnection`; the only handshake reader on it is `smpConfirmation` (R5').
|
||||
|
||||
### Deferred (future work): async delivery + connect UX
|
||||
|
||||
The synchronous send above fails in the user's face on a lost reply (the same wart as today's classic contact join), even though the request may have been delivered. Making it async is a separate, later change, not part of this DR work:
|
||||
|
||||
- Delivery cannot use the message-delivery worker: a `SndQueue` is unique per `(host, port, snd_id)` and belongs to one connection (schema PK), while a contact address is one queue that many connections send to, so no per-connection SndQueue to it can exist. It would go through the **async command worker**, keyed by `(connId, server)` (`getAsyncCmdWorker`, Agent.hs:1856-1858), which already retries the `JOIN` command (`tryMoveableCommand` → `retrySndOp`, 2016-2024); each retry re-runs `joinConnSrv` (re-build + ratchet advance, which the owner absorbs - above), so nothing is stored. (`joinConnSrvAsync` for `CRContactUri` is `CMD PROHIBITED` today, Agent.hs:1452, and the `JOIN` handler falls back to sync `joinConnSrv`, 1899-1902; the `TBC` at Agent.hs:1897 is about async *receive*-queue creation - Q_A - and is orthogonal.)
|
||||
- The async join returns "connecting" early and completes via the events chat already handles (`joinContact` sets `ConnJoined`; the DR requester emits `CONF` in R5' and the chat allows it, exactly as the classic contact requester, driving msg 3 → `CON`; a permanent send failure still surfaces as `ERR → ConnFailed`).
|
||||
- This needs a chat change: the join API takes a `CreatedConnLink` (full + short link), not the bare `ConnectionRequestUri` it takes today, so the agent can LGET-gate on the owner's server (a real reachability check) and verify the fetched `linkConnReq` equals the passed full link before reporting success. Used only for DR addresses (link data advertises `ratchetKeys`); old / non-DR addresses stay on the current sync path.
|
||||
|
||||
Owner side - a new dispatch branch and a new receive handler:
|
||||
|
||||
- O1'. In `processClientMsg` (Agent.hs:3176-3187), add a branch in state `(Nothing, Just e2ePubKey)`: an `AgentConfirmation` with `ratchetKeyId = Just _` **and** `e2eEncryption_ = Just _` on a `ContactConnection` -> `smpAddressConfirmation` (new). A `ratchetKeyId` without `e2eEncryption_` is ignored (it does not match this branch and falls through as a non-DR confirmation). It must be placed **before** the existing `(PHEmpty, AgentConfirmation) | senderCanSecure queueMode` case (Agent.hs:3182-3184), because a contact-address queue is `QMContact` (not `senderCanSecure`) and would otherwise fall into `prohibited "handshake: missing sender key"` (Agent.hs:3184). The address queue's `e2eDhSecret` stays `Nothing` (it is never set for a contact address - `smpInvitation` does not set it, Agent.hs:3609-3622), so every request is decrypted with its own ephemeral key via this `(Nothing, Just e2ePubKey)` path.
|
||||
- O2'. `smpAddressConfirmation` (new, modeled on `smpConfirmation` initiating branch, Agent.hs:3405-3444): select the private triple `(pk1, pk2, pKem)` by `ratchetKeyId` from `address_ratchet_keys`; `pqX3dhRcv pk1 pk2 pKem aliceSndParams`; `initRcvRatchet` with the address connection's stored `PQSupport` (`connPQEncryption` of the address `InitialKeys` - `On` for `IKUsePQ` and `IKPQOn`, `Off` for `IKPQOff`; this is what lets `IKPQOn` accept the requester's proposed KEM), combined with version compatibility as `smpConfirmation` derives `pqSupport'` (Agent.hs:3410); `rcDecrypt` of `encConnInfo` performs the first ratchet step, giving the ratchet its send side too (as it does for the initiator today), so the owner can later reply. Parse `AgentConnInfoReply (Q_A :| []) aliceProfile`. Store the request with `createInvitation` on the address connection (`contact_conn_id`), exactly as a classic invitation - except the request value is the `CRConfirmation` variant (Part 3) carrying the post-decrypt ratchet state and Q_A, and `recipient_conn_info` is `aliceProfile` - so **no connection or `ratchets` row is created at receive**, as with a classic invitation. Emit `REQ` with the `invitation_id`. A resend is not deduplicated: like a resent classic invitation it produces another `REQ` (the connect-UX fix for that is separate chat work). An unknown or expired `ratchetKeyId`, or a decryption failure: discard and acknowledge, as an undecryptable message is dropped today. This establishes ratchet state on unauthenticated input before the user accepts - see "Receive-time establishment, state, and abuse".
|
||||
- O3'. `acceptContact'` for a DR request - a new branch that continues the ratchet instead of `joinConn`. `getInvitation` returns the request; its `CRConfirmation` variant gives the stored ratchet state and Q_A. Create the connection now (as `joinConn` does for a classic invitation) and `createRatchet` (AgentStore.hs:1419) from the stored ratchet state. Reuse `mkAgentConfirmation` (Agent.hs:3780-3785) to create Bob's receive queue Q_B and return `AgentConnInfoReply (Q_B :| []) bobInfo`; create Bob's send queue to Q_A (`newSndQueue`, generating Bob's own sender key) and secure Q_A with `SKEY` using that key (`agentSecureSndQueue`, valid because Q_A is messaging mode) - the securing key is Bob's own, not taken from Alice's message; send the response to Q_A as `AgentConfirmation {e2eEncryption_ = Nothing, ratchetKeyId = Nothing, encConnInfo = ratchetEncrypt(AgentConnInfoReply (Q_B :| []) bobInfo)}` via `sendConfirmation` (`agentCbEncrypt` over Bob's send queue to Q_A, `PHEmpty` because Q_A is `senderCanSecure`) - exactly the current contact msg 2 path (Client.hs:1916), not `agentCbEncryptOnce`. The reply content is `AgentConnInfoReply`, not `AgentConnInfo`: it takes the `mkAgentConfirmation` path with `e2eEncryption_ = Nothing`, not the `enqueueConfirmation` path (which produces `AgentConnInfo`, Agent.hs:3789). `rejectContact'` deletes the `conn_invitations` row (the current behaviour), discarding the inline ratchet; no connection was created, so there is nothing else to clean up.
|
||||
|
||||
Requester side, receiving the response on Q_A:
|
||||
|
||||
- R5'. `smpConfirmation` needs a new branch `RcvConnection … Nothing` (today only `RcvConnection … Just` and `DuplexConnection … Nothing` exist, Agent.hs:3403-3447). It looks up the ratchet first (`getRatchet`) and, if there is none, falls through to `prohibited "conf: incorrect state"` - so a classic initiator (a New `RcvConnection` with x3dh keys but no ratchet) that receives a stray `Nothing`-confirmation keeps today's exact outcome; only a DR requester, which holds a send ratchet, takes the new path. Alice already holds the send ratchet, so `rcDecrypt` advances it and creates the receive side; parse `AgentConnInfoReply (Q_B :| []) bobInfo`. **This mirrors the classic contact requester exactly**: `setRcvQueueConfirmedE2E` on Q_A, `createRatchet` the advanced ratchet, store the reply as a `NewConfirmation`, and emit **`CONF`** - the app then calls `allowConnection'` (supplying `ownConnInfo` for msg 3), which drives `connectReplyQueues` (create Alice's send queue to Q_B, `SKEY`, upgrade to `DuplexConnection`, `enqueueConfirmation` the `AgentConnInfo` msg 3). Because Q_B is sender-securable, sending `AgentConnInfo` completes Alice with `CON` (Agent.hs:2252) - no `HELLO`. The only difference from the classic requester is that the ratchet is pre-built (from R2') rather than built from Bob's Snd params here, so there is no `CONF`-less auto-completion and no separate storage of Alice's own info.
|
||||
- R6'/completion. Unchanged from the current contact handshake, and modern (no `HELLO`). The exchange is three agent↔agent wire messages - Alice → address queue (msg 1), Bob → Q_A (msg 2, an `AgentConfirmation` carrying `AgentConnInfoReply` with Q_B), Alice → Q_B (msg 3, an `AgentConfirmation` carrying `AgentConnInfo`) - the same shape as the current contact flow, where msg 1 was `AgentInvitation`; here it is the ratchet-establishing `AgentConfirmation`. (`CON` is not a wire message - it is the agent→app event; `HELLO` and `AgentConnInfo` are the wire messages.) `HELLO` belongs to the older non-`senderCanSecure` path (duplexHandshake v2, before SKEY): there the confirmation secures the queue in-band (`PHConfirmation` carries the sender key, Client.hs:1918) and the receiver replies with `HELLO` (`ICDuplexSecure` → `enqueueDuplexHello`, Agent.hs:3457-3458). Both Q_A and Q_B here are messaging-mode - Q_A by R2', Q_B via `createReplyQueue` → `SCMInvitation` → `QMMessaging` (Agent.hs:1233,1458,3783) - so the sender secures with SKEY and sends `PHEmpty` (Client.hs:1918), the dispatch takes the `senderCanSecure` branch (Agent.hs:3182-3184), and each agent raises the `CON` app event locally off msg 3 - Bob on receiving it (`senderKey = Nothing`, Agent.hs:3459-3461), Alice on sending it (Agent.hs:2252) - with no separate `HELLO` wire message. (msg 2's `AgentConnInfoReply` only sets Q_A `Confirmed`, Agent.hs:2254.) Invitations are two messages because the initiator's queue is already in the link; a contact address needs three because Bob's receive queue Q_B is only delivered in msg 2. The third message no longer has a ratchet role: Bob's X3DH params are pre-published, so the agreement is complete once Bob receives msg 1 (in the current flow Bob's Snd params instead arrive in msg 2). msg 2 and msg 3 are queue setup - msg 2 delivers Q_B, msg 3 secures Q_B so Alice can send to Bob and signals Bob's `CON`; neither negotiates the ratchet. A one-directional exchange (the RPC) needs no Q_B and is two messages.
|
||||
|
||||
Net code touch points: `joinConnSrv` (new requester branch), `processClientMsg` (new owner dispatch), `smpConfirmation` (new `RcvConnection … Nothing` branch and `AgentConnInfoReply` acceptance), `acceptContact'` (new continue-ratchet branch), a new `smpAddressConfirmation` reusing `createInvitation`/`getInvitation` with the sum request value, and the link data and storage of Part 3-4. `rejectContact'` is unchanged (it deletes the `conn_invitations` row either way).
|
||||
|
||||
### Receive-time establishment, state, and abuse
|
||||
|
||||
This is the substantive departure from the current flow. Today `smpInvitation` creates only a lightweight `NewInvitation` and emits `REQ` (Agent.hs:3618-3621); no connection or ratchet exists until the user accepts. For DR the request is under the ratchet, so to show the requester's profile in `REQ` the owner must decrypt it, which means establishing the ratchet at **receive**, before accept.
|
||||
|
||||
Design decision (Q1): decrypt at receive. Both use cases need the request content at `REQ` - a person decides to accept from the profile, and a service bot needs the request payload to act. Deferring decryption to accept would make `REQ` contentless and does not fit the service case, so it is not done.
|
||||
|
||||
Consequences:
|
||||
|
||||
- No connection is created at receive, exactly as for a classic invitation. O2' stores the request with `createInvitation` on the address connection; the post-decrypt ratchet state and Q_A live inline in the `CRConfirmation` request value (`cr_invitation`). O3' (accept) creates the connection, `createRatchet` from the stored state, and adds Bob's queues, becoming a `DuplexConnection`; `rejectContact'` deletes the `conn_invitations` row.
|
||||
- Per incoming `AgentConfirmation` the owner does one `pqX3dhRcv` (three DH plus, with PQ, one `sntrup761` decapsulation) and one `rcDecrypt`, on unauthenticated input, and writes one `conn_invitations` row - more CPU than the current `NewInvitation`, the same order of state (no connection, no `ratchets` row until accept).
|
||||
|
||||
Abuse (Q2): a contact address already accepts and processes unauthenticated invitations today, so this is a degree-worse version of an existing surface, not a new class. It is bounded by the address queue quota (an attacker fills it, the owner drains and acknowledges) and, optionally, by basic auth on the address (already supported for contact addresses, `optBasicAuth`). The per-request state is a single `conn_invitations` row - the same class as a classic contact request - so it is subject to the same limits and lifecycle, with no DR-specific dedup or TTL. Proof-of-work or a stricter gate can be added later; it is out of scope here and noted as a follow-up.
|
||||
|
||||
`acceptContact'`/`rejectContact'` keep taking the `invitation_id` from `REQ` unchanged; the only difference is that `getInvitation` returns a request that is either a `CRInvitation` URI (current `joinConn` path, O3-O6) or a `CRConfirmation` (continue-ratchet path, O3'). Nothing in the `REQ`/accept/reject flow or the chat client changes - the change is contained in the agent.
|
||||
|
||||
### The four communication layers, per message (verified against code)
|
||||
|
||||
Layers, outermost (server-visible) first:
|
||||
|
||||
- **L1 `ClientMsgEnvelope`** (Protocol.hs:1089), `PubHeader {phVersion, phE2ePubDhKey :: Maybe PublicKeyX25519}` (1096) - **this is where per-queue encryption is agreed** (not L2). `phE2ePubDhKey` is the sender's e2e DH public key; the recipient combines it with the queue's e2e private key: `(e2eDhSecret, e2ePubKey_) -> (Nothing, Just e2ePubKey) -> e2eDh = dh' e2ePubKey e2ePrivKey` (Agent.hs:3172-3178). `agentCbEncryptOnce` (Client.hs:2214) puts a **fresh ephemeral** pubkey (generated 2217, set 2223) - used when the sender has no send queue (the address queue), whose `e2eDhSecret` stays `Nothing`, so it decrypts every message with the per-message ephemeral. `agentCbEncrypt` (Client.hs:2203) puts the **send queue's persistent** e2e pubkey (`Just` on a confirmation, 2210); the recipient stores the secret via `setRcvQueueConfirmedE2E`, and *later* messages send `phE2ePubDhKey = Nothing` (`sendAgentMessage`, 2080).
|
||||
- **L2 `ClientMessage PrivHeader`** (Protocol.hs:1113), `PrivHeader = PHConfirmation APublicAuthKey | PHEmpty` (1115) - **queue securing / authorization, not encryption**. `PHConfirmation` carries the sender's AUTH key for in-band securing (v2, non-`senderCanSecure`); `PHEmpty` when the sender secured the queue with SKEY out-of-band. `PHEmpty` on every message here is about securing, and says nothing about encryption (that is L1). Set in `sendConfirmation` (Client.hs:1918), `sendInvitation` (1934), `sendAgentMessage` (2079).
|
||||
- **L3 `AgentMsgEnvelope`** (Agent/Protocol.hs:829, encoding 851) - outside the ratchet. `AgentConfirmation` ('C') carries `e2eEncryption_` (Snd X3DH params, agrees DR) + `encConnInfo`; `AgentInvitation` ('I') carries `connReq` (Rcv X3DH params) + plaintext `connInfo` (no DR); `AgentMsgEnvelope` ('M') carries `encAgentMessage`.
|
||||
- **L4 `AgentMessage`** (Agent/Protocol.hs:883, encoding 893) - inside the ratchet. `AgentConnInfo` ('I'), `AgentConnInfoReply` ('D', reply queues + info), `AgentMessage APrivHeader AMessage` ('M'; `AMessage` includes `HELLO`, Agent/Protocol.hs:1018-1020). **Absent when L3 is `AgentInvitation`** (that profile is per-queue-only - the gap this plan closes).
|
||||
|
||||
Send routing: msg 1 (to address) → `sendInvitation` today / a new `agentCbEncryptOnce` confirmation send for DR; msg 2 → `secureConfirmQueue` → `sendConfirmation` (Agent.hs:3747); msg 3 → `connectReplyQueues` → `enqueueConfirmation` → delivery worker `AM_CONN_INFO` → `sendConfirmation` (Agent.hs:3733,3789,2183). `AM_CONN_INFO`/`AM_CONN_INFO_REPLY` both go through `sendConfirmation` (2183-2184); other `AMessage`s go through `sendAgentMessage` wrapping `AgentMsgEnvelope` 'M' (2192-2193).
|
||||
|
||||
Current contact handshake (address does **not** advertise DR):
|
||||
|
||||
| msg | L1 `PubHeader.phE2ePubDhKey` (per-queue enc) | L2 `PrivHeader` (securing) | L3 `AgentMsgEnvelope` | L4 `AgentMessage` |
|
||||
|---|---|---|---|---|
|
||||
| 1 Alice→addr | `Just` fresh ephemeral, `agentCbEncryptOnce` (Client.hs:1933,2223) | `PHEmpty` (1934) | `AgentInvitation` {connReq = Alice Rcv params, connInfo = profile} (Client.hs:1932) | — none (profile per-queue only) |
|
||||
| 2 Bob→Q_A | `Just` Bob's send-queue e2e pubkey, `agentCbEncrypt` (1920,2210) | `PHEmpty` [`senderCanSecure`] (1918) | `AgentConfirmation` {e2eEncryption_ = **Just Bob Snd params**, encConnInfo} (Agent.hs:3765) | `AgentConnInfoReply` (Q_B) bobInfo, DR-enc (Agent.hs:3785) |
|
||||
| 3 Alice→Q_B | `Just` Alice's send-queue e2e pubkey, `agentCbEncrypt` (1920,2210) | `PHEmpty` [`senderCanSecure`] (1918) | `AgentConfirmation` {e2eEncryption_ = **Nothing**, encConnInfo} (Agent.hs:3802) | `AgentConnInfo` aliceInfo, DR-enc (Agent.hs:3789) |
|
||||
|
||||
New DR handshake (address advertises DR):
|
||||
|
||||
| msg | L1 `PubHeader.phE2ePubDhKey` (per-queue enc) | L2 `PrivHeader` (securing) | L3 `AgentMsgEnvelope` | L4 `AgentMessage` |
|
||||
|---|---|---|---|---|
|
||||
| 1 Alice→addr | `Just` fresh ephemeral, `agentCbEncryptOnce` [same] | `PHEmpty` [same] | **`AgentConfirmation`** {e2eEncryption_ = **Just Alice Snd params**, **ratchetKeyId = Just**, encConnInfo} [was `AgentInvitation`] | **`AgentConnInfoReply`** (Q_A) aliceProfile, **DR-enc** [was plaintext connInfo] |
|
||||
| 2 Bob→Q_A | `Just` Bob's send-queue e2e pubkey, `agentCbEncrypt` [same] | `PHEmpty` [same] | `AgentConfirmation` {e2eEncryption_ = **Nothing**, ratchetKeyId = Nothing, encConnInfo} [was Just Bob Snd params] | `AgentConnInfoReply` (Q_B) bobInfo, DR-enc [same] |
|
||||
| 3 Alice→Q_B | `Just` Alice's send-queue e2e pubkey, `agentCbEncrypt` [same] | `PHEmpty` [same] | `AgentConfirmation` {e2eEncryption_ = Nothing, encConnInfo} [same] | `AgentConnInfo` aliceInfo, DR-enc [same] |
|
||||
|
||||
Net difference: **only msg 1 and msg 2's L3/L4 change.** msg 1's L3 becomes `AgentConfirmation` (was `AgentInvitation`) carrying Alice's Snd params + `ratchetKeyId`, and the profile moves from plaintext L3 to DR-encrypted L4 (`AgentConnInfoReply`) - the whole point of the change. msg 2 drops `e2eEncryption_` (Bob no longer sends Snd params - the ratchet is agreed from msg 1). msg 3 is unchanged. L1 (per-queue encryption - each queue agrees its own secret via the sender's e2e pubkey in the `PubHeader` on the first message to it) and L2 (securing, `PHEmpty` because SKEY is used) are unchanged throughout; the DR change is entirely at L3/L4. The only new send code is msg 1 (an `AgentConfirmation` fired to the address with `agentCbEncryptOnce`, like `sendInvitation` but with a confirmation envelope).
|
||||
|
||||
## Part 3 - types and link data
|
||||
|
||||
### Fixed data - unchanged
|
||||
|
||||
`FixedLinkData` (Protocol.hs:1824) is not touched. The double-ratchet keys go entirely in mutable data, so an existing address advertises them without a new link (the fixed data is hash-committed and cannot change). Fixed data keeps only `agentVRange`, `rootKey`, `linkConnReq`, `linkEntityId`.
|
||||
|
||||
### Mutable data - ratchet keys bundle
|
||||
|
||||
Appended to `UserContactData` (Protocol.hs:1840); the encoding stops at a trailing tail (Protocol.hs:1981), so earlier versions ignore it:
|
||||
|
||||
```haskell
|
||||
newtype RatchetKeyId = RatchetKeyId ByteString -- opaque short id; one Encoding instance, shared below
|
||||
|
||||
data AddressRatchetKeys = AddressRatchetKeys
|
||||
{ ratchetKeyId :: RatchetKeyId, -- identifies this bundle; changes on rotation, echoed in the request
|
||||
e2eParams :: CR.RcvE2ERatchetParamsUri 'C.X448 -- version range + both X3DH keys + optional KEM
|
||||
}
|
||||
instance Encoding AddressRatchetKeys where ... -- the key-bundle instance; both fields required
|
||||
|
||||
data UserContactData = UserContactData
|
||||
{ direct :: Bool, owners :: [OwnerAuth], relays :: [ConnShortLink 'CMContact],
|
||||
userData :: UserLinkData,
|
||||
ratchetKeys :: Maybe AddressRatchetKeys -- whole bundle optional, one Encoding instance
|
||||
}
|
||||
```
|
||||
|
||||
`e2eParams` is the existing `RcvE2ERatchetParamsUri 'C.X448` (`E2ERatchetParamsUri VersionRangeE2E k1 k2 (Maybe (RKEMParams s))`, Ratchet.hs:282-286) - the same type a `CRInvitationUri` advertises - with `StrEncoding`/`Encoding` already defined (Ratchet.hs:302-374). There is no bespoke key type and no reconstruction: the requester negotiates the concrete version with `compatibleVersion` against its own e2e range, exactly as `compatibleInvitationUri` does for an invitation (Agent.hs:1362-1368), giving `RcvE2ERatchetParams` for `pqX3dhSnd`. The KEM is optional: `Nothing` gives an X448-only ratchet (as when `PQSupport` is off), `Just` a hybrid one, matching `generateRcvE2EParams`'s `PQSupport` gate (Ratchet.hs:439-445).
|
||||
|
||||
The address-creation parameter is `InitialKeys` (Ratchet.hs:864) - the same 3-way choice as invitations, not a bare `PQSupport`. Currently `IKUsePQ` is prohibited for `SCMContact` (Agent.hs:990,1198) because a contact address carries no owner keys; this change lifts that prohibition. The bundle plays the published-contact-request role, so its KEM follows `initialPQEncryption False pqInitKeys` (Ratchet.hs:882) - exactly as the requester's contact request does today (Agent.hs:1422):
|
||||
|
||||
- `IKUsePQ` - the bundle advertises the KEM; the requester encapsulates to it, so PQ from message 1.
|
||||
- `IKPQOn` (`IKLinkPQ PQSupportOn`) - the bundle is X448-only (no KEM advertised), but the owner's ratchet supports PQ (`connPQEncryption` = On, Ratchet.hs:888); the requester proposes its own KEM (R2'), so PQ from message 2.
|
||||
- `IKPQOff` (`IKLinkPQ PQSupportOff`) - X448-only, and the owner's ratchet does not support PQ even if the requester proposes it.
|
||||
|
||||
Advertising the KEM adds ~1158 B to the rotated, widely-fetched link data, which is why `IKPQOn` exists (PQ one round later, without the size cost). The owner generates the bundle with `generateRcvE2EParams g v (initialPQEncryption False pqInitKeys)` (Ratchet.hs:439), stores the private triple `(pk1, pk2, pKem)` (Part 4), and advertises `e2eParams` by wrapping the public `E2ERatchetParams` in the address's e2e version range (`toVersionRangeT`; or `mkRcvE2ERatchetParams` from the stored privates, Ratchet.hs:412) - the same private-key shape `createRatchetX3dhKeys`/`getRatchetX3dhKeys` already store (AgentStore.hs:1362-1367). `ratchetKeys` is set by the agent when it signs mutable link data (`Crypto.ShortLink.encodeSignUserData`), not by the application.
|
||||
|
||||
### Authentication of the advertised keys
|
||||
|
||||
No signature is added on the keys: the mutable link data already signs them. `decryptLinkData` (Crypto/ShortLink.hs:106-114) verifies `sig2` over the mutable `UserContactData` by `rootKey`, so `ratchetKeys` is root-signed. This is the X3DH anti-substitution property: an SMP server cannot substitute the keys without forging the root signature. The signer is the root Ed25519 key (the address's signing identity); the X3DH keys are separate DH keys (X448, which cannot sign). A single owner signs address data ("we don't use multiple owners"), so the root signature alone is sufficient - no per-key signature. A malicious server can still serve an older but validly-signed `UserContactData` (rollback to a retired bundle); this is bounded by the retention window and by the ratchet advancing after the first message, and a signature does not prevent it. Inline ratchet params in a `CRInvitationUri` contact request are not in signed link data and remain unsigned - a separate change, out of scope here.
|
||||
|
||||
### Request envelope
|
||||
|
||||
`AgentConfirmation` (Protocol.hs:830-834) gains an optional `ratchetKeyId` - the `ratchetKeyId` of the `AddressRatchetKeys` bundle the requester used, so the owner selects the matching private keys:
|
||||
|
||||
```haskell
|
||||
AgentConfirmation
|
||||
{ agentVersion :: VersionSMPA,
|
||||
e2eEncryption_ :: Maybe (SndE2ERatchetParams 'C.X448), -- reused: Alice's Snd params in DR msg 1
|
||||
ratchetKeyId :: Maybe RatchetKeyId, -- selects the owner's key generation
|
||||
encConnInfo :: ByteString
|
||||
}
|
||||
```
|
||||
|
||||
`ratchetKeyId` is a separate optional selector (the shared `RatchetKeyId` newtype), not the bundle - the owner already holds the published public bundle and looks up its private keys by this id. It reuses the existing `e2eEncryption_` for Alice's Snd params rather than a new combined bundle; the minor cost is two correlated `Maybe`s (`ratchetKeyId = Just` is only meaningful with `e2eEncryption_ = Just`). **A `ratchetKeyId` with `e2eEncryption_ = Nothing` is ignored** - O2' requires both (there are no Snd params to run `pqX3dhRcv`), so such a message falls through to the current dispatch as if it had no `ratchetKeyId`.
|
||||
|
||||
Encoding (extends Protocol.hs:853-866): from `addressDRVersion`, `smpEncode (agentVersion, 'C', e2eEncryption_, ratchetKeyId, Tail encConnInfo)`; `ratchetKeyId` is `Just` for an address-DR confirmation and `Nothing` for the current joiner-to-initiator and initiator-to-joiner confirmations; earlier versions omit the field entirely and use `smpEncode (agentVersion, 'C', e2eEncryption_, Tail encConnInfo)`. Parsing gates the field on `agentVersion`. `CRInvitationUri` is unchanged - a connection request URI holds Rcv parameters and must not hold Snd parameters.
|
||||
|
||||
### Stored request - the invitation record
|
||||
|
||||
The `conn_invitations` record stays; only the type of the stored request widens. From chat's point of view a DR request is still an invitation - it "contains a confirmation" instead of an invitation URI - so `REQ`, `acceptContact'`/`rejectContact'`, and the chat side are unchanged; the change is contained in the agent. The `NewInvitation`/`Invitation` request field (`cr_invitation`, stays `NOT NULL`) becomes a sum:
|
||||
|
||||
```haskell
|
||||
data ContactRequest
|
||||
= CRInvitation (ConnectionRequestUri 'CMInvitation) -- classic: joinConn on accept (O3-O6)
|
||||
| CRConfirmation DRRequest -- DR: continue the ratchet on accept (O3')
|
||||
|
||||
data DRRequest = DRRequest
|
||||
{ drRatchet :: RatchetX448, -- post-decrypt receiving ratchet (with send side), stored inline
|
||||
drReplyQueue :: SMPQueueInfo, -- Q_A, where the owner replies
|
||||
drAgentVersion :: VersionSMPA, -- negotiated at receive; needed to build the connection shell at accept
|
||||
drPQSupport :: PQSupport -- the address's PQ setting for this connection
|
||||
}
|
||||
```
|
||||
|
||||
`recipient_conn_info` holds the profile in both cases. `getInvitation`/`createInvitation` carry `ContactRequest`; `acceptContact'` branches on the constructor. There is no dedup column: a resent request produces another `REQ`, exactly as a resent classic invitation does.
|
||||
|
||||
`drAgentVersion`/`drPQSupport` are stored because the accept flow creates the connection **shell** through `newConnToAccept` → `newConnToJoin` (via `prepareConnectionToAccept`, called by chat's sync accept before `acceptContact'`, Internal.hs:914,925) and `newConnToJoin` today derives `connAgentVersion`/`pqSupport` from the `ConnectionRequestUri` (Agent.hs:1277-1293); a `CRConfirmation` has no URI, so the values negotiated at receive (O2') are stored and used to build the shell.
|
||||
|
||||
Three readers of the widened `connReq` field (all via `getInvitation`) branch on the constructor:
|
||||
- `acceptContact'` (Agent.hs:1479, sync): `CRInvitation cr` → `joinConn … cr` (classic, unchanged); `CRConfirmation dr` → the O3' continue-ratchet path.
|
||||
- `newConnToAccept` (Agent.hs:1296, via `prepareConnectionToAccept`): `CRInvitation cr` → `newConnToJoin … cr` (unchanged); `CRConfirmation dr` → create the `NewConnection` shell from `drAgentVersion`/`drPQSupport` (`createNewConn`, generating the connId).
|
||||
- `acceptContactAsync'` (Agent.hs:900): `CRInvitation cr` → `joinConnAsync … cr` (unchanged); `CRConfirmation _` → `throwE $ CMD PROHIBITED` (async DR accept is deferred; DR requests accept synchronously). Chat's REQ/accept is unaffected either way - it only ever passes `invId`, never the `ContactRequest`, which stays internal to the agent.
|
||||
|
||||
Storage: `cr_invitation`'s `ToField`/`FromField` encode `CRInvitation` as the legacy `strEncode` URI (unchanged from before, so the format is downgrade-safe) and `CRConfirmation` as JSON (`J.encode` of `DRRequest`). `FromField` peeks the first byte: `{` → JSON `CRConfirmation`, else `strDecode` → `CRInvitation` (a URI never starts with `{`). `DRRequest` uses manual `ToJSON`/`FromJSON` (extensible), and `SMPQueueInfo` gets a base64 `StrEncoding` + JSON to sit inside it. `smpInvitation` (Agent.hs:3618) wraps its `connReq` in `CRInvitation`; `smpAddressConfirmation` (O2') writes `CRConfirmation`.
|
||||
|
||||
## Part 4 - key rotation (client-driven)
|
||||
|
||||
Rotation is independent of the handshake above and is driven by the client app, not the agent. The agent never rotates on its own - it lacks the app's intent and the mutable link data (profile/badge and other short-link data). The app rotates by calling `setConnShortLink` with the rotate flag; the whole ratchet-keys bundle - both X448 keys and the KEM - is generated fresh each time.
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
-- one row per ratchet-keys generation for an address; the current generation plus the most recent retained ones.
|
||||
-- private side of the advertised RcvE2ERatchetParamsUri - same shape as the ratchets x3dh
|
||||
-- columns and createRatchetX3dhKeys (AgentStore.hs).
|
||||
CREATE TABLE address_ratchet_keys(
|
||||
address_ratchet_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conn_id BLOB NOT NULL REFERENCES connections ON DELETE CASCADE,
|
||||
ratchet_key_id BLOB NOT NULL, -- the published id echoed by requests
|
||||
x3dh_priv_key_1 BLOB NOT NULL, -- X448
|
||||
x3dh_priv_key_2 BLOB NOT NULL, -- X448
|
||||
pq_priv_kem BLOB, -- RcvPrivRKEMParams (sntrup761 keypair); NULL when PQ is off for this address
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_address_ratchet_keys ON address_ratchet_keys(conn_id, ratchet_key_id);
|
||||
|
||||
-- a DR request stays in conn_invitations with NO schema change: cr_invitation now holds a ContactRequest
|
||||
-- sum (an invitation URI or a confirmation carrying the post-decrypt ratchet + reply queue), so it stays
|
||||
-- NOT NULL - no nullable change, no new column on conn_invitations, no new table for the request.
|
||||
```
|
||||
|
||||
`cr_invitation` stays `NOT NULL` - only its decoded value gains a variant (Part 3), so the invitations flow, `REQ`, and chat are unchanged; the only new storage is the `address_ratchet_keys` table. The link signing key is already on the address queue (`rcv_queues.link_priv_sig_key`, M20250322), so nothing is added there - rotation and retrofit re-sign mutable data with it. PostgreSQL mirrors this. Migration `M20260712_address_dr`.
|
||||
|
||||
### Rotation logic
|
||||
|
||||
The app rotates by calling `setConnShortLink` with the rotate flag; there is no automatic, agent-driven rotation. On rotation:
|
||||
|
||||
1. `generateRcvE2EParams` for a fresh generation - two X448 keys, and an sntrup761 keypair only if PQ is on for this address - with a fresh `ratchetKeyId`.
|
||||
2. Recompute mutable link data with the new `AddressRatchetKeys` (the public `e2eParams`), re-sign with the root key (`encodeSignUserData`, key from `rcv_queues.link_priv_sig_key`), and `LSET` it to the address queue (`setConnShortLink` path).
|
||||
3. Insert the new `address_ratchet_keys` row (`x3dh_priv_key_1`, `x3dh_priv_key_2`, `pq_priv_kem`).
|
||||
|
||||
### Retention
|
||||
|
||||
Retention is count-based, not time-based: on each rotation `deleteOldAddressRatchetKeys` keeps the newest `keepAddressKeys` generations per address (default 3, ordered by `address_ratchet_key_id`) and deletes older ones. There is no `retired_at` column, no time window, and no `cleanupManager` step. A request that used a recently-retired bundle still decrypts while that generation is retained; how long a recorded first message stays decryptable after a compromise of the current private keys is therefore bounded by the retained-generation count and the app's rotation cadence (both app-controlled). Unaccepted DR request rows in `conn_invitations` are handled exactly like unaccepted classic invitation requests - no DR-specific cleanup (a DR request is one `conn_invitations` row, the same class of state as a classic contact request).
|
||||
|
||||
## Part 5 - backward compatibility
|
||||
|
||||
- A requester older than `addressDRVersion`, or an address without `ratchetKeys`, uses R2/R3 (`AgentInvitation`); the owner uses O1-O8. Unchanged.
|
||||
- The owner dispatches on the envelope: `AgentInvitation` -> `smpInvitation` (current); `AgentConfirmation` with `ratchetKeyId` on a `ContactConnection` -> `smpAddressConfirmation` (new). Both coexist.
|
||||
- `AgentConfirmation` without `ratchetKeyId` remains the current confirmation on established connections.
|
||||
- An existing address gains `ratchetKeys` via a new agent API (e.g. `updateContactAddressLink`) that the app calls with the mutable link data (profile/badge and any other short-link data): the agent generates the DR bundle if absent (the first `address_ratchet_keys` row and its stored private keys), adds `ratchetKeys` to `UserContactData`, re-signs with `rcv_queues.link_priv_sig_key`, and `LSET`s it. **Only mutable data changes - the address (link) is unchanged**, because the keys are in mutable, not fixed, data. Requesters that fetch the updated data use DR; older ones still use `AgentInvitation`. The agent does not do this on its own (it lacks the profile and the user's intent); the app drives it, combined with the full→short address migration.
|
||||
|
||||
## Part 6 - tests
|
||||
|
||||
- Encoding roundtrips: `UserContactData` with and without `ratchetKeys`, and with the KEM present and absent; `AgentConfirmation` with and without `ratchetKeyId`, across versions.
|
||||
- Address creation advertises `ratchetKeys` (the `RcvE2ERatchetParamsUri`); `decryptLinkData` (Crypto/ShortLink.hs:100) verifies signatures and the requester negotiates the advertised params to a concrete version, with and without the KEM.
|
||||
- Both PQ modes: an address whose bundle carries a KEM gives a hybrid ratchet (`pqEncryption` on); one without gives an X448-only ratchet.
|
||||
- End to end: a DR-advertising address; a new requester establishes the ratchet, sends its profile under it, owner emits `REQ`, accepts, both reach `CON`; assert the profile never travels under per-queue-only encryption; assert `pqEncryption` on.
|
||||
- Rotation and retrofit: request against the current bundle; against a just-retired bundle within the window still decrypts; against a bundle past the window is discarded and the requester times out; an address that adds `ratchetKeys` via `LSET` is then reached by DR while an old requester still uses `AgentInvitation`.
|
||||
- Backward compatibility: old requester against a DR address connects via `AgentInvitation`; new requester against a non-DR address falls back to `AgentInvitation`.
|
||||
|
||||
## Part 7 - phases
|
||||
|
||||
1. Link data: `AddressRatchetKeys` in `UserContactData` (reusing `RcvE2ERatchetParamsUri`), encoding, `encodeSignUserData`; `AgentConfirmation.ratchetKeyId`; address creation taking `InitialKeys` (lifting the `IKUsePQ`-for-`SCMContact` prohibition), generating (`generateRcvE2EParams`, KEM per `initialPQEncryption False`) and storing the first `address_ratchet_keys` row.
|
||||
2. Handshake: thread the optional `Maybe AddressRatchetKeys` through `joinConnection`/`joinConn`/`joinConnSrv` (present → DR branch, absent → classic); requester R2'/R3' (synchronous one-shot send); owner O1'/O2'/O3' storing the DR request as a `conn_invitations` row whose request value is the `CRConfirmation` variant; `smpConfirmation` `RcvConnection … Nothing` branch and `AgentConnInfoReply` acceptance; end-to-end connection with the profile under the ratchet. Tests pass `AddressRatchetKeys` directly (chat wiring is later work).
|
||||
3. Rotation and retrofit: schema migration, `rotateRatchetKeys`, retention window, cleanup step, app-driven `LSET` retrofit (with the full→short address migration), rotation/retrofit tests.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Signed service requests
|
||||
|
||||
Optional Ed25519 signature on service RPC requests, constructed and verified in the agent (not the bot), bound to the request's double ratchet. Requests only — responses stay authenticated by the address/ratchet. Signing is optional; a bot decides whether to require it. The agent is stateless: the meaning of a signer key (identity, resource) is the bot's concern.
|
||||
|
||||
## Wire — `Simplex.Messaging.Agent.Protocol`
|
||||
|
||||
Extend the existing `'A'` inner message; `Maybe` absent = unsigned, so the unsigned path is unchanged:
|
||||
|
||||
```haskell
|
||||
AgentServiceRequest (NonEmpty SMPQueueInfo) (Maybe RequestSignature) MsgBody
|
||||
|
||||
data RequestSignature = RequestSignature C.PublicKeyEd25519 (C.Signature 'C.Ed25519)
|
||||
```
|
||||
|
||||
## Binding
|
||||
|
||||
```
|
||||
binding = sha3-256("SimpleXService" <> rcAD)
|
||||
sig = Ed25519.sign(sk, binding <> payload)
|
||||
```
|
||||
The service recomputes `binding` from its own `rcAD` and verifies.
|
||||
|
||||
- `rcAD` = the ratchet associated data (`Ratchet.rcAD`) — the shared connection security code: identical on both ratchets by construction (`pubKey(requester ephemeral) <> pubKey(service key)`), stable, and unique per request (fresh requester ephemeral). Already on the ratchet; nothing derived or stored.
|
||||
- sha3-256 here is not for uniformity or secrecy (both moot: the value is signed, not keyed, and only a ratchet holder can craft a valid request). It gives a canonical fixed-length, domain-tagged binding; the 32-byte fixed prefix also makes `binding <> payload` unambiguous.
|
||||
- Domain string `"SimpleXService"`: separates this signature from other uses of the signing key.
|
||||
- Not covered: reply queues (the AEAD protects them in transit; addresses may use redundant queues).
|
||||
- Anti-relay: a signature bound to one session's rcAD does not verify under another's (both parties' keys differ). Replay of the encrypted blob is handled separately by transport dedup.
|
||||
|
||||
## Sign (requester) — `Simplex.Messaging.Agent`
|
||||
|
||||
- `sendServiceRequest` / `sendServiceRequestAsync` gain a `Maybe` Ed25519 signing key.
|
||||
- `joinConnSrv'` DR path takes the ratchet straight from the `createRatchet_`/`getSndRatchet` line (both now yield `(RatchetX448, params)`) and computes `serviceReqBinding` from its `rcAD`; the `mkInner :: SMPQueueInfo -> ByteString -> AgentMessage` closure calls `signServiceReq signKey_ binding payload` — `RequestSignature pub (sign' pk (binding <> payload))` when a key is given, `Nothing` otherwise.
|
||||
- Async carries the key in `JRServiceReq {requestKey :: Maybe C.PrivateKeyEd25519}` (enabled `StrEncoding (PrivateKey Ed25519)`); the JOIN worker deserializes it and signs after building the ratchet.
|
||||
|
||||
**Status:** implemented and tested in simplexmq-3 (sync + async); invalid signature → `A_SERVICE ASEBadSignature` (logs + `ERR` event), no invitation.
|
||||
|
||||
## Verify (service) — `smpContactRequest`
|
||||
|
||||
After `initRcvRatchet_` + decrypt, on `AgentServiceRequest (replyQueue :| _) sig_ payload`, one helper does the check:
|
||||
|
||||
`verifyServiceReq rc payload sig_ :: Either String (Maybe C.PublicKeyEd25519)`
|
||||
- `Nothing` → `Right Nothing` (unsigned).
|
||||
- `Just (RequestSignature key sig)` → recompute `serviceReqBinding rc` and `C.verify' key sig (binding <> payload)`; `Right (Just key)` if valid, else `Left err`.
|
||||
|
||||
Then:
|
||||
- `Right key_` → `storeInvitation … True` + `notify $ SREQ invId key_ payload`.
|
||||
- `Left err` → `logError` + `notify (ERR (AGENT (A_SERVICE ASEBadSignature)))`, no invitation.
|
||||
|
||||
Dedup unchanged.
|
||||
|
||||
## Event / API
|
||||
|
||||
- `SREQ :: InvitationId -> Maybe C.PublicKeyEd25519 -> MsgBody -> AEvent AEConn`.
|
||||
- `StrEncoding (PrivateKey Ed25519)` enabled so a caller (e.g. via `JRServiceReq`) can carry the signing key.
|
||||
|
||||
## Tests
|
||||
|
||||
- `testSignedServiceRequest` (sync) + `testSignedServiceRequestAsync` — signed round-trip delivers the exact signer key on `SREQ` (`sigKey_ == Just signPub`).
|
||||
- Unsigned path unchanged (existing service tests carry `Nothing` for the new field).
|
||||
@@ -1,325 +0,0 @@
|
||||
# Ethereum crypto primitives for simplexmq
|
||||
|
||||
Client-side crypto for SimpleX names: enough to derive an Ethereum key from a
|
||||
recovery phrase and sign EIP-712 typed data. General-purpose — these modules
|
||||
know nothing about names, registrars or relayers.
|
||||
|
||||
This is Workstream B of the SimpleX names v2 plan. The design it serves: names
|
||||
are owned by a plain EOA derived per chat profile from one BIP-39 seed, and
|
||||
every post-registration action (transfer, record edit) is a one-shot EIP-712
|
||||
intent signed by that key and relayed by SimpleX, which pays the gas.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
- **No RLP encoder, and no transaction building.** RLP is only needed to
|
||||
construct raw transactions or EIP-7702 authorizations. The client does
|
||||
neither: it signs EIP-712 typed data and hands the signature to the relayer.
|
||||
The client never reads a nonce, estimates gas or broadcasts anything, so the
|
||||
`RSLV` resolver path in this repo stays strictly read-only.
|
||||
- **No low-s normalization.** libsecp256k1 already emits the canonical low-`s`
|
||||
form EIP-2 requires. `isLowS` exists so tests assert that rather than assume
|
||||
it. There is deliberately no normalization entry point: we never accept a
|
||||
foreign signature, we only produce our own.
|
||||
- **No BIP-32 public derivation.** We always hold the seed, so CKDpub, xpub
|
||||
serialization and fingerprints are not implemented. Non-hardened *private*
|
||||
derivation is, because BIP-44 paths end in non-hardened components.
|
||||
- **No EIP-712 schema encoder.** The caller supplies the canonical type string.
|
||||
Our structs are a handful of fixed shapes agreed with the contracts, and a
|
||||
hand-written string checked against Solidity in a test is easier to audit than
|
||||
a schema encoder whose output nobody reads.
|
||||
- **English wordlist only.** Every English BIP-39 word is ASCII, so the NFKD
|
||||
normalization BIP-39 mandates is a no-op on the mnemonic side and no
|
||||
normalization dependency is needed.
|
||||
|
||||
## Modules
|
||||
|
||||
```
|
||||
Simplex.Messaging.Crypto.Secp256k1 FFI to libsecp256k1
|
||||
Simplex.Messaging.Crypto.BIP39 mnemonics
|
||||
Simplex.Messaging.Crypto.BIP39.English generated 2048-word list
|
||||
Simplex.Messaging.Crypto.BIP32 HD derivation
|
||||
Simplex.Messaging.Eth.Keccak Keccak-256
|
||||
Simplex.Messaging.Eth.Address addresses, EIP-55
|
||||
Simplex.Messaging.Eth.EIP712 typed data hashing
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
```haskell
|
||||
newtype PrivateKey -- 32 bytes, validated in [1, n-1]
|
||||
newtype PublicKey -- libsecp256k1's opaque 64-byte form
|
||||
data RecoverableSignature = RecoverableSignature {rsCompact :: ByteString, rsRecId :: Int}
|
||||
data PubKeyFormat = Compressed | Uncompressed
|
||||
|
||||
data Mnemonic -- validated indexes + words, always consistent
|
||||
data MnemonicStrength = MS128 | MS160 | MS192 | MS224 | MS256
|
||||
|
||||
data ExtendedKey = ExtendedKey {xkKey :: PrivateKey, xkChainCode :: ByteString}
|
||||
|
||||
newtype Address -- 20 bytes; Show renders the EIP-55 form
|
||||
data Eip712Domain = Eip712Domain {edName, edVersion :: ByteString, edChainId :: Integer, edVerifyingContract :: Address}
|
||||
data Value = VUint Integer | VInt Integer | VBool Bool | VAddress Address
|
||||
| VFixedBytes ByteString | VBytes ByteString | VString ByteString
|
||||
| VArray [Value] | VStruct ByteString
|
||||
```
|
||||
|
||||
`PrivateKey`, `Mnemonic` and `ExtendedKey` have **redacting `Show` instances**,
|
||||
and `PrivateKey` compares with `constEq`. These keys authorise transfers of
|
||||
assets with monetary value: a derived `Show` would put one in a log the first
|
||||
time anything is traced. A chain code is secret too — it plus one child key
|
||||
derives siblings.
|
||||
|
||||
## Functions
|
||||
|
||||
```haskell
|
||||
-- Secp256k1
|
||||
mkPrivateKey :: ByteString -> Either String PrivateKey
|
||||
publicKey :: PrivateKey -> PublicKey -- total: key is validated
|
||||
parsePublicKey :: ByteString -> Either String PublicKey
|
||||
serializePublicKey :: PubKeyFormat -> PublicKey -> ByteString
|
||||
privateKeyTweakAdd :: PrivateKey -> ByteString -> Maybe PrivateKey
|
||||
signRecoverable :: PrivateKey -> ByteString -> Either String RecoverableSignature
|
||||
recoverPublicKey :: RecoverableSignature -> ByteString -> Either String PublicKey
|
||||
isLowS :: RecoverableSignature -> Bool
|
||||
|
||||
-- BIP39
|
||||
entropyToMnemonic :: ByteString -> Either String Mnemonic
|
||||
mnemonicToEntropy :: Mnemonic -> ByteString -- total
|
||||
parseMnemonic :: ByteString -> Either String Mnemonic
|
||||
mnemonicToSeed :: Mnemonic -> ByteString -> ByteString
|
||||
randomMnemonic :: MnemonicStrength -> TVar ChaChaDRG -> STM Mnemonic
|
||||
|
||||
-- BIP32
|
||||
masterKey :: ByteString -> Either String ExtendedKey
|
||||
deriveChild :: ExtendedKey -> Word32 -> Either String ExtendedKey
|
||||
derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
|
||||
parsePath :: ByteString -> Either String [Word32]
|
||||
renderPath :: [Word32] -> ByteString
|
||||
|
||||
-- Eth
|
||||
keccak256 :: ByteString -> ByteString
|
||||
addressFromPrivateKey :: PrivateKey -> Address
|
||||
checksumAddress :: Address -> ByteString
|
||||
parseAddress :: ByteString -> Either String Address
|
||||
ethereumPath :: Word32 -> [Word32] -- m/44'/60'/i'/0/0
|
||||
typeHash :: ByteString -> ByteString
|
||||
hashStruct :: ByteString -> [Value] -> Either String ByteString
|
||||
domainSeparator :: Eip712Domain -> Either String ByteString
|
||||
hashTypedData :: Eip712Domain -> ByteString -> [Value] -> Either String ByteString
|
||||
```
|
||||
|
||||
`randomMnemonic` is shaped like `Simplex.Messaging.Crypto.randomBytes` so it
|
||||
composes with the agent's DRG instead of reaching for system entropy.
|
||||
|
||||
`parseMnemonic` lower-cases and splits on any whitespace, so a user retyping
|
||||
their recovery key is not rejected for capitalising a word. This does not change
|
||||
the derived seed: `mnemonicPhrase` always rebuilds the canonical lowercase
|
||||
sentence from the wordlist, and that is what `mnemonicToSeed` hashes.
|
||||
|
||||
## How applications use it
|
||||
|
||||
An application defines the derivation path and the EIP-712 type strings. For
|
||||
SimpleX names, one seed per chat database and one key per chat profile —
|
||||
see `Simplex.Chat.Names.Wallet` in simplex-chat:
|
||||
|
||||
```haskell
|
||||
m <- either fail pure $ parseMnemonic phrase
|
||||
mk <- either fail pure $ masterKey (mnemonicToSeed m "")
|
||||
xk <- either fail pure $ derivePath mk (ethereumPath userId)
|
||||
let addr = addressFromPrivateKey (xkKey xk)
|
||||
```
|
||||
|
||||
Signing a transfer intent — the type string must match the contract's exactly,
|
||||
including EIP-712 canonical form (no spaces after commas, referenced struct
|
||||
types appended in alphabetical order):
|
||||
|
||||
```haskell
|
||||
digest <- either fail pure $ hashTypedData domain
|
||||
"TransferName(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline)"
|
||||
[VAddress from, VAddress to, VUint tokenId, VUint nonce, VUint deadline]
|
||||
sig <- either fail pure $ signRecoverable (xkKey xk) digest
|
||||
-- Ethereum's v is rsRecId + 27
|
||||
```
|
||||
|
||||
Nested structs go in as `VStruct` holding an already-computed `hashStruct`;
|
||||
arrays as `VArray`, which hashes the concatenation of its members.
|
||||
|
||||
## libsecp256k1 C API mapping
|
||||
|
||||
```c
|
||||
secp256k1_context_create(SECP256K1_CONTEXT_NONE) /* once, then _randomize */
|
||||
secp256k1_ec_seckey_verify(ctx, seckey)
|
||||
secp256k1_ec_pubkey_create(ctx, pubkey, seckey)
|
||||
secp256k1_ec_pubkey_parse(ctx, pubkey, input, inputlen)
|
||||
secp256k1_ec_pubkey_serialize(ctx, output, outputlen, pubkey, flags)
|
||||
secp256k1_ec_seckey_tweak_add(ctx, seckey, tweak)
|
||||
secp256k1_ecdsa_sign_recoverable(ctx, sig, msghash32, seckey, NULL, NULL)
|
||||
secp256k1_ecdsa_recoverable_signature_serialize_compact(ctx, output64, recid, sig)
|
||||
secp256k1_ecdsa_recoverable_signature_parse_compact(ctx, sig, input64, recid)
|
||||
secp256k1_ecdsa_recover(ctx, pubkey, sig, msghash32)
|
||||
```
|
||||
|
||||
Passing `NULL` for the nonce function selects RFC-6979, so signing is a
|
||||
deterministic pure function of (key, digest) — which is why the module exposes a
|
||||
pure API over `unsafePerformIO`. The context is created and blinded once at
|
||||
first use; randomization is a side-channel countermeasure that affects no
|
||||
output, and signing does not mutate the context, so one shared context is safe
|
||||
across threads.
|
||||
|
||||
`secp256k1_ec_seckey_tweak_add` returns 0 exactly when BIP-32 says "proceed with
|
||||
the next index" (tweak out of range, or a zero result), which is why
|
||||
`privateKeyTweakAdd` returns `Maybe` and `deriveChild` can surface it.
|
||||
|
||||
libsecp256k1 never reads OS entropy — RFC-6979 nonces are derived from the key
|
||||
and digest, and the context blinding seed is supplied by the caller. So unlike
|
||||
libbbs it raises no `getentropy` / ITMS-90338 concern on iOS, and needs no
|
||||
equivalent of the `commoncrypto` flag.
|
||||
|
||||
## Build
|
||||
|
||||
Submodule in `cbits/`, same pattern as blst and libbbs:
|
||||
`cbits/libsecp256k1` — https://github.com/bitcoin-core/secp256k1, pinned to
|
||||
**v0.8.0**.
|
||||
|
||||
```
|
||||
c-sources: cbits/libsecp256k1/src/{secp256k1,precomputed_ecmult,precomputed_ecmult_gen}.c
|
||||
include-dirs: cbits/libsecp256k1{,/include,/src}
|
||||
cc-options: -DENABLE_MODULE_RECOVERY=1
|
||||
```
|
||||
|
||||
Built **without** its autotools config header. Every knob has an `#ifndef`
|
||||
default in the headers, and the checked-in precomputed tables are generated for
|
||||
those defaults, so only the recovery module has to be switched on. The recovery
|
||||
module is `#include`d from `secp256k1.c`, so it needs no extra `c-sources`
|
||||
entry. `secp256k1.c` defines `SECP256K1_BUILD` itself, so that needs no `-D`
|
||||
either.
|
||||
|
||||
32-bit targets (armv7a-android, i686 musl) are covered by libsecp256k1's own
|
||||
fallback: `src/util.h` selects `SECP256K1_WIDEMUL_INT64` with the 10x26 field
|
||||
and 8x32 scalar backends when `__SIZEOF_INT128__` is absent.
|
||||
|
||||
`include-dirs` order matters: libsecp256k1's directories come last, after
|
||||
libbbs and blst. There are no filename collisions between the three (checked),
|
||||
and C quoted includes prefer the including file's own directory anyway, but the
|
||||
ordering keeps it that way if any library later adds a generically-named header.
|
||||
|
||||
`-DENABLE_MODULE_RECOVERY=1` lands on the shared `cc-options`, so it also
|
||||
reaches blst, libbbs and sntrup761 — harmless, none of them use the macro, and
|
||||
symmetrically `-D__BLST_PORTABLE__` reaches libsecp256k1.
|
||||
|
||||
No `flake.nix` change is needed in simplex-chat: the per-platform overrides
|
||||
there only force `packages.simplexmq.components.library.libs` (external
|
||||
libraries, i.e. openssl for `extra-libraries: crypto`) and flags. Vendored
|
||||
`c-sources` need no nix entry, which is why blst and libbbs have none either.
|
||||
|
||||
### Cross-compilation status
|
||||
|
||||
Verified by building simplex-chat through its flake:
|
||||
|
||||
| Target | Result |
|
||||
|---|---|
|
||||
| `x86_64-linux` (native, nix) | compiles and links |
|
||||
| `aarch64-android` | **compiles and links** into the final shared object |
|
||||
| `armv7a-android` | libsecp256k1 compiles; final link not reached (see below) |
|
||||
| `x86_64-windows` (mingw) | blocked before our code — see below |
|
||||
| `aarch64-darwin-ios` | not yet run (needs a darwin host) |
|
||||
|
||||
`aarch64-android` is the meaningful pass: it proves the C both cross-compiles
|
||||
and links into the artifact the app actually ships.
|
||||
|
||||
`armv7a-android` gets far enough to prove the 32-bit path compiles — that is,
|
||||
libsecp256k1's `SECP256K1_WIDEMUL_INT64` fallback builds under the NDK — but the
|
||||
build then dies in simplex-chat's own `Simplex.Chat.Operators`, on the
|
||||
`$(embedFile "PRIVACY.md")` splice. Cross-compiled Template Haskell runs the
|
||||
splice on the target via `iserv-proxy` under `qemu-arm`, and that interpreter
|
||||
fails to resolve `realpath` out of `libHSdirectory` and segfaults. It is
|
||||
unrelated to this work: none of these modules use Template Haskell, and
|
||||
simplexmq (which does) builds for armv7a fine. So 32-bit *linking* remains
|
||||
unproven, though there is no plausible mechanism by which it would fail given
|
||||
aarch64 links and the 32-bit objects compile.
|
||||
|
||||
`x86_64-windows` fails while bootstrapping the mingw cross-GHC, long before any
|
||||
of our code is considered: haskell.nix applies
|
||||
`ghc-9.6-fix-code-symbol-jumps.patch` to `rts/linker/PEi386.c` twice from the
|
||||
same store path, and the second application aborts. That is a duplicate entry in
|
||||
the patch list of the pinned haskell.nix branch
|
||||
(`github:input-output-hk/haskell.nix/armv7a`), not something this change can
|
||||
influence.
|
||||
|
||||
Both gaps can be closed without GHC by compiling the three C files with the
|
||||
cross toolchain directly and linking a program that calls into both the core and
|
||||
the recovery module — that isolates the C question from the Haskell build
|
||||
entirely.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/CoreTests/EthCryptoTests.hs`, 98 examples. Everything is checked against
|
||||
published vectors rather than our own output:
|
||||
|
||||
- **BIP-39** — all 24 official English vectors from
|
||||
`trezor/python-mnemonic/vectors.json`, entropy → mnemonic → entropy and
|
||||
mnemonic → seed with the `TREZOR` passphrase.
|
||||
- **BIP-32** — spec test vectors 1 (all six chains) and 2. Expected private keys
|
||||
and chain codes were decoded from the published `xprv` base58 strings, since
|
||||
we do not implement xprv serialization.
|
||||
- **EIP-55** — the four addresses from the EIP-55 spec, round-tripped.
|
||||
- **EIP-712** — the `Mail` example from the spec: domain separator, `hashStruct`
|
||||
and the final digest.
|
||||
- **BIP-44** — the well-known `0x9858EfFD232B4033E47d90003D41EC34EcaEda94` for
|
||||
the `abandon … about` mnemonic at `m/44'/60'/0'/0/0`, plus accounts 1 and 2.
|
||||
- Keccak-256 against SHA3-256, so the padding-byte confusion cannot pass.
|
||||
- Negative cases: zero and out-of-range private keys, wrong digest length,
|
||||
malformed public keys, bad BIP-39 checksums and word counts, out-of-range
|
||||
seeds, bad EIP-55 checksums, and every EIP-712 range and length check.
|
||||
|
||||
The EIP-712 and BIP-44 expectations were additionally reproduced by an
|
||||
independent pure-Python secp256k1 reference written for the purpose, so they are
|
||||
not just our implementation agreeing with itself.
|
||||
|
||||
## Addendum: ERC-5564 stealth addresses
|
||||
|
||||
`Simplex.Messaging.Eth.Stealth`, added for the names v2 gifting flow (rc3 §7.4).
|
||||
A recipient publishes a meta-address — a spending public key and a viewing
|
||||
public key — and a sender derives a one-time destination from it with no
|
||||
handshake. Only the viewing key finds those destinations; only the spending key
|
||||
spends from them.
|
||||
|
||||
### Why not `secp256k1_ecdh`
|
||||
|
||||
The ECDH module hashes the shared secret point with SHA-256 and offers no way to
|
||||
substitute a hash without a C callback. ERC-5564 hashes with keccak256. So the
|
||||
module stays disabled and the two core-API point operations are bound instead:
|
||||
|
||||
- `secp256k1_ec_pubkey_tweak_mul` → `publicKeyTweakMul`, for `r · P_view`
|
||||
- `secp256k1_ec_pubkey_tweak_add` → `publicKeyTweakAdd`, for `P_spend + s_h · G`
|
||||
|
||||
Both are in `secp256k1.h`, so no build flag changed. The recipient's key,
|
||||
`p_spend + s_h`, reuses the existing `privateKeyTweakAdd`.
|
||||
|
||||
### The parts the EIP does not specify
|
||||
|
||||
ERC-5564 fixes the algebra but not the encoding, and getting either wrong
|
||||
produces a wallet that is self-consistent and interoperable with nothing. From
|
||||
the EIP author's reference implementation
|
||||
(`Nerolation/EIP-Stealth-Address-ERC`, `minimal_poc.ipynb`):
|
||||
|
||||
- the shared secret point is serialized **uncompressed with the SEC1 prefix
|
||||
removed**, `x || y`, 64 bytes;
|
||||
- it is hashed with **keccak256**;
|
||||
- the **view tag is the first byte** of that hash.
|
||||
|
||||
That is the same encoding Ethereum uses to turn a public key into an address, so
|
||||
`addressFromPublicKey` performs the final step unchanged.
|
||||
|
||||
### Tests
|
||||
|
||||
13 examples in `CoreTests.EthCryptoTests`, 111 in the module overall. Beyond the
|
||||
round-trip and negative cases, two carry the weight:
|
||||
|
||||
- **Batch scanning.** A recipient scans 512 announcements addressed to someone
|
||||
else; about two pass the one-byte view tag by chance and none yields an address
|
||||
they control. The complementary test confirms they find all 64 of their own.
|
||||
This exercises the scan loop rather than a single derivation.
|
||||
- **Independent agreement.** The pinned vector was reproduced by a from-scratch
|
||||
pure-Python secp256k1 implementing the reference algorithm directly, sharing no
|
||||
code with libsecp256k1. Without that, a pin only records our own output.
|
||||
@@ -1,81 +0,0 @@
|
||||
## Root cause: PRXY errors are attributed to the forwarding server instead of the destination relay
|
||||
|
||||
When private routing is enabled and the destination relay is unreachable, the client reports
|
||||
**"Error connecting to forwarding server smp5.simplex.im"** — naming a preset server that the client
|
||||
connected to successfully. Retrying rotates to the next proxy (`getNextServer`, `Agent/Client.hs:689`)
|
||||
and produces the same message with a different preset server, so the destination server is never named
|
||||
and the failure looks like an outage of our own infrastructure.
|
||||
|
||||
### Reproduction
|
||||
|
||||
Connecting to a contact address on an unresolvable host (`simplex.server.home`, no DNS record):
|
||||
|
||||
```
|
||||
-- private routing off (correct)
|
||||
BROKER {brokerAddress = "smp://VvXX…@simplex.server.home:5223",
|
||||
brokerErr = NETWORK {networkError = NEConnectError {connectError = "…does not exist (Name or service not known)"}}}
|
||||
|
||||
-- private routing on (misattributed)
|
||||
SMP {serverAddress = "smp://…@smp5.simplex.im,…onion",
|
||||
smpErr = PROXY {proxyErr = BROKER {brokerErr = NETWORK {networkError = NEFailedError}}}}
|
||||
```
|
||||
|
||||
### The asymmetry between the two proxied paths
|
||||
|
||||
A server returns `PROXY (BROKER …)` only from `smpProxyError` (`Client.hs:804-815`), which is called
|
||||
exclusively where the proxy failed to reach the relay — `PRXY` (`Server.hs:1444`) and `PFWD`
|
||||
(`Server.hs:1466`). The error therefore *always* describes the proxy→relay hop. The two paths then
|
||||
diverge in how the agent wraps it:
|
||||
|
||||
**PFWD — keeps both addresses** (`Agent/Client.hs:1183-1189`): the proxy's error arrives as
|
||||
`Left ProxyClientError` and is thrown as `PROXY {proxyServer, relayServer, proxyErr}`.
|
||||
|
||||
**PRXY — drops the relay** (`Agent/Client.hs:713`): `connectSMPProxiedRelay` has no `Either` layer, so
|
||||
the error arrives as `PCEProtocolError` and `liftClient SMP` maps it to `SMP <proxyAddr> (PROXY …)`
|
||||
(`Agent/Client.hs:1244`). The destination address is discarded.
|
||||
|
||||
Both clients read the second shape as a client→proxy failure and word it accordingly
|
||||
(`SimpleXAPI.kt:2692`, `ErrorAlert.swift:117`), which is never what it means.
|
||||
|
||||
### Fix
|
||||
|
||||
In `newProxiedRelay`, map proxy-reported `PROXY (BROKER …)` errors to the same shape `PFWD` already
|
||||
produces:
|
||||
|
||||
```haskell
|
||||
proxyRelayError :: HostName -> ErrorType -> AgentErrorType
|
||||
proxyRelayError proxyHost = \case
|
||||
e@(SMP.PROXY (SMP.BROKER _)) -> PROXY {proxyServer = protocolClientServer smp, relayServer = …destSrv, proxyErr = ProxyProtocolError e}
|
||||
e -> SMP proxyHost e
|
||||
```
|
||||
|
||||
`liftClient` applies this only to `PCEProtocolError`, so genuine client↔proxy failures (response
|
||||
timeout, network error, proxy transport version) still map to `BROKER <proxy> …` and remain attributed
|
||||
to the proxy. Both apps already render the resulting shape correctly, with no client change:
|
||||
*"Forwarding server smp5.simplex.im failed to connect to destination server simplex.server.home."*
|
||||
|
||||
The guard is `BROKER` rather than every `ProxyError`, so the remap covers exactly the misattributed
|
||||
class and nothing else. `BASIC_AUTH` is deliberately excluded — the proxy returns it when proxying is
|
||||
disabled or the basic auth does not match (`Server.hs:1416-1420`), which is a client↔proxy fact and is
|
||||
correctly attributed today. `NO_SESSION` is returned only for `PFWD`. `PROTOCOL` describes the relay
|
||||
but is not rendered as a proxy-connection error by either client, so leaving it unchanged keeps the
|
||||
diff to the errors that actually produce a wrong message.
|
||||
|
||||
### Blast radius
|
||||
|
||||
- `temporaryAgentError` (`Agent/Client.hs:1572-1580`) and `serverHostError` (`:1594-1596`) already match
|
||||
both shapes with the same helpers — retry and proxy-fallback behaviour is unchanged.
|
||||
- `clientServiceError` (`:1268-1273`) has no `PROXY`-shape twin for `BROKER NO_SERVICE`, but both ends
|
||||
document that case as unreachable (`Client.hs:812`); left as is.
|
||||
- simplex-chat `Subscriber.hs:1819-1820` handles both shapes; send failures move from `SndErrProxy` to
|
||||
`SndErrProxyRelay`, i.e. "Destination server error" rather than "Error" — also more accurate.
|
||||
- `SMP _ (PROXY _)` becomes unreachable, making `smpProxyErrorAlert` in both clients dead code. Removing
|
||||
it is a follow-up in simplex-chat, not required by this change.
|
||||
|
||||
### Verification
|
||||
|
||||
- Reproduced before/after with a CLI built against this branch: the error now carries
|
||||
`relayServer = "smp://VvXX…@simplex.server.home:5223"`, and the direct (non-proxied) path is
|
||||
byte-identical to before.
|
||||
- `SMPProxyTests`: 45 examples, 0 failures — including `fails when fallback is prohibited` and both
|
||||
retry tests, which exercise `newProxiedRelay` and the error classification.
|
||||
@@ -1,152 +0,0 @@
|
||||
# Server: batched SUB command processing
|
||||
|
||||
Implementation plan for Part 1 of [RFC 2026-03-28-subscription-performance](../rfcs/2026-03-28-subscription-performance.md).
|
||||
|
||||
## Current state
|
||||
|
||||
When a batch of ~135 SUB commands arrives, the server already batches:
|
||||
- Queue record lookups (`getQueueRecs` in `receive`, Server.hs:1151)
|
||||
- Command verification (`verifyLoadedQueue`, Server.hs:1152)
|
||||
|
||||
But command processing is per-command (`foldrM process` in `client`, Server.hs:1372-1375). Each SUB calls `subscribeQueueAndDeliver` which calls `tryPeekMsg` - one DB query per queue. For Postgres, that's ~135 individual `SELECT ... FROM messages WHERE recipient_id = ? ORDER BY message_id ASC LIMIT 1` queries per batch.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace ~135 individual message peek queries with 1 batched query per batch. No protocol changes.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Add `tryPeekMsgs` to MsgStoreClass
|
||||
|
||||
File: `src/Simplex/Messaging/Server/MsgStore/Types.hs`
|
||||
|
||||
Add to `MsgStoreClass`:
|
||||
|
||||
```haskell
|
||||
tryPeekMsgs :: s -> [StoreQueue s] -> ExceptT ErrorType IO (Map RecipientId Message)
|
||||
```
|
||||
|
||||
Returns a map from recipient ID to earliest pending message for each queue that has one. Queues with no messages are absent from the map.
|
||||
|
||||
### Step 2: Parameterize `deliver` to accept pre-fetched message
|
||||
|
||||
File: `src/Simplex/Messaging/Server.hs`
|
||||
|
||||
Currently `deliver` (inside `subscribeQueueAndDeliver`, line 1641) calls `tryPeekMsg ms q`. Add a parameter for an optional pre-fetched message:
|
||||
|
||||
```haskell
|
||||
deliver :: Maybe Message -> (Bool, Maybe Sub) -> M s ResponseAndMessage
|
||||
deliver prefetchedMsg (hasSub, sub_) = do
|
||||
stats <- asks serverStats
|
||||
fmap (either ((,Nothing) . err) id) $ liftIO $ runExceptT $ do
|
||||
msg_ <- maybe (tryPeekMsg ms q) (pure . Just) prefetchedMsg
|
||||
...
|
||||
```
|
||||
|
||||
When `Nothing` is passed, falls back to individual `tryPeekMsg` (existing behavior). When `Just msg` is passed, uses it directly (batched path).
|
||||
|
||||
### Step 3: Pre-fetch messages before the processing loop
|
||||
|
||||
File: `src/Simplex/Messaging/Server.hs`
|
||||
|
||||
Currently (lines 1372-1375):
|
||||
|
||||
```haskell
|
||||
forever $
|
||||
atomically (readTBQueue rcvQ)
|
||||
>>= foldrM process ([], [])
|
||||
>>= \(rs_, msgs) -> ...
|
||||
```
|
||||
|
||||
Add a pre-fetch step before the existing loop:
|
||||
|
||||
```haskell
|
||||
forever $ do
|
||||
batch <- atomically (readTBQueue rcvQ)
|
||||
msgMap <- prefetchMsgs batch
|
||||
foldrM (process msgMap) ([], []) batch
|
||||
>>= \(rs_, msgs) -> ...
|
||||
```
|
||||
|
||||
`prefetchMsgs` scans the batch, collects queues from SUB commands that have a verified queue (`q_ = Just (q, _)`), calls `tryPeekMsgs` once, returns the map. For batches with no SUBs it returns an empty map (no DB call).
|
||||
|
||||
`process` passes the looked-up message (or Nothing) through to `processCommand` and down to `deliver`.
|
||||
|
||||
The `foldrM process` loop, `processCommand`, `subscribeQueueAndDeliver`, and all other command handlers stay structurally the same. Only `deliver` gains one parameter, and the `client` loop gains one pre-fetch call.
|
||||
|
||||
### Step 4: Review
|
||||
|
||||
Review the typeclass signature and server usage. Confirm the interface has the right shape before implementing store backends.
|
||||
|
||||
### Step 5: Implement for each store backend
|
||||
|
||||
#### Postgres
|
||||
|
||||
File: `src/Simplex/Messaging/Server/MsgStore/Postgres.hs`
|
||||
|
||||
Single query using `DISTINCT ON`:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT ON (recipient_id)
|
||||
recipient_id, msg_id, msg_ts, msg_quota, msg_ntf_flag, msg_body
|
||||
FROM messages
|
||||
WHERE recipient_id IN ?
|
||||
ORDER BY recipient_id, message_id ASC
|
||||
```
|
||||
|
||||
Build `Map RecipientId Message` from results.
|
||||
|
||||
#### STM
|
||||
|
||||
File: `src/Simplex/Messaging/Server/MsgStore/STM.hs`
|
||||
|
||||
Loop over queues, call `tryPeekMsg` for each, collect into map.
|
||||
|
||||
#### Journal
|
||||
|
||||
File: `src/Simplex/Messaging/Server/MsgStore/Journal.hs`
|
||||
|
||||
Loop over queues, call `tryPeekMsg` for each, collect into map.
|
||||
|
||||
### Step 6: Handle edge cases
|
||||
|
||||
1. **Mixed batches**: `prefetchMsgs` collects only SUB queues. Non-SUB commands get Nothing for the pre-fetched message and process unchanged.
|
||||
|
||||
2. **Already-subscribed queues**: Include in pre-fetch - `deliver` is called for re-SUBs too (delivers pending message).
|
||||
|
||||
3. **Service subscriptions**: The pre-fetch doesn't care about service state. `sharedSubscribeQueue` handles service association in STM; message peek is the same.
|
||||
|
||||
4. **Error queues**: Verification errors from `receive` are Left values in the batch. `prefetchMsgs` only looks at Right values with SUB commands.
|
||||
|
||||
5. **Empty pre-fetch**: If batch has no SUBs (e.g., all ACKs), `prefetchMsgs` returns empty map, no DB call made.
|
||||
|
||||
### Step 7: Batch other commands (future, not in scope)
|
||||
|
||||
The same pattern (pre-fetch before loop, parameterize handler) can extend to:
|
||||
- `ACK` with `tryDelPeekMsg` - batch delete+peek
|
||||
- `GET` with `tryPeekMsg` - same map lookup
|
||||
|
||||
Lower priority since these don't have the N-at-once pattern of subscriptions.
|
||||
|
||||
## File changes summary
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/Simplex/Messaging/Server/MsgStore/Types.hs` | Add `tryPeekMsgs` to typeclass |
|
||||
| `src/Simplex/Messaging/Server/MsgStore/Postgres.hs` | Implement `tryPeekMsgs` with batch SQL |
|
||||
| `src/Simplex/Messaging/Server/MsgStore/STM.hs` | Implement `tryPeekMsgs` as loop |
|
||||
| `src/Simplex/Messaging/Server/MsgStore/Journal.hs` | Implement `tryPeekMsgs` as loop |
|
||||
| `src/Simplex/Messaging/Server.hs` | Add `prefetchMsgs`, parameterize `deliver` |
|
||||
|
||||
## Testing
|
||||
|
||||
1. Existing server tests must pass unchanged (correctness preserved).
|
||||
2. Add a test that subscribes a batch of queues (some with pending messages, some without) and verifies all get correct SOK + MSG responses.
|
||||
3. Prometheus metrics: existing `qSub` stat should still increment correctly.
|
||||
|
||||
## Performance expectation
|
||||
|
||||
For 300K queues across ~2200 batches:
|
||||
- Before: ~300K individual DB queries
|
||||
- After: ~2200 batched DB queries (one per batch of ~135)
|
||||
- ~136x reduction in DB round-trips
|
||||
@@ -1,126 +0,0 @@
|
||||
# Server: batch queue service associations
|
||||
|
||||
When a batch of SUB or NSUB commands arrives from a service client, each command that needs a new or removed service association calls `setQueueService` individually - one DB write per command. For 135 commands per batch, that's 135 individual `UPDATE msg_queues` queries.
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce to at most 2 DB queries per batch (one for rcv associations, one for ntf associations), using `UPDATE ... RETURNING recipient_id` to identify which queues were actually updated.
|
||||
|
||||
Also fuse message pre-fetch and association batching into a single batch preparation step with a clean contract.
|
||||
|
||||
## Contract
|
||||
|
||||
```haskell
|
||||
prepareBatch :: Maybe ServiceId -> NonEmpty (VerifiedTransmission s) -> M s (Either ErrorType (Map RecipientId (Maybe Message, Maybe (Either ErrorType ()))))
|
||||
```
|
||||
|
||||
`Left e` = batch-level failure (message pre-fetch or association query failed entirely). All SUBs/NSUBs in the batch get this error.
|
||||
|
||||
`Right map` = per-queue results as a tuple:
|
||||
- `Maybe Message` - pre-fetched message for SUB queues, `Nothing` for NSUB or no message
|
||||
- `Maybe (Either ErrorType ())` - association result. `Nothing` = no update needed. `Just (Right ())` = update succeeded. `Just (Left e)` = update failed for this queue.
|
||||
|
||||
One map, one lookup per queue. `processCommand` passes both values to `subscribeQueueAndDeliver` / `subscribeNotifications` -> `sharedSubscribeQueue`.
|
||||
|
||||
Queues not in the map (non-SUB/NSUB commands, failed verification) are not affected.
|
||||
|
||||
## prepareBatch implementation
|
||||
|
||||
One accumulating fold over the batch, collecting three lists:
|
||||
- `subMsgQs :: [StoreQueue s]` - SUB queues for message pre-fetch
|
||||
- `rcvAssocQs :: [StoreQueue s]` - SUB queues needing `rcv_service_id` update (`clntServiceId /= rcvServiceId qr`)
|
||||
- `ntfAssocQs :: [StoreQueue s]` - NSUB queues needing `ntf_service_id` update (`clntServiceId /= ntfServiceId` from `NtfCreds`)
|
||||
|
||||
Classification reads from the already-loaded `QueueRec` in `VerifiedTransmission` - no extra DB query.
|
||||
|
||||
Then three store calls (each skipped if its list is empty):
|
||||
1. `tryPeekMsgs ms subMsgQs` -> `Map RecipientId Message`
|
||||
2. `setRcvQueueServices (queueStore ms) clntServiceId rcvAssocQs` -> `Set RecipientId`
|
||||
3. `setNtfQueueServices (queueStore ms) clntServiceId ntfAssocQs` -> `Set RecipientId`
|
||||
|
||||
Then one pass to merge results into `Map RecipientId (Maybe Message, Maybe (Either ErrorType ()))`:
|
||||
- For each SUB queue: `(M.lookup rId msgMap, assocResult rId rcvUpdated rcvAssocQs)`
|
||||
- For each NSUB queue: `(Nothing, assocResult rId ntfUpdated ntfAssocQs)`
|
||||
|
||||
Where `assocResult rId updated assocQs` = if the queue was in `assocQs` (needed update), then `Just (Right ())` if `rId` is in `updated`, else `Just (Left AUTH)`. If not in `assocQs` (no update needed), `Nothing`.
|
||||
|
||||
If any of the three calls fails entirely, return `Left e`.
|
||||
|
||||
## Store interface
|
||||
|
||||
Replace the polymorphic `setQueueServices` with two plain functions in `QueueStoreClass`:
|
||||
|
||||
```haskell
|
||||
setRcvQueueServices :: s -> Maybe ServiceId -> [q] -> IO (Set RecipientId)
|
||||
setNtfQueueServices :: s -> Maybe ServiceId -> [q] -> IO (Set RecipientId)
|
||||
```
|
||||
|
||||
No `SParty p` polymorphism. Each function knows its column.
|
||||
|
||||
### Postgres implementation
|
||||
|
||||
`setRcvQueueServices`:
|
||||
```sql
|
||||
UPDATE msg_queues SET rcv_service_id = ?
|
||||
WHERE recipient_id IN ? AND deleted_at IS NULL
|
||||
RETURNING recipient_id
|
||||
```
|
||||
|
||||
`setNtfQueueServices`:
|
||||
```sql
|
||||
UPDATE msg_queues SET ntf_service_id = ?
|
||||
WHERE recipient_id IN ? AND notifier_id IS NOT NULL AND deleted_at IS NULL
|
||||
RETURNING recipient_id
|
||||
```
|
||||
|
||||
After each batch query, for each queue in the returned set:
|
||||
1. Read QueueRec TVar, update with new serviceId
|
||||
2. Write store log entry
|
||||
|
||||
### STM implementation
|
||||
|
||||
Loop over queues, call existing per-item logic, collect succeeded `RecipientId`s into a Set.
|
||||
|
||||
## Downstream changes in Server.hs
|
||||
|
||||
### processCommand
|
||||
|
||||
Gains one parameter: `Map RecipientId (Maybe Message, Maybe (Either ErrorType ()))`.
|
||||
|
||||
SUB case: `M.lookup entId prepared` gives `Just (msg_, assocResult)` or `Nothing`. Pass both to `subscribeQueueAndDeliver`.
|
||||
|
||||
NSUB case: `M.lookup entId prepared` gives `Just (Nothing, assocResult)` or `Nothing`. Pass `assocResult` to `subscribeNotifications`.
|
||||
|
||||
Forwarded commands: pass `M.empty`.
|
||||
|
||||
### subscribeQueueAndDeliver
|
||||
|
||||
Takes `Maybe Message` and `Maybe (Either ErrorType ())` as before. No change in how it uses them.
|
||||
|
||||
### sharedSubscribeQueue
|
||||
|
||||
Takes `Maybe (Either ErrorType ())`. On paths needing association update:
|
||||
- `Just (Left e)` -> return error
|
||||
- `Just (Right ())` -> skip `setQueueService`, proceed with STM work
|
||||
- `Nothing` -> no update needed, proceed with existing logic
|
||||
|
||||
## Implementation order (top-down)
|
||||
|
||||
1. Define the `prepareBatch` contract and thread one map through `processCommand` -> `subscribeQueueAndDeliver` / `subscribeNotifications` -> `sharedSubscribeQueue` (Server.hs)
|
||||
2. Implement `prepareBatch` with the fold, three calls, and merge (Server.hs)
|
||||
3. Add `setRcvQueueServices` and `setNtfQueueServices` to `QueueStoreClass` (Types.hs)
|
||||
4. Implement for Postgres with batch `UPDATE ... RETURNING` (Postgres.hs)
|
||||
5. Implement for STM as loop (STM.hs)
|
||||
6. Implement for Journal as delegation (Journal.hs)
|
||||
|
||||
At step 2, store functions can initially be stubs returning empty sets. Steps 3-6 fill in the real implementations.
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/Simplex/Messaging/Server.hs` | `prepareBatch` with fold + merge; one map parameter through `processCommand` -> `subscribeQueueAndDeliver` / `subscribeNotifications` -> `sharedSubscribeQueue` |
|
||||
| `src/Simplex/Messaging/Server/QueueStore/Types.hs` | Add `setRcvQueueServices`, `setNtfQueueServices` to `QueueStoreClass` |
|
||||
| `src/Simplex/Messaging/Server/QueueStore/Postgres.hs` | Implement with batch `UPDATE ... RETURNING` + per-item TVar/log updates |
|
||||
| `src/Simplex/Messaging/Server/QueueStore/STM.hs` | Implement as loop |
|
||||
| `src/Simplex/Messaging/Server/MsgStore/Journal.hs` | Delegate to underlying store |
|
||||
@@ -1,455 +0,0 @@
|
||||
# Server: SMP support for public namespaces
|
||||
|
||||
> **⚠ Implementation diverged from this plan.** Six audit rounds reshaped the
|
||||
> original design. **The shipped code differs in several load-bearing ways:**
|
||||
>
|
||||
> - **Wire format**: `NameRecord` is now JSON (aeson), not the custom binary
|
||||
> ABNF this plan documents. See `protocol/simplex-messaging.md` §Resolver
|
||||
> commands and `src/Simplex/Messaging/Protocol.hs` ToJSON/FromJSON instances.
|
||||
> - **No cache**: the TTL + FIFO + byte-cap cache, in-flight coalescing,
|
||||
> `psqueues` dep, and `cache_*` INI keys are all gone. Every RSLV becomes
|
||||
> one `eth_call` bounded by `rpcMaxConcurrency` + `rpcTimeoutMs`. See
|
||||
> `src/Simplex/Messaging/Server/Names.hs`.
|
||||
> - **No `allow_dangerous_colocation` flag**: the proxy co-location guard
|
||||
> was demoted to a startup `logWarn` (the flag was always-on because
|
||||
> `[PROXY]` has no enable toggle).
|
||||
> - **Module shape**: `Names/Resolver.hs` was merged into `Names.hs`; only
|
||||
> `Names/Eth/RPC.hs` and `Names/Eth/SNRC.hs` remain as separate modules.
|
||||
> - **Test list**: of the 15 specs listed below, ~7 shipped; the rest were
|
||||
> either superseded by the cache removal (CacheSpec) or deferred
|
||||
> (ForwardedRslvSpec, MockRpcSpec, StartupGuardSpec, UrlValidationSpec,
|
||||
> EipChecksumSpec).
|
||||
>
|
||||
> Sources of truth: `CHANGELOG.md` (release notes),
|
||||
> `protocol/simplex-messaging.md` §Resolver commands (wire format),
|
||||
> `src/Simplex/Messaging/Server/Names*.hs` (implementation). This file is
|
||||
> retained as historical context; do not treat it as a specification.
|
||||
|
||||
Implementation plan for Part 2 of [RFC 2026-05-21-public-namespaces](https://github.com/simplex-chat/simplex-chat/blob/ep/namespace/docs/rfcs/2026-05-21-public-namespaces.md). Adds a forwarded-only `RSLV <lookup_key>` SMP command that returns `NAME <NameRecord>` read from the SNRC contract via a Reth+Nimbus JSON-RPC endpoint. Smp-server becomes name-capable by `[NAMES] enable: on`.
|
||||
|
||||
Out of scope: `Simplex.Messaging.Client` API, agent-side resolution flow, `ServerRoles.names` in the agent, default-router list, reverse resolution, multicoin/text records, state proofs.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant P as Proxy (storage role)
|
||||
participant N as Name server (names role)
|
||||
participant E as Ethereum endpoint<br/>(Reth+Nimbus)
|
||||
|
||||
C ->> P: PFWD(enc(RSLV key))
|
||||
P ->> N: RFWD(enc(RSLV key))
|
||||
note over N: verifyTransmission True →<br/>vc SResolver (RSLV _) → VRVerified
|
||||
N ->> N: cache lookup
|
||||
alt cache miss
|
||||
N ->> E: eth_call(SNRC, namehash(key))
|
||||
E -->> N: ABI bytes
|
||||
note over N: ABI decode + zero-owner check + cache insert
|
||||
end
|
||||
N -->> P: RFWD(enc(NAME rec | ERR AUTH))
|
||||
P -->> C: PRES(enc(NAME rec | ERR AUTH))
|
||||
```
|
||||
|
||||
RSLV is **forwarded-only** — direct RSLV is rejected `CMD PROHIBITED`. This preserves the RFC's two-server resolution: the name server sees the lookup key but never the client's IP, session, or identity.
|
||||
|
||||
## Protocol
|
||||
|
||||
Shared library: `src/Simplex/Messaging/Protocol.hs` and `src/Simplex/Messaging/Transport.hs`.
|
||||
|
||||
**Version.** `Transport.hs:226`: `namesSMPVersion = VersionSMP 20`. Bump `currentClientSMPRelayVersion`, `currentServerSMPRelayVersion`, `proxiedSMPRelayVersion` to 20. Pre-v20 binaries lack the `RSLV_` tag; v20 binaries with sessions negotiated at v < 20 reject `RSLV_` at the parameter parser. The proxied-version bump 18 → 20 is safe (v19's `RecipientService`/`NotifierService` aren't in the forwarded whitelist; v18's `BLOCKED info` is already version-branched at `Protocol.hs:1943`).
|
||||
|
||||
**Party kind.** Append `Resolver` to `Party` (line 335); add `SResolver` (line 349), `TestEquality` clause (line 361), `PartyI Resolver` (line 394). `queueParty SResolver = Nothing` (falls through line 412). `partyClientRole SResolver = Nothing`.
|
||||
|
||||
**`RSLV` command.**
|
||||
|
||||
```haskell
|
||||
RSLV :: LookupKey -> Command Resolver
|
||||
newtype LookupKey = LookupKey ByteString
|
||||
|
||||
instance Encoding LookupKey where
|
||||
smpEncode (LookupKey s) = smpEncode s
|
||||
smpP = do
|
||||
n <- lenP
|
||||
when (n > 64) $ fail "LookupKey too large"
|
||||
LookupKey <$> A.take n
|
||||
```
|
||||
|
||||
Name-syntax validation is client-side per RFC; the server treats the key as opaque bytes. Tag `"RSLV"`, version guard inside `protocolP v (CT SResolver RSLV_)`: `| v >= namesSMPVersion -> Cmd SResolver . RSLV <$> _smpP`.
|
||||
|
||||
**Testnet/mainnet selector**: how the `#testnet:name` namespace appears in `LookupKey` bytes is determined by the SNRC contract (Part 1) — confirm with Part 1 before merging.
|
||||
|
||||
**`NAME` response.**
|
||||
|
||||
```haskell
|
||||
NAME :: NameRecord -> BrokerMsg
|
||||
```
|
||||
|
||||
Tag `"NAME"`. Symmetric version guards on encode (in `encodeProtocol v`) and decode (in `protocolP v NAME_`): `| v >= namesSMPVersion -> ...`. `NameRecord` has **no `Encoding` typeclass instance** — the typeclass cannot version-branch. Use top-level helpers `nameRecBytes :: VersionSMP -> NameRecord -> ByteString` and `parseNameRec :: VersionSMP -> Parser NameRecord`, mirroring the `IDS QIK` precedent at `Protocol.hs:1912–1979`.
|
||||
|
||||
**`NameRecord` schema and wire layout.**
|
||||
|
||||
```haskell
|
||||
data NameRecord = NameRecord
|
||||
{ nrDisplayName :: Text -- ≤255 bytes UTF-8
|
||||
, nrOwner :: NameOwner -- 20 raw bytes
|
||||
, nrChannelLinks :: [NameLink]
|
||||
, nrContactLinks :: [NameLink]
|
||||
, nrAdminAddress :: Maybe Text
|
||||
, nrAdminEmail :: Maybe Text
|
||||
, nrExpiry :: Int64 -- Unix seconds, ≥ 0
|
||||
, nrIsTest :: Bool
|
||||
}
|
||||
|
||||
newtype NameOwner = NameOwner ByteString -- bare ctor NOT exported; smart ctor enforces length 20
|
||||
newtype NameLink = NameLink Text -- bare ctor NOT exported; smart ctor enforces ≤1024 bytes
|
||||
|
||||
unNameOwner :: NameOwner -> ByteString
|
||||
unNameOwner (NameOwner bs) = bs
|
||||
|
||||
unNameLink :: NameLink -> Text
|
||||
unNameLink (NameLink t) = t
|
||||
```
|
||||
|
||||
Field additions are gated by future SMP version bumps (matching the `IDS QIK` precedent at `Protocol.hs:1912–1979`) — no separate record-version field.
|
||||
|
||||
| Field | Encoding | Max bytes |
|
||||
|---|---|---|
|
||||
| `nrDisplayName` | 1-byte length prefix + UTF-8 | 1 + 255 |
|
||||
| `nrOwner` | 20 raw bytes, no prefix | 20 |
|
||||
| `nrChannelLinks`, `nrContactLinks` | 1-byte count + per-element (Word16 BE len + UTF-8); combined cap **8 entries** across both lists | 1 + Σ(2 + ≤1024) |
|
||||
| `nrAdminAddress`, `nrAdminEmail` | `'0'` or `'1'` + (1-byte length + UTF-8 if `'1'`) | 1 + 1 + 255 |
|
||||
| `nrExpiry` | two big-endian `Word32` | 8 |
|
||||
| `nrIsTest` | `'T'` or `'F'` | 1 |
|
||||
|
||||
`Encoding NameLink` reads the Word16 length **before** `A.take` allocates — going through the existing `Large` wrapper allows up to 65 535 bytes per element. There is no `Encoding [a]` instance — use `smpEncodeList` / `smpListP` / a bounded variant:
|
||||
|
||||
```haskell
|
||||
smpListPUpTo :: Encoding a => Int -> Parser [a]
|
||||
smpListPUpTo cap = do
|
||||
n <- lenP
|
||||
when (n > cap) $ fail "list too long"
|
||||
A.count n smpP
|
||||
|
||||
parseNameRec _v = do
|
||||
nrDisplayName <- smpP
|
||||
nrOwner <- smpP
|
||||
nrChannelLinks <- smpListPUpTo 8
|
||||
nrContactLinks <- smpListPUpTo (8 - length nrChannelLinks)
|
||||
nrAdminAddress <- smpP
|
||||
nrAdminEmail <- smpP
|
||||
nrExpiry <- smpP
|
||||
when (nrExpiry < 0) $ fail "expiry must be non-negative"
|
||||
nrIsTest <- smpP
|
||||
pure NameRecord{..}
|
||||
```
|
||||
|
||||
Both list parsers fail at the count step before allocating; the second inherits the residual budget. Canonical encoding by construction: every primitive has exactly one valid byte form — two name servers reading the same SNRC state produce byte-identical responses.
|
||||
|
||||
**Wire-size budget.** `paddedProxiedTLength = 16226` is the plaintext input to `cbEncrypt` (`Server.hs:2117`); `pad` reserves 2 bytes → framed transmission ≤ 16 224 bytes. Combined-link cap 8 yields max payload ≈ 9 050 bytes — generous margin.
|
||||
|
||||
**Error semantics.** A single wire code: `ERR AUTH`. Per RFC, this collapses every failure (name not found, malformed key, names disabled, RPC unreachable, decode error, timeout). Resolver internally distinguishes the cause for stats only.
|
||||
|
||||
**Forwarded-only access.** Direct RSLV is rejected with `CMD PROHIBITED`. The shape of `THAuthServer` alone cannot discriminate direct from forwarded (`Transport.hs:852` sets `sessSecret' = Just _` for every v6+ direct client too). An explicit `forwarded :: Bool` flag is threaded through `verifyTransmission` (see below).
|
||||
|
||||
## Server changes
|
||||
|
||||
All edits in `src/Simplex/Messaging/Server.hs`.
|
||||
|
||||
**`forwarded :: Bool` plumbing.** Three signatures change:
|
||||
|
||||
- `verifyTransmission :: Bool -> ...` (line 1233) — direct path passes `False` (lines 1152–1153), forwarded path passes `True` (line 2129).
|
||||
- `verifyLoadedQueue :: Bool -> ...` (line 1238) — receives the flag from `verifyTransmission` (lines 1235, 1240).
|
||||
- `verifyQueueTransmission :: Bool -> ...` (line 1244) — receives and uses the flag.
|
||||
|
||||
New `vc` clauses inside `verifyQueueTransmission`:
|
||||
|
||||
```haskell
|
||||
vc SResolver (RSLV _) | forwarded = VRVerified Nothing
|
||||
| otherwise = VRFailed (CMD PROHIBITED)
|
||||
vc SResolver _ = VRFailed (CMD PROHIBITED) -- defensive catch-all
|
||||
```
|
||||
|
||||
**Forwarded whitelist** (`Server.hs:2132`):
|
||||
|
||||
```haskell
|
||||
Cmd SResolver (RSLV _) -> True
|
||||
```
|
||||
|
||||
**`processCommand` branch** (alongside line 1481):
|
||||
|
||||
```haskell
|
||||
Cmd SResolver (RSLV (LookupKey key)) -> do
|
||||
st <- asks (rslvStats . serverStats)
|
||||
incStat (rslvReqs st)
|
||||
asks namesEnv >>= \case
|
||||
Nothing -> incStat (rslvDisabled st) $> response (corrId, NoEntity, ERR AUTH)
|
||||
Just nenv -> liftIO (resolveName nenv key) >>= \case
|
||||
Right rec -> incStat (rslvSucc st) $> response (corrId, NoEntity, NAME rec)
|
||||
Left NotFound -> incStat (rslvNotFound st) $> response (corrId, NoEntity, ERR AUTH)
|
||||
Left _ -> incStat (rslvEthErrs st) $> response (corrId, NoEntity, ERR AUTH)
|
||||
```
|
||||
|
||||
**Shutdown.** Add `closeNamesEnv :: NamesEnv -> IO ()` calling `closeManager`. Wire into `closeServer` (`Server.hs:247`):
|
||||
|
||||
```haskell
|
||||
closeServer = do
|
||||
asks (smpAgent . proxyAgent) >>= liftIO . closeSMPClientAgent
|
||||
asks namesEnv >>= liftIO . mapM_ closeNamesEnv
|
||||
```
|
||||
|
||||
In-flight `resolveName` calls during shutdown receive `ConnectionClosed` → `EthHttpErr` → masked-leader cleanup runs → waiters unblock with `ERR AUTH`.
|
||||
|
||||
**`incStat` relocation.** Defined at `Server.hs:2220`, currently unexported. Move to `Server/Stats.hs` (one-line transplant + export) so `Resolver.hs` can use it.
|
||||
|
||||
**Co-located proxy warning.** `newEnv` logs a startup warning whenever `allowSMPProxy = True` and `namesConfig = Just _`. RSLV is the first slow forwarded command; on a proxy host it can serialise other forwarded commands on the same proxy-relay session up to `rpcTimeoutMs` per cache miss. The warning is not a hard refusal because `[PROXY]` has no `enable: on/off` toggle — proxy is always on for every smp-server. `forkForwardedCmd` async dispatch is the longer-term fix, tracked as a follow-up; once the proxy role is gateable per-server, the warning can be tightened back to a refusal.
|
||||
|
||||
## Resolver subtree
|
||||
|
||||
New module tree at `src/Simplex/Messaging/Server/Names/`:
|
||||
|
||||
| Module | Contents |
|
||||
|---|---|
|
||||
| `Names.hs` | Façade — re-exports `NamesConfig`, `NamesEnv`, `ResolveError`, `resolveName`, `newNamesEnv`, `closeNamesEnv`. |
|
||||
| `Names/Resolver.hs` | All types + cache + in-flight + `resolveName`. Helpers exported directly (no `.Internal` per codebase convention). **Test seam**: `NamesEnv` holds `ethCall` as a function value, so tests construct stubs via `newNamesEnvWith`. |
|
||||
| `Names/Eth/RPC.hs` | `EthRpcEnv`; `ethCallReal` via `http-client` + `withResponse` + `brReadSome rpcMaxResponseBytes`. JSON-RPC error / HTTP error split. `rpcMaxConcurrency` semaphore. `Authorization` header from `rpcAuth`. |
|
||||
| `Names/Eth/SNRC.hs` | `EthAddress`, Keccak-256 namehash via `crypton`'s `Crypto.Hash.Algorithms.Keccak_256` (mirroring `Crypto.hs:1023–1025` for SHA3), hand-rolled bounded Solidity ABI codec, `getRecord` with zero-owner detection. **Ethereum's Keccak ≠ NIST SHA3-256.** |
|
||||
|
||||
**ABI codec invariants**, enforced before any allocation: `offset + 32 ≤ buf.length`; `offset + 32 + length ≤ buf.length`; `offset ≥ headEnd` (no backward jumps); every length ≤ per-field cap; `string[]` outer length × 32 ≤ buf.length; recursion depth ≤ 2; `uint256 → Int64` rejects if any high 24 bytes non-zero; UTF-8 via `decodeUtf8'` returns `EthDecodeErr`.
|
||||
|
||||
**Zero-owner → `NotFound`**: ENS-style resolvers return zeroed records for non-existent names. After ABI decode, if `nrOwner == NameOwner (B.replicate 20 0)` return `Left NotFound`.
|
||||
|
||||
**Errors.**
|
||||
|
||||
```haskell
|
||||
data ResolveError = NotFound | EthHttpErr | EthRpcErr { rpcCode :: Int, rpcMessage :: Text }
|
||||
| EthDecodeErr | TimedOut
|
||||
```
|
||||
|
||||
All collapse to `ERR AUTH`. `EthRpcErr` carries JSON-RPC `error` object — method-not-found (SNRC not deployed at `snrc_address`) is logged immediately on the first error after a recent success: `logError "NAMES: JSON-RPC error from endpoint — check snrc_address: <code> <message>"`. No automatic retry.
|
||||
|
||||
**Cache.** TTL + FIFO eviction. `TVar (OrdPSQ LookupKey Word64 NameRecord, Int)` — priority = monotonic-ns at insert; the `Int` is running byte count. `cacheLookup` is one STM transaction (read, expiry-check, expired-delete-with-byte-decrement). `cacheInsert` is one STM transaction: while `size > cacheMaxEntries` OR `bytes + sizeOf(rec) > cacheMaxBytes`, `minView` to drop oldest, then `insert`. Byte counter prevents `100 000 × 9 KB ≈ 900 MB` worst-case blow-up.
|
||||
|
||||
**Request coalescing** (async-exception safe via `E.mask`):
|
||||
|
||||
```haskell
|
||||
resolveName env bs = do
|
||||
let k = LookupKey bs
|
||||
now <- getMonotonicTimeNSec
|
||||
atomically (cacheLookup env k now) >>= \case
|
||||
Just rec -> incStat (rslvCacheHits ...) $> Right rec
|
||||
Nothing -> do
|
||||
incStat (rslvCacheMiss ...)
|
||||
ticket <- atomically $ TM.lookup k (inflight env) >>= \case
|
||||
Just mv -> pure (Waiter mv)
|
||||
Nothing -> newEmptyTMVar >>= \mv -> TM.insert k mv (inflight env) $> Leader mv
|
||||
case ticket of
|
||||
Waiter mv -> atomically (readTMVar mv)
|
||||
Leader mv -> E.mask $ \restore -> do
|
||||
r <- restore (fetchOnceTimed env bs)
|
||||
`E.catch` \(e :: E.SomeException) -> pure (Left (mapEthErr e))
|
||||
atomically $ putTMVar mv r >> TM.delete k (inflight env)
|
||||
case r of Right rec -> atomically (cacheInsert env k now rec); Left _ -> pure ()
|
||||
pure r
|
||||
|
||||
fetchOnceTimed env bs =
|
||||
System.Timeout.timeout (rpcTimeoutMs (config env) * 1000) (fetchOnce env bs) >>= \case
|
||||
Just r -> pure r
|
||||
Nothing -> pure (Left TimedOut)
|
||||
```
|
||||
|
||||
`E.mask` ensures `putTMVar + TM.delete` runs even on async exception; `fetchOnceTimed` runs under `restore` so it remains interruptible. Waiters always see a value; the in-flight TMap entry is always removed.
|
||||
|
||||
`fetchOnce`, `mapEthErr`, `scrubUrl`, `cacheLookup`, `cacheInsert` are internal to `Resolver.hs`. `getMonotonicTimeNSec` from `GHC.Clock` — first monotonic-clock use in the codebase; clock-jump safe.
|
||||
|
||||
**STM contention.** Cache hits are read-only `readTVar` — STM scales. Cache writes under sustained miss traffic can retry; `CacheSpec` asserts < 5% retry at 4 readers + 1 writer @ 1k RPS. If observed higher, swap `TVar` for `IORef` + `atomicModifyIORef'`.
|
||||
|
||||
**Multicoin and text records** are not in `NameRecord`. If Part 1 contract returns them from `getRecord`, extend `NameRecord` and the wire-size budget. **Confirm with Part 1 author before implementing `Eth/SNRC.hs`.**
|
||||
|
||||
## Configuration
|
||||
|
||||
`ServerConfig` (`Env/STM.hs:142`) gains one field `namesConfig :: Maybe NamesConfig`. `Env` (`Env/STM.hs:261`) gains `namesEnv :: Maybe NamesEnv`. `newEnv` constructs it after `proxyAgent` (line 605) with the co-location guard.
|
||||
|
||||
```haskell
|
||||
data NamesConfig = NamesConfig
|
||||
{ ethereumEndpoint :: Text -- http(s), no userinfo, explicit port required
|
||||
, snrcAddress :: NameOwner -- 20 bytes
|
||||
, rpcAuth :: Maybe RpcAuth -- required when https & non-loopback host
|
||||
, cacheSeconds :: Int -- 300
|
||||
, cacheMaxEntries :: Int -- 100000
|
||||
, cacheMaxBytes :: Int -- 67108864 (64 MB)
|
||||
, rpcTimeoutMs :: Int -- 3000
|
||||
, rpcMaxResponseBytes :: Int -- 262144 (256 KB)
|
||||
, rpcMaxConcurrency :: Int -- 8
|
||||
}
|
||||
|
||||
data RpcAuth = AuthBearer Text | AuthBasic Text Text
|
||||
```
|
||||
|
||||
INI parsing in `Server/Main.hs`:
|
||||
|
||||
- `validateUrl` (using new `network-uri` dep): accepts only http(s), non-empty host, **explicit port** (rejects `http://localhost` defaulting to 80 while Reth is on 8545), no userinfo, no query/fragment. Rejects `https://...` without `rpc_auth` when host is non-loopback. On rejection: `logError` + `exitFailure`.
|
||||
- `parseEthAddr`: accepts `0x[0-9a-fA-F]{40}` and the same without `0x`. Mixed-case → verify EIP-55 checksum and reject mismatch (catches typos).
|
||||
- `parseRpcAuth`: reads optional `rpc_auth` key; format `bearer <token>` or `basic <user>:<pass>`.
|
||||
- `scrubUrl`: strips userinfo from all log lines mentioning the endpoint, including inside `mapEthErr`.
|
||||
- Transition-aware error logging: log immediately on first error after a recent success, then at most hourly while persisting + summary at every stats reset.
|
||||
|
||||
Default INI template (`Server/Main/Init.hs`, after `[PROXY]`):
|
||||
|
||||
```
|
||||
[NAMES]
|
||||
# Public-namespace resolution (SNRC on Ethereum).
|
||||
# Requires an Ethereum JSON-RPC endpoint (Reth+Nimbus). See deployment guide.
|
||||
# Cannot be combined with [PROXY] enable: on by default — see allow_dangerous_colocation.
|
||||
# Restart required to change settings.
|
||||
enable: off
|
||||
# Same-host:
|
||||
# ethereum_endpoint: http://127.0.0.1:8545
|
||||
# Central Reth via Caddy:
|
||||
# ethereum_endpoint: https://eth.simplex.chat:443
|
||||
# rpc_auth: basic <username>:<password>
|
||||
# snrc_address: 0x0000000000000000000000000000000000000000
|
||||
# cache_seconds: 300
|
||||
# cache_max_entries: 100000
|
||||
# cache_max_bytes: 67108864
|
||||
# rpc_timeout_ms: 3000
|
||||
# rpc_max_response_bytes: 262144
|
||||
# rpc_max_concurrency: 8
|
||||
# allow_dangerous_colocation: off
|
||||
```
|
||||
|
||||
Upgrade from a pre-v6.6 INI: missing `[NAMES]` section → disabled. No operator action required.
|
||||
|
||||
## Operator deployment
|
||||
|
||||
Two supported topologies. smp-server is agnostic — only `ethereum_endpoint` changes.
|
||||
|
||||
**Topology A (same-host)**: smp-server, Caddy (optional), Reth, Nimbus all on one box. `ethereum_endpoint: http://127.0.0.1:8545`.
|
||||
|
||||
**Topology B (central Reth, N smp-server hosts — recommended for fleets)**: one operator runs one eth host with Reth+Nimbus behind Caddy on public HTTPS. Each smp-server has its own credential.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph eth-host
|
||||
Caddy["Caddy<br/>(public :443, basic auth)"]
|
||||
Reth["Reth<br/>(127.0.0.1:8545)"]
|
||||
Nimbus["Nimbus"]
|
||||
Caddy --> Reth
|
||||
Nimbus -- Engine API (jwt.hex) --> Reth
|
||||
end
|
||||
subgraph smp-host-1
|
||||
S1["smp-server #1"]
|
||||
end
|
||||
subgraph smp-host-N
|
||||
SN["smp-server #N"]
|
||||
end
|
||||
S1 -- HTTPS + Authorization --> Caddy
|
||||
SN -- HTTPS + Authorization --> Caddy
|
||||
Reth <-- Ethereum p2p --> internet
|
||||
Nimbus <-- beacon sync --> internet
|
||||
```
|
||||
|
||||
Sharing one Reth across **multiple operators** is **not** supported — collapses the RFC's two-server resolution privacy.
|
||||
|
||||
**Reth + Nimbus**: Reth (execution layer) holds Ethereum state on ~260 GB pruned NVMe; Nimbus (consensus light client) follows beacon-chain headers. Paired via Engine API on `127.0.0.1:8551` with a shared `jwt.hex`. Recommended Reth flags:
|
||||
|
||||
```bash
|
||||
reth node \
|
||||
--http.addr 127.0.0.1 \
|
||||
--http.api eth \ # only eth namespace
|
||||
--rpc.gascap 50000000 \ # cap gas per eth_call
|
||||
--rpc.max-response-size 5242880 \ # 5 MB
|
||||
--http.corsdomain none \
|
||||
--authrpc.jwtsecret /opt/eth/jwt.hex \
|
||||
--authrpc.addr 127.0.0.1 --authrpc.port 8551
|
||||
```
|
||||
|
||||
**Caddy + Let's Encrypt + Basic auth** (Topology B):
|
||||
|
||||
```caddy
|
||||
eth.simplex.chat {
|
||||
basicauth {
|
||||
smp-server-1 $2a$14$<bcrypt-hash-1>
|
||||
smp-server-2 $2a$14$<bcrypt-hash-2>
|
||||
}
|
||||
log { format filter { wrap json; fields { request>headers>Authorization delete } } }
|
||||
reverse_proxy 127.0.0.1:8545
|
||||
}
|
||||
```
|
||||
|
||||
Caddy auto-fetches Let's Encrypt cert. Each smp-server has its own credential; revoking one = delete the line. `Authorization` stripped from access logs. Port 80 needed for the ACME HTTP-01 challenge (use TLS-ALPN-01 or DNS-01 to drop it). The threat being defended against is DoS (SNRC state is public); mTLS would be overkill. WireGuard/Tailscale are alternative network-layer approaches — both compatible with the plan.
|
||||
|
||||
**Capacity.** One Reth+Nimbus box handles a realistic operator fleet by 10–1000× margin. Per-smp-server peak RSLV ≈ 1700 RPS (pessimistic); cache hit rate ≥ 95% → ~85 RPS cache miss per smp-server; 10 smp-servers → ~850 RPS aggregate cache miss reaching Reth; Reth `eth_call` throughput on warm NVMe ≈ 1k–10k RPS. Sizing: 8 vCPU, 32 GB RAM, 1 TB NVMe is comfortable. Scale-out path: more Reth+Nimbus pairs, smp-servers round-robin or shard.
|
||||
|
||||
## Implementation
|
||||
|
||||
**Order**:
|
||||
|
||||
1. Protocol: party/SParty/PartyI, RSLV+tag, NAME+tag, NameRecord + helpers, version constants in `Transport.hs`.
|
||||
2. `verifyTransmission`/`verifyLoadedQueue`/`verifyQueueTransmission` `forwarded :: Bool` flag + `vc SResolver` clauses.
|
||||
3. Forwarded whitelist + `processCommand` branch + `incStat` move to `Stats.hs`.
|
||||
4. Env plumbing: `Server/Env/STM.hs`, `Server/Main.hs` INI parse, `Server/Main/Init.hs` template.
|
||||
5. Resolver subtree: `Eth/SNRC.hs` → `Eth/RPC.hs` → `Resolver.hs`.
|
||||
6. `NameResolverStats` sub-record + CSV log + Prometheus `names =` block.
|
||||
7. Replace stub in (3) with real `resolveName`.
|
||||
8. Tests.
|
||||
9. `protocol/simplex-messaging.md`: header version line 1 (`19 → 20`), sentence at line 86, version-history list (lines 93–105) v20 entry, TOC (lines 25–68) "Resolver commands" subsection, new section with ABNF + byte layout + error semantics, "Router security requirements" paragraph about names-role outbound HTTP, cross-ref `Transport.hs:226`.
|
||||
10. `CHANGELOG.md`: v6.6 entry.
|
||||
|
||||
**Cabal** (`simplexmq.cabal`): bump `version: 6.6.0.0`. Add to `if !flag(client_library)` block: `http-client >=0.7 && <0.8`, `http-client-tls >=0.3 && <0.4`, `network-uri >=2.6 && <2.7`, `psqueues >=0.2.7 && <0.3`. Expose 4 new `Server.Names.*` modules in the same block. `crypton` already provides `Keccak_256`.
|
||||
|
||||
**Files changed**:
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `Protocol.hs` | Resolver party + RSLV/NAME tags + version guards; `NameRecord` + newtypes + smart ctors; `nameRecBytes`/`parseNameRec`/`smpListPUpTo` helpers (no Encoding NameRecord instance); `LookupKey` parser-side cap |
|
||||
| `Transport.hs` | `namesSMPVersion = 20`; bump current/proxied SMP versions |
|
||||
| `Server.hs` | Thread `forwarded :: Bool`; `vc SResolver` clauses; whitelist (2132); Resolver branch in `processCommand` (1481); `closeServer` calls `closeNamesEnv`; CSV log (579–618); **remove** local `incStat` |
|
||||
| `Server/Env/STM.hs` | `namesConfig` field; `namesEnv` field; `newEnv` constructs `NamesEnv` with co-location guard |
|
||||
| `Server/Main.hs` | `[NAMES]` parse: `validateUrl`/`parseEthAddr`/`parseRpcAuth`; `scrubUrl` in logs |
|
||||
| `Server/Main/Init.hs` | `[NAMES]` block in default INI |
|
||||
| `Server/Stats.hs` | `incStat` moved here + exported; `NameResolverStats` sub-record + helpers; `rslvStats` field |
|
||||
| `Server/Prometheus.hs` | `names =` metric block |
|
||||
| `Server/Names.hs` (new) | Façade re-exports |
|
||||
| `Server/Names/Resolver.hs` (new) | All resolver types + cache + coalescing + `fetchOnceTimed` + `newNamesEnv[With]` + `closeNamesEnv` |
|
||||
| `Server/Names/Eth/RPC.hs` (new) | `EthRpcEnv`, `ethCallReal` with bounded body + concurrency semaphore + `Authorization` header |
|
||||
| `Server/Names/Eth/SNRC.hs` (new) | `EthAddress`, Keccak namehash, bounded ABI (8 invariants), `getRecord` with zero-owner detection |
|
||||
| `simplexmq.cabal` | Bump `6.6.0.0`; 4 new deps + 4 new modules in `if !flag(client_library)` block |
|
||||
| `protocol/simplex-messaging.md` | Header version, version-history v20 entry, new "Resolver commands" section |
|
||||
| `CHANGELOG.md` | v6.6 entry |
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/SMPNamesTests/` registered in `tests/Test.hs:112–151`. Build only when `client_library = False`.
|
||||
|
||||
1. **ProtocolEncodingSpec** — `nameRecBytes` ↔ `parseNameRec` round-trip; oversized fields rejected at parse; combined-list cap 8 enforced; negative `nrExpiry` rejected; canonical encoding byte-stable.
|
||||
2. **MaxSizeSpec** — max `NameRecord` encodes ≤ ~9 KB; `encodeTransmission v ≤ paddedProxiedTLength - 2`; `cbEncrypt` succeeds.
|
||||
3. **CommandTagSpec** — `"RSLV"`/`"NAME"` parse; v < 20 sessions reject `RSLV_` at parameter parser.
|
||||
4. **ForwardedGateSpec** — direct RSLV → `CMD PROHIBITED`; forwarded RSLV reaches handler.
|
||||
5. **ForwardedRslvSpec** — RSLV wrapped in PFWD reaches the handler end-to-end. **Test infra cost**: first protocol-level PFWD test; budget for `runProxiedSmpCommand` helper performing `PRXY`/`PKEY`/`PFWD` manually.
|
||||
6. **CacheSpec** — hit avoids RPC; TTL expiry forces re-fetch; bytes cap evicts before entries cap on large records; concurrent same-key callers issue one RPC; leader exception → all waiters get `Left _`, TMap entry removed; leader async-cancel → cleanup STM still runs.
|
||||
7. **AbiSpec** — encode/decode against pinned fixtures (`tests/fixtures/snrc/`); QuickCheck fuzz on random buffers ≤ `rpcMaxResponseBytes` must never crash.
|
||||
8. **NamehashSpec** — Keccak-256 reference vectors; assert Keccak ≠ SHA3-256.
|
||||
9. **MockRpcSpec** — fake HTTP server; missing → `EthHttpErr`; slow → `TimedOut`; multi-GB body truncated → `EthDecodeErr`. `rpcAuth = AuthBasic` sends correct header.
|
||||
10. **Uint256OverflowSpec** — `expiry > Int64.maxBound` → `EthDecodeErr`.
|
||||
11. **ZeroOwnerSpec** — `owner = 0x000...000` → `NotFound`.
|
||||
12. **StartupGuardSpec** — `allowSMPProxy + names.enable` aborts; `allow_dangerous_colocation = on` starts with warning.
|
||||
13. **UrlValidationSpec** — userinfo/scheme/host/port edge cases; rejects `https://` without `rpc_auth` for non-loopback.
|
||||
14. **EipChecksumSpec** — `parseEthAddr` accepts lower/upper; verifies mixed-case checksum; rejects typos.
|
||||
15. **AbiBoundsSpec** — each of 8 ABI invariants triggers `EthDecodeErr` without crash/allocation blow-up.
|
||||
|
||||
Integration against real Reth+Nimbus mainnet deferred to ops.
|
||||
|
||||
## Threat model, scope, coordination
|
||||
|
||||
| Actor | Can | Cannot |
|
||||
|---|---|---|
|
||||
| Name server | See lookup-key bytes; see query timing; see Eth endpoint URL (operator-self) | See client IP/session; correlate clients across queries |
|
||||
| Compromised Eth endpoint | Poison this server's cache for one TTL window; see every lookup key the server queries | Bypass two-server agreement (client-side, out of scope) |
|
||||
| Adversarial client (high-rate unique keys) | Cache-thrash DoS; fill `Manager` connection pool up to `managerConnCount = 8` | Bypass `rpcMaxResponseBytes` or `fetchOnceTimed` |
|
||||
| Adversarial proxy (slow inner RSLVs) | Block other forwarded commands on that proxy connection up to `rpcTimeoutMs` per miss | Affect other proxy connections |
|
||||
| Operator with footgun config (https no auth, public Eth RPC) | (rejected at startup, or operator-acknowledged data leak) | — |
|
||||
|
||||
Mitigations: caching + coalescing + `rpcTimeoutMs` + `rpcMaxResponseBytes` + `rpcMaxConcurrency`; co-location refused at startup; URL validation; Caddy + auth in front of Reth; Reth's own gas/size caps. Timing side-channels (cache-hit vs miss latency) not mitigated — flagged for post-MVP. State proofs deferred to post-MVP per RFC.
|
||||
|
||||
**Cross-repo coordination.** The `simplex-chat` `ep/namespace` branch currently contains only the RFC commit — no agent-side wire-format code yet. This plan's wire format is validated only by simplexmq's own tests until a matching agent PR lands (structurally weak — encoder/decoder bugs are mutually consistent with themselves). Coordinate with the agent-side implementer **before merging** on: exact `NameRecord` field order and types; `LookupKey` namespace-prefix convention; error-code semantics; Part 1 SNRC contract `getRecord` ABI surface.
|
||||
@@ -1,416 +0,0 @@
|
||||
|
||||
# Fix subQ deadlock: blocking writeTBQueue inside connLock
|
||||
|
||||
## Problem
|
||||
|
||||
Users report that message reception silently and permanently stops across all connections, with no error alerts. The app appears functional but no messages arrive. Recovery requires restart.
|
||||
|
||||
Root cause: a deadlock between worker threads holding `connLock` and the `agentSubscriber` (sole `subQ` reader).
|
||||
|
||||
### The deadlock mechanism
|
||||
|
||||
`subQ` (`TBQueue ATransmission`, capacity 4096 on mobile / 1024 on desktop) is the single pipeline between the agent layer and the chat layer. The `agentSubscriber` thread (`Commands.hs:4373`) is its **sole reader**.
|
||||
|
||||
Three code sites hold `connLock` and call blocking `writeTBQueue subQ` without a fullness check. When `subQ` is full, these block while holding the lock. If `agentSubscriber` simultaneously needs the same `connLock` (via `sendMessagesB_` → `withConnLocks`), it blocks too — creating a circular wait:
|
||||
|
||||
- **Worker**: holds `connLock(X)`, waits for `subQ` space (needs `agentSubscriber` to read)
|
||||
- **agentSubscriber**: sole `subQ` reader, waits for `connLock(X)` (needs worker to release)
|
||||
- **Result**: permanent silent deadlock — no exception, no alert, all connections blocked
|
||||
|
||||
### Confirmed deadlock scenarios
|
||||
|
||||
**Scenario 1**: Delivery worker during queue rotation test
|
||||
|
||||
```
|
||||
Delivery worker: agentSubscriber (sole subQ reader):
|
||||
withConnLock(X) [2187] readTBQueue subQ → processAgentMessageConn
|
||||
...DB operations... → sendPendingGroupMessages (on CON/SENT/QCONT)
|
||||
notify → writeTBQueue subQ [2238] → batchSendConnMessages → deliverMessagesB
|
||||
[BLOCKED — subQ full] → withAgent sendMessagesB [synchronous]
|
||||
→ sendMessagesB_ → withConnLocks({..X..}) [1708]
|
||||
[BLOCKED — connLock(X) held]
|
||||
```
|
||||
|
||||
**Scenario 2**: Async command worker during message ACK with notification
|
||||
|
||||
```
|
||||
Async cmd worker: agentSubscriber (sole subQ reader):
|
||||
tryWithLock "ICAck" [1930→1824] readTBQueue subQ → processAgentMessageConn
|
||||
→ withConnLock(X) → sendPendingGroupMessages
|
||||
→ ack → ackQueueMessage [1899] → sendMessagesB_ → withConnLocks({..X..})
|
||||
→ sendMsgNtf [2381] [BLOCKED — connLock(X) held]
|
||||
→ writeTBQueue subQ [2386]
|
||||
[BLOCKED — subQ full]
|
||||
```
|
||||
|
||||
**Scenario 3**: Synchronous `ackMessage'` API (same mechanism as Scenario 2 but from external API caller)
|
||||
|
||||
```
|
||||
ackMessage' caller: agentSubscriber (sole subQ reader):
|
||||
withConnLock(X) [2254] → sendMessagesB_ → withConnLocks({..X..})
|
||||
→ ack → ackQueueMessage [2267] [BLOCKED — connLock(X) held]
|
||||
→ sendMsgNtf [2381]
|
||||
→ writeTBQueue subQ [2386]
|
||||
[BLOCKED — subQ full]
|
||||
```
|
||||
|
||||
### ConnId overlap verified
|
||||
|
||||
No guard prevents a connection undergoing queue rotation (AM_QTEST_) or ACK processing from being included in `sendMessagesB_`'s batch. During these operations, the connection has `connStatus == ConnReady`, passing all filters in `memberSendAction`.
|
||||
|
||||
### Cascade amplification
|
||||
|
||||
Once any single deadlock triggers, `subQ` never drains. ALL other threads that attempt `writeTBQueue subQ` block progressively — their locks are held forever too. The entire threading system freezes within seconds.
|
||||
|
||||
### Affected code sites (blocking `writeTBQueue subQ` inside `connLock`)
|
||||
|
||||
| Site | File | Lock line | Write line | Events written |
|
||||
|------|------|-----------|------------|----------------|
|
||||
| `runSmpQueueMsgDelivery::notify` | Agent.hs | 2187 | 2238 | SWITCH SPCompleted, ERR INTERNAL |
|
||||
| `runSmpQueueMsgDelivery::internalErr/notifyDel` | Agent.hs | 2187 | 2238 (via notifyDel→notify) | ERR INTERNAL + delMsg |
|
||||
| `ackQueueMessage::sendMsgNtf` | Agent.hs | 2254 or 1930 | 2386 | MSGNTF |
|
||||
|
||||
### Safe patterns that already exist in the codebase
|
||||
|
||||
1. **`isFullTBQueue` + pending TVar** (used at `runCommandProcessing` lines 1782-1784/1937, and `runProcessSMP` lines 3027-3029/3216):
|
||||
```haskell
|
||||
-- Before processing (e.g. line 1782):
|
||||
pending <- newTVarIO []
|
||||
-- During processing — safe notify (e.g. line 1937):
|
||||
notify cmd =
|
||||
let t = (corrId, connId, AEvt (sAEntity @e) cmd)
|
||||
in atomically $ ifM (isFullTBQueue subQ) (modifyTVar' pendingCmds (t :)) (writeTBQueue subQ t)
|
||||
-- After processing — flush (e.g. line 1784):
|
||||
mapM_ (atomically . writeTBQueue subQ) . reverse =<< readTVarIO pending
|
||||
```
|
||||
|
||||
2. **`nonBlockingWriteTBQueue`** (used at Client.hs:789, NtfSubSupervisor.hs:507):
|
||||
```haskell
|
||||
nonBlockingWriteTBQueue q x = do
|
||||
sent <- atomically $ tryWriteTBQueue q x
|
||||
unless sent $ void $ forkIO $ atomically $ writeTBQueue q x
|
||||
```
|
||||
Note: `nonBlockingWriteTBQueue` does NOT preserve ordering — the spawned background thread may complete out of order relative to subsequent direct writes from the same calling thread.
|
||||
|
||||
### Exhaustive proof: no other deadlock scenarios exist
|
||||
|
||||
All 15 `withConnLock` sites in Agent.hs were analyzed. Only 3 write to `subQ`:
|
||||
|
||||
| withConnLock site | Writes subQ? | Safe? |
|
||||
|-------------------|-------------|-------|
|
||||
| switchConnectionAsync' (899) | No | ✓ |
|
||||
| setConnShortLinkAsync' (995) | No | ✓ |
|
||||
| setConnShortLink' (1031) | No | ✓ |
|
||||
| deleteConnShortLink' (1075) | No | ✓ |
|
||||
| allowConnection' (1407) | No | ✓ |
|
||||
| acceptContact' (1417) | No | ✓ |
|
||||
| sendMessagesB_ (1708, `withConnLocks`) | No | ✓ |
|
||||
| tryWithLock/runSmpCommand (1930) | Yes (1937) | ✓ — `isFullTBQueue` check |
|
||||
| tryMoveableWithLock/runSmpCommand (1931) | Yes (1937) | ✓ — `isFullTBQueue` check |
|
||||
| **runSmpQueueMsgDelivery AM_QTEST_ (2187)** | **Yes (2238)** | **✗ — DEADLOCK** |
|
||||
| **ackMessage' (2254)** | **Yes (2386)** | **✗ — DEADLOCK** |
|
||||
| switchConnection' (2298) | No | ✓ |
|
||||
| abortConnectionSwitch' (2328) | No | ✓ |
|
||||
| synchronizeRatchet' (2351) | No | ✓ |
|
||||
| suspendConnection' (2390) | No | ✓ |
|
||||
| **processSMP (3037)** | Yes (3216) | ✓ — `isFullTBQueue` check |
|
||||
|
||||
Note: `processSMP` (line 3037) holds `connLock` and its local `notify` (line 3216) writes to `subQ`, but it uses the safe `isFullTBQueue` pattern. Its `ack` (line 3196) uses `enqueueCmd` (DB-only), NOT `ackQueueMessage`. The actual `ackQueueMessage` runs later from the async command worker via ICAck/ICAckDel.
|
||||
|
||||
Other lock pairs checked — no circular dependencies:
|
||||
- `connLock × DB MVar`: DB never acquires connLock
|
||||
- `entityLock × connLock`: consistent ordering (entity first in chat, conn in agent)
|
||||
- `connLock(X) × connLock(Y)`: single agentSubscriber thread, one `withConnLocks` at a time
|
||||
|
||||
---
|
||||
|
||||
## Deadlock call graph: agentSubscriber → connLock
|
||||
|
||||
All deadlock paths require `agentSubscriber` to synchronously acquire `connLock`. Exhaustive analysis shows that **every such path converges on a single agent function**: `sendMessagesB_` → `withConnLocks` (Agent.hs:1708). No other agent API function called synchronously from the agentSubscriber acquires connLock.
|
||||
|
||||
Verified (FACT): `ackMessageAsync` → `enqueueCommand` only (no connLock). `toggleConnectionNtfs` → no lock. `deleteConnectionAsync` → `deleteLock` not `connLock`. `joinConnectionAsync` → `withInvLock` not `connLock`.
|
||||
|
||||
Also verified (FACT): `Lock = TMVar Text` (Lock.hs:24) is **non-reentrant** — double acquisition on the same thread deadlocks.
|
||||
|
||||
### All 22 trigger paths
|
||||
|
||||
Every path goes through `deliverMessage`/`deliverMessages`/`deliverMessagesB` → `withAgent sendMessagesB` → `sendMessagesB_` → `withConnLocks`:
|
||||
|
||||
| # | Trigger | Chat function | ConnIds locked | Risk |
|
||||
|---|---------|--------------|----------------|------|
|
||||
| 1 | Group CON (Invitee) | `introduceToAll` → broadcast XGrpMemNew | **ALL member connIds** | **HIGHEST** |
|
||||
| 2 | Group MSG XGrpLinkAcpt | `introduceToRemaining` → broadcast | **ALL member connIds** | **HIGHEST** |
|
||||
| 3 | Group CON (Invitee) | `sendIntroductions` → batch intros to new member | new member connId | Medium |
|
||||
| 4 | Group CON (Invitee) | `sendHistory` → batch to new member | new member connId | Medium |
|
||||
| 5 | Group CON | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 6 | Group SENT | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 7 | Group QCONT | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 8 | Group CON (PendingReview) | `introduceToModerators` → to moderators | moderator connIds | Medium |
|
||||
| 9 | Group CON (PreMember) | `sendXGrpMemCon` → to host | host connId | Low |
|
||||
| 10 | Group CON (PreMember) | `probeMatchingMemberContact` → probes + hashes | member + N matching connIds | Medium |
|
||||
| 11 | Direct CON | `probeMatchingMembers` → probes + hashes | contact + N matching connIds | Medium |
|
||||
| 12 | Direct JOINED | `sendAutoReply` | contact connId | Low |
|
||||
| 13 | Group JOINED | `sendGroupAutoReply` | member connId | Low |
|
||||
| 14 | Group INV | `sendXGrpMemInv` → to host | host connId | Low |
|
||||
| 15 | Group INV (legacy) | `sendGrpInvitation` → to contact | contact connId | Low |
|
||||
| 16 | Group MSG XGrpMemInv | `xGrpMemInv` → `sendGroupMemberMessage` | re-member connId | Low |
|
||||
| 17 | Group MSG XGrpMemDel | `forwardToMember` | deleted member connId | Low |
|
||||
| 18 | Group MSG XGrpLinkMem | `probeMatchingMemberContact` | member + N matching connIds | Medium |
|
||||
| 19 | Group MSG (dup relay) | `saveGroupRcvMsg` error → `sendDirectMemberMessage` | forwarder connId | Low |
|
||||
| 20 | SFDONE | `sendFileDescriptions` → to recipients | recipient connIds | Medium |
|
||||
| 21 | Group MSG XGrpLinkAcpt | `sendHistory` → to accepted member | accepted member connId | Medium |
|
||||
| 22 | Direct MSG (autoAccept) | `autoAcceptFile` → inline accept reply | contact connId | Low (test-only config) |
|
||||
|
||||
### Key observations
|
||||
|
||||
1. **Single bottleneck**: All 22 paths converge on `sendMessagesB_` → `withConnLocks` (Agent.hs:1708). The deadlock is between this lock acquisition and any worker thread holding `connLock` + blocking on `writeTBQueue subQ`.
|
||||
|
||||
2. **Highest-risk paths** (#1, #2): Broadcasting to ALL group members in `introduceToAll` / `introduceToRemaining` acquires `withConnLocks` on ALL member connIds in a single batch. For large groups, this holds the agentSubscriber thread for a long time, during which subQ fills, which causes worker threads holding connLock on any of those connIds to deadlock.
|
||||
|
||||
3. **Medium-risk paths** (#5-7): `sendPendingGroupMessages` fires on every CON/SENT/QCONT. These are frequent and lock the member's connId, which is the SAME connId that a delivery worker or ACK worker may hold while writing to subQ.
|
||||
|
||||
---
|
||||
|
||||
## Analysis: `withConnLocks` in `sendMessagesB_`
|
||||
|
||||
### FACT: the lock protects ratchet encryption state
|
||||
|
||||
`sendMessagesB_` (Agent.hs:1708-1713) acquires `withConnLocks` and executes:
|
||||
|
||||
1. **`getConn_`** — reads connection metadata, send queues from DB
|
||||
2. **`setConnPQSupport`** — updates PQ encryption flag per connection
|
||||
3. **`enqueueMessagesB`** → `enqueueMessageB` → `storeSentMsg_` which calls:
|
||||
- **`updateSndIds`** (AgentStore.hs:899) — increments `internalSndId` (sequential send counter)
|
||||
- **`agentRatchetEncryptHeader`** (Agent.hs:3698) — reads current ratchet via `getRatchetForUpdate`, encrypts message header via `rcEncryptHeader`, writes advanced ratchet state via `updateRatchet`
|
||||
- **`createSndMsg`** + **`createSndMsgDelivery`** — inserts message and delivery records
|
||||
|
||||
All operations run within `unsafeWithStore` → `withTransaction` (single DB transaction per batch).
|
||||
|
||||
### FACT: the lock CANNOT be removed
|
||||
|
||||
Without `withConnLocks`, concurrent `sendMessagesB_` calls targeting the same connection would:
|
||||
- Read the same ratchet state, both encrypt, one overwrite the other → **ratchet desync** (unrecoverable)
|
||||
- Get duplicate `internalSndId` values → **message ID collision**
|
||||
- Race on `setConnPQSupport` → **PQ state inconsistency**
|
||||
|
||||
The lock serializes ALL operations on the connection's encryption state. Removing it would introduce data corruption.
|
||||
|
||||
Note: `sendMessage` (singular, line 530) uses the same `sendMessagesB_` function — there is no lock-free send path.
|
||||
|
||||
### Eliminated strategies
|
||||
|
||||
- **Strategy C (remove lock)**: The lock protects ratchet encryption. Removing it causes unrecoverable ratchet desync. Eliminated.
|
||||
- **Strategy A (async dispatch)**: All 22 chat-layer callers use `deliverMessagesB` return values (delivery IDs, PQ state) synchronously. `forkIO` loses results. Eliminated.
|
||||
- **Strategy W (isFullTBQueue + pending TVar)**: The existing pattern (lines 1937, 3216) buffers events in a local TVar and flushes after lock release. Between lock release and flush, another thread can acquire the same connLock and write events to subQ — reordering events within the same connection. This trades a visible deadlock for invisible ordering bugs. Eliminated.
|
||||
- **Strategy O (per-connection overflow queues)**: Bounded overflow queues with "drop when full" were analyzed. Drop consequences are unacceptable at 5 of 6 write sites — CONF, INFO, CON cause permanent connection failure after ACK; INV loses connection invitations; SENT/MERR leave messages stuck forever. Unbounded overflow defeats backpressure. Eliminated.
|
||||
|
||||
---
|
||||
|
||||
## Solution: move subQ writes outside connLock
|
||||
|
||||
### Root cause
|
||||
|
||||
The `writeTBQueue subQ` calls at the 3 deadlock sites are inside `connLock` by accident of code structure, not necessity. `connLock` protects ratchet encryption state and DB consistency. The `notify` calls write informational events to `subQ` — they do not modify any state that `connLock` protects.
|
||||
|
||||
Moving the writes outside the lock scope eliminates the deadlock: blocking `writeTBQueue subQ` without holding `connLock` is safe — agentSubscriber is free to acquire the lock, process events, and drain `subQ`.
|
||||
|
||||
### Why reordering doesn't matter at these sites
|
||||
|
||||
The chat layer handlers for the 3 deadlock site events do NOT advance the ratchet:
|
||||
|
||||
| Event | Chat handler | Calls sendMessagesB_? |
|
||||
|-------|-------------|----------------------|
|
||||
| SWITCH SPCompleted | Creates internal chat item, updates UI | **No** |
|
||||
| ERR INTERNAL | Logs error to view | **No** |
|
||||
| MSGNTF | `toView CEvtNtfMessage` → empty output | **No** |
|
||||
|
||||
Events that DO trigger ratchet advances (CON, SENT, QCONT → `sendPendingGroupMessages` → `sendMessagesB_`) are all already written OUTSIDE `connLock` in the current code.
|
||||
|
||||
Ratchet state lives in the DB, not in subQ events. agentSubscriber processes events sequentially regardless of arrival order. The SENT-before-SWITCH race already exists in the current code (new queue worker writes SENT outside connLock while old queue worker writes SWITCH inside connLock).
|
||||
|
||||
### Fix: Site 1 — `runSmpQueueMsgDelivery` AM_QTEST_ (line 2187)
|
||||
|
||||
Restructure `withConnLock` to return the event, write outside.
|
||||
|
||||
**Current code** (Agent.hs:2187-2214):
|
||||
```haskell
|
||||
AM_QTEST_ -> withConnLock c connId "runSmpQueueMsgDelivery AM_QTEST_" $ do
|
||||
withStore' c $ \db -> setSndQueueStatus db sq Active
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection cData' rqs sqs -> do
|
||||
let addr = qAddress sq
|
||||
case findQ addr sqs of
|
||||
Just SndQueue {dbReplaceQueueId = Just replacedId, primary} ->
|
||||
case removeQP (\sq' -> dbQId sq' == replacedId && not (sameQueue addr sq')) sqs of
|
||||
Nothing -> internalErr msgId "sent QTEST: queue not found in connection"
|
||||
Just (sq', sq'' : sqs') -> do
|
||||
checkSQSwchStatus sq' SSSendingQTEST
|
||||
atomically $ TM.delete (qAddress sq') $ smpDeliveryWorkers c
|
||||
withStore' c $ \db -> do
|
||||
when primary $ setSndQueuePrimary db connId sq
|
||||
deletePendingMsgs db connId sq'
|
||||
deleteConnSndQueue db connId sq'
|
||||
let sqs'' = sq'' :| sqs'
|
||||
conn' = DuplexConnection cData' rqs sqs''
|
||||
cStats <- connectionStats c conn'
|
||||
notify $ SWITCH QDSnd SPCompleted cStats -- DEADLOCK
|
||||
_ -> internalErr msgId "sent QTEST: ..." -- DEADLOCK (via notifyDel → notify)
|
||||
_ -> internalErr msgId "sent QTEST: ..." -- DEADLOCK
|
||||
_ -> internalErr msgId "QTEST sent not in duplex ..." -- DEADLOCK
|
||||
```
|
||||
|
||||
**New code:**
|
||||
```haskell
|
||||
AM_QTEST_ -> do
|
||||
evt_ <- withConnLock c connId "runSmpQueueMsgDelivery AM_QTEST_" $ do
|
||||
withStore' c $ \db -> setSndQueueStatus db sq Active
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection cData' rqs sqs -> do
|
||||
let addr = qAddress sq
|
||||
case findQ addr sqs of
|
||||
Just SndQueue {dbReplaceQueueId = Just replacedId, primary} ->
|
||||
case removeQP (\sq' -> dbQId sq' == replacedId && not (sameQueue addr sq')) sqs of
|
||||
Nothing -> pure $ Left "sent QTEST: queue not found in connection"
|
||||
Just (sq', sq'' : sqs') -> do
|
||||
checkSQSwchStatus sq' SSSendingQTEST
|
||||
atomically $ TM.delete (qAddress sq') $ smpDeliveryWorkers c
|
||||
withStore' c $ \db -> do
|
||||
when primary $ setSndQueuePrimary db connId sq
|
||||
deletePendingMsgs db connId sq'
|
||||
deleteConnSndQueue db connId sq'
|
||||
let sqs'' = sq'' :| sqs'
|
||||
conn' = DuplexConnection cData' rqs sqs''
|
||||
cStats <- connectionStats c conn'
|
||||
pure $ Right $ SWITCH QDSnd SPCompleted cStats
|
||||
_ -> pure $ Left "sent QTEST: there is only one queue in connection"
|
||||
_ -> pure $ Left "sent QTEST: queue not in connection or not replacing another queue"
|
||||
_ -> pure $ Left "QTEST sent not in duplex connection"
|
||||
-- subQ write is now OUTSIDE connLock — blocking writeTBQueue is safe
|
||||
case evt_ of
|
||||
Right evt -> notify evt
|
||||
Left err -> internalErr msgId err
|
||||
```
|
||||
|
||||
All DB operations remain inside the lock. Only `notify`/`internalErr` (which write to subQ) move outside. `internalErr` calls `notifyDel` = `notify >> delMsg` — both `notify` (subQ write) and `delMsg` (`deleteSndMsgDelivery`, keyed on unique msgId) are safe outside the lock. The existing double-delete pattern (`delMsg` inside `internalErr` + `delMsgKeep` at line 2216) is preserved.
|
||||
|
||||
### Fix: Sites 2 & 3 — `ackQueueMessage::sendMsgNtf` (line 2386)
|
||||
|
||||
Change `ackQueueMessage` to return the MSGNTF event instead of writing it to subQ. Callers write to subQ after releasing connLock.
|
||||
|
||||
**Current code** (Agent.hs:2371-2386):
|
||||
```haskell
|
||||
ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM ()
|
||||
ackQueueMessage c rq@RcvQueue {userId, connId, server} srvMsgId = do
|
||||
atomically $ incSMPServerStat c userId server ackAttempts
|
||||
tryAllErrors (sendAck c rq srvMsgId) >>= \case
|
||||
Right _ -> sendMsgNtf ackMsgs
|
||||
Left (SMP _ SMP.NO_MSG) -> sendMsgNtf ackNoMsgErrs
|
||||
Left e -> ...
|
||||
where
|
||||
sendMsgNtf stat = do
|
||||
atomically $ incSMPServerStat c userId server stat
|
||||
whenM (liftIO $ hasGetLock c rq) $ do
|
||||
atomically $ releaseGetLock c rq
|
||||
brokerTs_ <- eitherToMaybe <$> tryAllErrors (withStore c $ \db -> getRcvMsgBrokerTs db connId srvMsgId)
|
||||
atomically $ writeTBQueue (subQ c) ("", connId, AEvt SAEConn $ MSGNTF srvMsgId brokerTs_)
|
||||
```
|
||||
|
||||
**New code** — return `Maybe ATransmission` instead of writing:
|
||||
```haskell
|
||||
ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM (Maybe ATransmission)
|
||||
ackQueueMessage c rq@RcvQueue {userId, connId, server} srvMsgId = do
|
||||
atomically $ incSMPServerStat c userId server ackAttempts
|
||||
tryAllErrors (sendAck c rq srvMsgId) >>= \case
|
||||
Right _ -> sendMsgNtf ackMsgs
|
||||
Left (SMP _ SMP.NO_MSG) -> sendMsgNtf ackNoMsgErrs
|
||||
Left e -> ... >> pure Nothing
|
||||
where
|
||||
sendMsgNtf stat = do
|
||||
atomically $ incSMPServerStat c userId server stat
|
||||
ifM (liftIO $ hasGetLock c rq)
|
||||
(do atomically $ releaseGetLock c rq
|
||||
brokerTs_ <- eitherToMaybe <$> tryAllErrors (withStore c $ \db -> getRcvMsgBrokerTs db connId srvMsgId)
|
||||
pure $ Just ("", connId, AEvt SAEConn $ MSGNTF srvMsgId brokerTs_))
|
||||
(pure Nothing)
|
||||
```
|
||||
|
||||
**Caller 1: `ackMessage'`** (Agent.hs:2253-2267) — return event from `withConnLock`, write after:
|
||||
```haskell
|
||||
ackMessage' c connId msgId rcptInfo_ = do
|
||||
t_ <- withConnLock c connId "ackMessage" $ do
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection {} -> do
|
||||
t_ <- ack
|
||||
sendRcpt conn
|
||||
del
|
||||
pure t_
|
||||
RcvConnection {} -> do
|
||||
t_ <- ack
|
||||
del
|
||||
pure t_
|
||||
SndConnection {} -> throwE $ CONN SIMPLEX "ackMessage"
|
||||
ContactConnection {} -> throwE $ CMD PROHIBITED "ackMessage: ContactConnection"
|
||||
NewConnection _ -> throwE $ CMD PROHIBITED "ackMessage: NewConnection"
|
||||
-- subQ write is OUTSIDE connLock
|
||||
case t_ of
|
||||
Just t -> atomically $ writeTBQueue (subQ c) t
|
||||
Nothing -> pure ()
|
||||
```
|
||||
|
||||
**Caller 2: `ICAck` / `ICAckDel`** (Agent.hs:1823-1824) — inline `tryWithLock` as `tryCommand` + `withConnLock`, write subQ between the two scopes:
|
||||
|
||||
`tryWithLock name = tryCommand . withConnLock c connId name` — by inlining, the subQ write can be placed outside `withConnLock` but inside `tryCommand` (retaining retry/error handling).
|
||||
|
||||
```haskell
|
||||
ICAck rId srvMsgId -> withServer $ \srv ->
|
||||
tryCommand $ do
|
||||
t_ <- withConnLock c connId "ICAck" $ ack srv rId srvMsgId
|
||||
-- subQ write is OUTSIDE connLock — cannot deadlock with agentSubscriber
|
||||
forM_ t_ $ atomically . writeTBQueue subQ
|
||||
|
||||
ICAckDel rId srvMsgId msgId -> withServer $ \srv ->
|
||||
tryCommand $ do
|
||||
t_ <- withConnLock c connId "ICAckDel" $ do
|
||||
t_ <- ack srv rId srvMsgId
|
||||
withStore' c (\db -> deleteMsg db connId msgId)
|
||||
pure t_
|
||||
-- subQ write is OUTSIDE connLock — cannot deadlock with agentSubscriber
|
||||
forM_ t_ $ atomically . writeTBQueue subQ
|
||||
```
|
||||
|
||||
Where `ack` now returns `AM (Maybe ATransmission)`:
|
||||
```haskell
|
||||
ack srv rId srvMsgId = do
|
||||
rq <- withStore c $ \db -> getRcvQueue db connId srv rId
|
||||
ackQueueMessage c rq srvMsgId
|
||||
```
|
||||
|
||||
All subQ writes for MSGNTF are now outside connLock. FIFO ordering is preserved — no `nonBlockingWriteTBQueue`, no forked threads. The same thread that held the lock writes to subQ sequentially after releasing it.
|
||||
|
||||
### Race analysis
|
||||
|
||||
Window between connLock release and subQ write at Site 1:
|
||||
|
||||
| Thread | Can acquire connLock(X)? | Writes subQ? | Consequence |
|
||||
|--------|-------------------------|-------------|-------------|
|
||||
| agentSubscriber via sendMessagesB_ | Yes | **No** (encrypts only) | No race |
|
||||
| processSMP for connId X | Yes | Yes (pending flush) | MSG before SWITCH — cosmetic |
|
||||
| runCommandProcessing for connId X | Yes | Yes (pending flush) | Command response before SWITCH — cosmetic |
|
||||
| New queue delivery worker | No (SENT outside lock) | Yes | SENT before SWITCH — cosmetic, **already exists in current code** |
|
||||
|
||||
All races are cosmetic UI ordering. None affect ratchet state, protocol correctness, or message delivery.
|
||||
|
||||
### Summary of changes
|
||||
|
||||
| File | Change | Lines affected |
|
||||
|------|--------|---------------|
|
||||
| Agent.hs | Restructure AM_QTEST_ to return event from `withConnLock`, write outside | ~2187-2214 |
|
||||
| Agent.hs | Change `ackQueueMessage` return type to `AM (Maybe ATransmission)`, return event instead of writing | ~2371-2386 |
|
||||
| Agent.hs | `ackMessage'`: return event from `withConnLock`, write outside | ~2253-2267 |
|
||||
| Agent.hs | `ICAck`/`ICAckDel`: inline `tryCommand` + `withConnLock`, write subQ between scopes | ~1823-1824 |
|
||||
| Agent.hs | `ack` helper: propagate new return type | ~1899-1901 |
|
||||
|
||||
No new data structures. No new modules. No changes to other write sites (1937, 3216 — already safe). ~25 lines changed total.
|
||||
@@ -1,4 +1,4 @@
|
||||
Version 7, 2025-01-24
|
||||
Version 5, 2024-06-22
|
||||
|
||||
# SMP agent protocol - duplex communication over SMP protocol
|
||||
|
||||
@@ -6,10 +6,9 @@ Version 7, 2025-01-24
|
||||
|
||||
- [Abstract](#abstract)
|
||||
- [SMP agent](#smp-agent)
|
||||
- [SMP routers management](#smp-routers-management)
|
||||
- [SMP servers management](#smp-servers-management)
|
||||
- [SMP agent protocol scope](#smp-agent-protocol-scope)
|
||||
- [Duplex connection procedure](#duplex-connection-procedure)
|
||||
- [Fast duplex connection procedure](#fast-duplex-connection-procedure)
|
||||
- [Contact addresses](#contact-addresses)
|
||||
- [Communication between SMP agents](#communication-between-smp-agents)
|
||||
- [Message syntax](#messages-between-smp-agents)
|
||||
@@ -21,58 +20,41 @@ Version 7, 2025-01-24
|
||||
- [Rotating messaging queue](#rotating-messaging-queue)
|
||||
- [End-to-end encryption](#end-to-end-encryption)
|
||||
- [Connection link: 1-time invitation and contact address](#connection-link-1-time-invitation-and-contact-address)
|
||||
- [Full connection link syntax](#full-connection-link-syntax)
|
||||
- [Short connection link syntax](#short-connection-link-syntax)
|
||||
- [Short links](#short-links)
|
||||
- [Link key derivation](#link-key-derivation)
|
||||
- [Link data encryption](#link-data-encryption)
|
||||
- [Short link resolution](#short-link-resolution)
|
||||
- [Link data management](#link-data-management)
|
||||
- [Appendix A: SMP agent API](#appendix-a-smp-agent-api)
|
||||
- [Appendix A: SMP agent API](#smp-agent-api)
|
||||
- [API functions](#api-functions)
|
||||
- [API events](#api-events)
|
||||
|
||||
## Abstract
|
||||
|
||||
The purpose of SMP agent protocol is to define the syntax and the semantics of communications between the client and the agent that connects to [SMP](./simplex-messaging.md) routers.
|
||||
The purpose of SMP agent protocol is to define the syntax and the semantics of communications between the client and the agent that connects to [SMP](./simplex-messaging.md) servers.
|
||||
|
||||
It provides:
|
||||
- API to create and manage bi-directional (duplex) connections between the users of SMP agents consisting of two (or more) separate unidirectional (simplex) SMP queues, abstracting away multiple steps required to establish bi-directional connections and any information about the routers location from the users of the agent protocol.
|
||||
- API to create and manage bi-directional (duplex) connections between the users of SMP agents consisting of two (or more) separate unidirectional (simplex) SMP queues, abstracting away multiple steps required to establish bi-directional connections and any information about the servers location from the users of the agent protocol.
|
||||
- management of E2E encryption between SMP agents, generating ephemeral asymmetric keys for each connection.
|
||||
- SMP command authentication on SMP routers, generating ephemeral keys for each SMP queue.
|
||||
- TCP/TLS transport handshake with SMP routers.
|
||||
- SMP command authentication on SMP servers, generating ephemeral keys for each SMP queue.
|
||||
- TCP/TLS transport handshake with SMP servers.
|
||||
- validation of message integrity.
|
||||
|
||||
SMP agent API provides no security between the agent and the client - it is assumed that the agent is executed in the trusted and secure environment, via the agent library, when the agent logic is included directly into the client application - [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) uses this approach.
|
||||
|
||||
This document describes SMP agent protocol version 7. The version history:
|
||||
|
||||
- v1: initial version
|
||||
- v2: duplex handshake - allows including reply queue(s) in the initial confirmation
|
||||
- v3: ratchet sync - supports re-negotiating double ratchet encryption
|
||||
- v4: delivery receipts - supports acknowledging message delivery to the sender
|
||||
- v5: post-quantum - supports post-quantum key exchange in double ratchet (PQDR)
|
||||
- v6: sender auth key - supports sender authentication key in confirmations
|
||||
- v7: ratchet on confirmation - initializes double ratchet during confirmation
|
||||
|
||||
## SMP agent
|
||||
|
||||
SMP agents communicate with each other via SMP routers using [simplex messaging protocol (SMP)](./simplex-messaging.md) according to the API calls used by the client applications. This protocol is a middle layer in SimpleX protocols (above SMP protocol but below any application level protocol) - it is intended to be used by client-side applications that need secure asynchronous bi-directional communication channels ("connections").
|
||||
SMP agents communicate with each other via SMP servers using [simplex messaging protocol (SMP)](./simplex-messaging.md) according to the API calls used by the client applications. This protocol is a middle layer in SimpleX protocols (above SMP protocol but below any application level protocol) - it is intended to be used by client-side applications that need secure asynchronous bi-directional communication channels ("connections").
|
||||
|
||||
The agent must have a persistent storage to manage the states of known connections and of the client-side information of SMP queues that each connection consists of, and also the buffer of the most recent sent and received messages. The number of the messages that should be stored is implementation specific, depending on the error management approach that the agent implements; at the very least the agent must store the hashes and IDs of the last received and sent messages.
|
||||
|
||||
## SMP routers management
|
||||
## SMP servers management
|
||||
|
||||
SMP agent API does not use the addresses of the SMP routers that the agent will use to create and use the connections (excluding the router address in queue URIs used in JOIN command). The list of the routers is a part of the agent configuration and can be dynamically changed by the agent implementation:
|
||||
SMP agent API does not use the addresses of the SMP servers that the agent will use to create and use the connections (excluding the server address in queue URIs used in JOIN command). The list of the servers is a part of the agent configuration and can be dynamically changed by the agent implementation:
|
||||
- by the client applications via any API that is outside of scope of this protocol.
|
||||
- by the agents themselves based on availability and latency of the configured routers.
|
||||
- by the agents themselves based on availability and latency of the configured servers.
|
||||
|
||||
## SMP agent protocol scope
|
||||
|
||||
SMP agent protocol has 2 main parts:
|
||||
|
||||
- the messages that SMP agents exchange with each other in order to:
|
||||
- negotiate establishing unidirectional (simplex) encrypted queues on SMP routers.
|
||||
- negotiate establishing unidirectional (simplex) encrypted queues on SMP servers.
|
||||
- exchange client messages and delivery notifications, providing sequential message IDs and message integrity (by including the hash of the previous message).
|
||||
- re-negotiate messaging queues to use and connection e2e encryption.
|
||||
- the messages that the clients of SMP agents should send out-of-band (as pre-shared "invitation" including queue URIs) to protect [E2E encryption][1] from active attacks ([MITM attacks][2]).
|
||||
@@ -85,40 +67,40 @@ SMP agent protocol has 2 main parts:
|
||||
|
||||

|
||||
|
||||
The procedure of establishing a duplex connection is explained on the example of Alice and Bob creating a bi-directional connection consisting of two unidirectional (simplex) queues, using SMP agents (A and B) to facilitate it, and two different SMP routers (which could be the same router). It is shown on the diagram above and has these steps:
|
||||
The procedure of establishing a duplex connection is explained on the example of Alice and Bob creating a bi-directional connection consisting of two unidirectional (simplex) queues, using SMP agents (A and B) to facilitate it, and two different SMP servers (which could be the same server). It is shown on the diagram above and has these steps:
|
||||
|
||||
1. Alice requests the new connection from the SMP agent A using agent `createConnection` api function.
|
||||
2. Agent A creates an SMP queue on the router (using [SMP protocol](./simplex-messaging.md) `NEW` command) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address).
|
||||
2. Agent A creates an SMP queue on the server (using [SMP protocol](./simplex-messaging.md) `NEW` command) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address).
|
||||
3. Alice sends the [connection link](#connection-link-1-time-invitation-and-contact-address) to Bob via any secure channel (out-of-band message) - as a link or as a QR code.
|
||||
4. Bob uses agent `joinConnection` api function with the connection link as a parameter to agent B to accept the connection.
|
||||
5. Agent B creates Bob's SMP reply queue with SMP router `NEW` command.
|
||||
6. Agent B confirms the connection: sends an "SMP confirmation" with SMP router `SEND` command to the SMP queue specified in the connection link - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, and the connection link to this 2nd queue to Agent A - this connection link SHOULD use "simplex" URI scheme). This message is encrypted using key passed in the connection link (or with the derived shared secret, in which case public key for key derivation should be sent in clear text).
|
||||
7. Alice confirms and continues the connection:
|
||||
- Agent A receives the SMP confirmation containing Bob's key, reply queue and info as SMP router `MSG`.
|
||||
5. Agent B creates Bob's SMP reply queue with SMP server `NEW` command.
|
||||
6. Agent B confirms the connection: sends an "SMP confirmation" with SMP server `SEND` command to the SMP queue specified in the connection link - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, and the connection link to this 2nd queue to Agent A - this connection link SHOULD use "simplex" URI scheme). This message is encrypted using key passed in the connection link (or with the derived shared secret, in which case public key for key derivation should be sent in clear text).
|
||||
6. Alice confirms and continues the connection:
|
||||
- Agent A receives the SMP confirmation containing Bob's key, reply queue and info as SMP server `MSG`.
|
||||
- Agent A notifies Alice sending `CONF` notification with Bob's info.
|
||||
- Alice allows connection to continue with agent `allowConnection` api function.
|
||||
- Agent A secures the queue with SMP router `KEY` command.
|
||||
- Agent A secures the queue with SMP server `KEY` command.
|
||||
- Agent A sends SMP confirmation with ephemeral sender key, ephemeral public encryption key and profile (but without reply queue).
|
||||
8. Agent B confirms the connection:
|
||||
7. Agent B confirms the connection:
|
||||
- receives the confirmation.
|
||||
- sends the notification `INFO` with Alice's information to Bob.
|
||||
- secures SMP queue that it sent to Alice in the first confirmation with SMP `KEY` command .
|
||||
- sends `HELLO` message via SMP `SEND` command. This confirms that the reply queue is secured and also validates that Agent A secured the first SMP queue
|
||||
9. Agent A notifies Alice.
|
||||
8. Agent A notifies Alice.
|
||||
- receives `HELLO` message from Agent B.
|
||||
- sends `HELLO` message to Agent B via SMP `SEND` command.
|
||||
- sends `CON` notification to Alice, confirming that the connection is established.
|
||||
10. Agent B notifies Bob.
|
||||
9. Agent B notifies Bob.
|
||||
- Once Agent B receives `HELLO` from Agent A, it sends to Bob `CON` notification as well.
|
||||
|
||||
At this point the duplex connection between Alice and Bob is established, they can use `SEND` command to send messages. The diagram also shows how the connection status changes for both parties, where the first part is the status of the SMP queue to receive messages, and the second part - the status of the queue to send messages.
|
||||
|
||||
The most communication happens between the agents and routers, from the point of view of Alice and Bob there are 4 steps (not including notifications):
|
||||
The most communication happens between the agents and servers, from the point of view of Alice and Bob there are 4 steps (not including notifications):
|
||||
|
||||
1. Alice requests a new connection with `createConnection` agent API function and receives the connection link.
|
||||
2. Alice passes connection link out-of-band to Bob.
|
||||
3. Bob accepts the connection with `joinConnection` agent API function with the connection link to his agent.
|
||||
4. Alice accepts the connection with `allowConnection` agent API function.
|
||||
4. Alice accepts the connection with `ACPT` agent API function.
|
||||
5. Both parties receive `CON` notification once duplex connection is established.
|
||||
|
||||
Clients SHOULD support establishing duplex connection asynchronously (when parties are intermittently offline) by persisting intermediate states and resuming SMP queue subscriptions.
|
||||
@@ -136,14 +118,14 @@ Faster duplex connection process is possible with the `SKEY` command added in v9
|
||||

|
||||
|
||||
1. Alice requests the new connection from the SMP agent A using agent `createConnection` api function
|
||||
2. Agent A creates an SMP queue on the router (using [SMP protocol](./simplex-messaging.md) `NEW` command with the flag allowing the sender to secure the queue) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address).
|
||||
2. Agent A creates an SMP queue on the server (using [SMP protocol](./simplex-messaging.md) `NEW` command with the flag allowing the sender to secure the queue) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address).
|
||||
3. Alice sends the [connection link](connection-link-1-time-invitation-and-contact-address) to Bob via any secure channel (out-of-band message) - as a link or as a QR code. This link contains the flag that the queue can be secured by the sender.
|
||||
4. Bob uses agent `joinConnection` api function with the connection link as a parameter to agent B to accept the connection.
|
||||
5. Agent B secures Alice's queue with SMP command `SKEY` - this command can be proxied.
|
||||
6. Agent B creates Bob's SMP reply queue with SMP router `NEW` command (with the flag allowing the sender to secure the queue).
|
||||
7. Agent B confirms the connection: sends an "SMP confirmation" with SMP router `SEND` command to the SMP queue specified in the connection link - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, and the connection link to this 2nd queue to Agent A - this connection link SHOULD use "simplex" URI scheme). This message is encrypted using key passed in the connection link (or with the derived shared secret, in which case public key for key derivation should be sent in clear text).
|
||||
6. Agent B creates Bob's SMP reply queue with SMP server `NEW` command (with the flag allowing the sender to secure the queue).
|
||||
7. Agent B confirms the connection: sends an "SMP confirmation" with SMP server `SEND` command to the SMP queue specified in the connection link - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, and the connection link to this 2nd queue to Agent A - this connection link SHOULD use "simplex" URI scheme). This message is encrypted using key passed in the connection link (or with the derived shared secret, in which case public key for key derivation should be sent in clear text).
|
||||
8. Alice confirms the connection:
|
||||
- Agent A receives the SMP confirmation containing Bob's key, reply queue and info as SMP router `MSG`.
|
||||
- Agent A receives the SMP confirmation containing Bob's key, reply queue and info as SMP server `MSG`.
|
||||
- Agent A notifies Alice sending `CONF` notification with Bob's info (that indicates that Agent B already secured the queue).
|
||||
- Alice allows connection to continue with agent `allowConnection` api function.
|
||||
- Agent A secures Bob's queue with SMP command `SKEY`.
|
||||
@@ -158,11 +140,11 @@ Faster duplex connection process is possible with the `SKEY` command added in v9
|
||||
|
||||
SMP agents support creating a special type of connection - a contact address - that allows to connect to multiple network users who can send connection requests by sending 1-time connection links to the message queue.
|
||||
|
||||
This connection address uses a messaging queue on SMP router to receive invitations to connect - see `agentInvitation` message below. Once connection request is accepted, a new connection is created and the address itself is no longer used to send the messages - deleting this address does not disrupt the connections that were created via it.
|
||||
This connection address uses a messaging queue on SMP server to receive invitations to connect - see `agentInvitation` message below. Once connection request is accepted, a new connection is created and the address itself is no longer used to send the messages - deleting this address does not disrupt the connections that were created via it.
|
||||
|
||||
## Communication between SMP agents
|
||||
|
||||
To establish duplex connections and to send messages on behalf of their clients, SMP agents communicate via SMP routers.
|
||||
To establish duplex connections and to send messages on behalf of their clients, SMP agents communicate via SMP servers.
|
||||
|
||||
Agents use SMP message client body (the part of the SMP message after header - see [SMP protocol](./simplex-messaging.md)) to transmit agent client messages and exchange messages between each other.
|
||||
|
||||
@@ -170,13 +152,13 @@ These messages are encrypted with per-queue shared secret using NaCL crypto_box
|
||||
- `agentConfirmation` - used when confirming SMP queues, contains connection information encrypted with double ratchet. This envelope can only contain `agentConnInfo` or `agentConnInfoReply` encrypted with double ratchet.
|
||||
- `agentMsgEnvelope` - contains different agent messages encrypted with double ratchet, as defined in `agentMessage`.
|
||||
- `agentInvitation` - sent to SMP queue that is used as contact address, does not use double ratchet.
|
||||
- `agentRatchetKey` - used to re-negotiate double ratchet encryption - can contain additional information in `agentRatchetInfo`.
|
||||
- `agentRatchetKey` - used to re-negotiate double ratchet encryption - can contain additional information in `agentRatchetKey`.
|
||||
|
||||
```abnf
|
||||
decryptedSMPClientMessage = agentConfirmation / agentMsgEnvelope / agentInvitation / agentRatchetKey
|
||||
agentConfirmation = agentVersion %s"C" ("0" / "1" sndE2EEncryptionParams) encConnInfo
|
||||
agentVersion = 2*2 OCTET
|
||||
sndE2EEncryptionParams = <sender E2E ratchet parameters, see pqdr.md>
|
||||
sndE2EEncryptionParams = TODO
|
||||
encConnInfo = doubleRatchetEncryptedMessage
|
||||
|
||||
agentMsgEnvelope = agentVersion %s"M" encAgentMessage
|
||||
@@ -184,25 +166,13 @@ encAgentMessage = doubleRatchetEncryptedMessage
|
||||
|
||||
agentInvitation = agentVersion %s"I" connReqLength connReq connInfo
|
||||
connReqLength = 2*2 OCTET ; Word16
|
||||
connReq = *OCTET ; URI text encoding of connection link, length given by connReqLength
|
||||
connInfo = *OCTET ; opaque connection information (remaining bytes)
|
||||
|
||||
agentRatchetKey = agentVersion %s"R" rcvE2EEncryptionParams ratchetKeyInfo
|
||||
rcvE2EEncryptionParams = <receiver E2E ratchet parameters, see pqdr.md>
|
||||
ratchetKeyInfo = *OCTET ; additional ratchet renegotiation info (remaining bytes)
|
||||
agentRatchetKey = agentVersion %s"R" rcvE2EEncryptionParams agentRatchetInfo
|
||||
rcvE2EEncryptionParams = TODO
|
||||
|
||||
doubleRatchetEncryptedMessage = <double ratchet encrypted message, see pqdr.md>
|
||||
doubleRatchetEncryptedMessage = TODO
|
||||
```
|
||||
|
||||
The maximum size of the encrypted connection info and agent message depend on whether post-quantum key exchange is used:
|
||||
|
||||
| Constant | PQ on | PQ off |
|
||||
|----------|-------|--------|
|
||||
| `e2eEncConnInfoLength` | 11106 | 14832 |
|
||||
| `e2eEncAgentMsgLength` | 13618 | 15840 |
|
||||
|
||||
The PQ-on sizes are smaller because the ratchet header and reply link include larger PQ keys (SNTRUP761).
|
||||
|
||||
This syntax of decrypted SMP client message body is defined by `decryptedAgentMessage` below.
|
||||
|
||||
Decrypted SMP message client body can be one of 4 types:
|
||||
@@ -212,15 +182,14 @@ Decrypted SMP message client body can be one of 4 types:
|
||||
- `agentMessage` - all other agent messages.
|
||||
|
||||
`agentMessage` contains these parts:
|
||||
- `agentMsgHeader` - agent message header that contains sequential agent message ID for a particular SMP queue and the hash of the previous message.
|
||||
- `agentMsgHeader` - agent message header that contains sequential agent message ID for a particular SMP queue, agent timestamp (ISO8601) and the hash of the previous message.
|
||||
- `aMessage` - a command/message to the other SMP agent:
|
||||
- to confirm the connection (`HELLO`).
|
||||
- to send and to confirm reception of user messages (`A_MSG`, `A_RCVD`).
|
||||
- to confirm that the new double ratchet encryption is agreed (`EREADY`).
|
||||
- to notify another party that it can continue sending messages after queue capacity was exceeded (`A_QCONT`).
|
||||
- to manage SMP queue rotation (`QADD`, `QKEY`, `QUSE`, `QTEST`).
|
||||
|
||||
The encoded `agentMessage` is padded to a fixed size by the double ratchet encryption layer (see [ratchet message wire format](./pqdr.md#ratchet-message-wire-format)) to make all SMP messages have constant size, preventing routers from observing the actual message size.
|
||||
- `msgPadding` - an optional message padding to make all SMP messages have constant size, to prevent servers from observing the actual message size. The only case the message padding can be absent is when the message has exactly the maximum size, in all other cases the message MUST be padded to a fixed size.
|
||||
|
||||
### Messages between SMP agents
|
||||
|
||||
@@ -231,11 +200,9 @@ decryptedAgentMessage = agentConnInfo / agentConnInfoReply / agentRatchetInfo /
|
||||
agentConnInfo = %s"I" connInfo
|
||||
connInfo = *OCTET
|
||||
agentConnInfoReply = %s"D" smpQueues connInfo
|
||||
smpQueues = length 1*newQueueInfo ; NonEmpty list of reply queues
|
||||
agentRatchetInfo = %s"R" ratchetInfo
|
||||
ratchetInfo = *OCTET
|
||||
|
||||
agentMessage = %s"M" agentMsgHeader aMessage
|
||||
agentMessage = %s"M" agentMsgHeader aMessage msgPadding
|
||||
agentMsgHeader = agentMsgId prevMsgHash
|
||||
agentMsgId = 8*8 OCTET ; Int64
|
||||
prevMsgHash = shortString
|
||||
@@ -246,13 +213,10 @@ aMessage = HELLO / A_MSG / A_RCVD / EREADY / A_QCONT /
|
||||
HELLO = %s"H"
|
||||
|
||||
A_MSG = %s"M" userMsgBody
|
||||
userMsgBody = *OCTET ; remaining bytes
|
||||
userMsgBody = *OCTET
|
||||
|
||||
A_RCVD = %s"V" msgReceipts
|
||||
msgReceipts = length 1*msgReceipt ; NonEmpty list
|
||||
A_RCVD = %s"V" msgReceipt
|
||||
msgReceipt = agentMsgId msgHash rcptLength rcptInfo
|
||||
msgHash = shortString
|
||||
rcptInfo = *OCTET ; opaque receipt info, length given by rcptLength (Word16)
|
||||
|
||||
EREADY = %s"E" agentMsgId
|
||||
|
||||
@@ -260,14 +224,14 @@ A_QCONT = %s"QC" sndQueueAddr
|
||||
|
||||
QADD = %s"QA" sndQueues
|
||||
sndQueues = length 1*(newQueueUri replacedSndQueue)
|
||||
newQueueUri = clientVRange smpRouter senderId dhPublicKey [queueMode]
|
||||
newQueueUri = clientVRange smpServer senderId dhPublicKey [sndSecure]
|
||||
dhPublicKey = length x509encoded
|
||||
queueMode = %s"M" / %s"C" ; M - messaging (sender can secure), C - contact
|
||||
sndSecure = "T"
|
||||
replacedSndQueue = "0" / "1" sndQueueAddr
|
||||
|
||||
QKEY = %s"QK" sndQueueKeys
|
||||
sndQueueKeys = length 1*(newQueueInfo senderKey)
|
||||
newQueueInfo = version smpRouter senderId dhPublicKey [queueMode]
|
||||
newQueueInfo = version smpServer senderId dhPublicKey [sndSecure]
|
||||
senderKey = length x509encoded
|
||||
|
||||
QUSE = %s"QU" sndQueuesReady
|
||||
@@ -277,8 +241,8 @@ primary = %s"T" / %s"F"
|
||||
QTEST = %s"QT" sndQueueAddrs
|
||||
sndQueueAddrs = length 1*sndQueueAddr
|
||||
|
||||
sndQueueAddr = smpRouter senderId
|
||||
smpRouter = hosts port keyHash
|
||||
sndQueueAddr = smpServer senderId
|
||||
smpServer = hosts port keyHash
|
||||
hosts = length 1*host
|
||||
host = shortString
|
||||
port = shortString
|
||||
@@ -288,6 +252,7 @@ senderId = shortString
|
||||
clientVRange = version version
|
||||
version = 2*2 OCTET
|
||||
|
||||
msgPadding = *OCTET
|
||||
rcptLength = 2*2 OCTET
|
||||
shortString = length *OCTET
|
||||
length = 1*1 OCTET
|
||||
@@ -301,11 +266,11 @@ This message is not used with [fast duplex connection](#fast-duplex-connection-p
|
||||
|
||||
#### A_MSG message
|
||||
|
||||
This is the agent envelope used to send client messages once the connection is established. This is different from the MSG sent by SMP router to the agent and MSG event from SMP agent to the client that are sent in different contexts.
|
||||
This is the agent envelope used to send client messages once the connection is established. This is different from the MSG sent by SMP server to the agent and MSG event from SMP agent to the client that are sent in different contexts.
|
||||
|
||||
#### A_RCVD message
|
||||
|
||||
This message is sent to confirm the client message reception. It includes a list of message receipts, each containing the received message number, message hash and receipt info.
|
||||
This message is sent to confirm the client message reception. It includes received message number and message hash.
|
||||
|
||||
#### EREADY message
|
||||
|
||||
@@ -317,7 +282,7 @@ This message is sent to notify the sender client that it can continue sending th
|
||||
|
||||
### Rotating messaging queue
|
||||
|
||||
SMP agents SHOULD support 4 messages to rotate message reception to another messaging router:
|
||||
SMP agents SHOULD support 4 messages to rotate message reception to another messaging server:
|
||||
`QADD`: add the new queue address(es) to the connection - sent by the client that initiates rotation.
|
||||
`QKEY`: pass sender's key via existing connection (SMP confirmation message will not be used, to avoid the same "race" of the initial key exchange that would create the risk of intercepting the queue for the attacker) - sent by the client accepting the rotation
|
||||
`QUSE`: instruct the sender to use the new queue with sender's queue ID as parameter. From this point some messages can be sent to both the new queue and the old queue.
|
||||
@@ -380,191 +345,31 @@ To summarize, the upgrade to DH+KEM secret happens in a sent message that has PQ
|
||||
|
||||
Connection links are generated by SMP agent in response to `createConnection` api call, used by another party user with `joinConnection` api, and then another connection link is sent by the agent in `agentConnInfoReply` and used by the first party agent to connect to the reply queue (the second part of the process is invisible to the users).
|
||||
|
||||
### Full connection link syntax
|
||||
Connection link syntax:
|
||||
|
||||
```
|
||||
connectionLink = connectionScheme "/" connLinkType "#/?v=" versionRange "&smp=" smpQueues ["&e2e=" e2eEncryption] ["&data=" clientData]
|
||||
connectionLink = connectionScheme "/" connLinkType "#/?smp=" smpQueues "&e2e=" e2eEncryption
|
||||
connLinkType = %s"invitation" / %s"contact"
|
||||
connectionScheme = (%s"https://" clientAppServer) / %s"simplex:"
|
||||
connectionScheme = (%s"https://" clientAppServer) | %s"simplex:"
|
||||
clientAppServer = hostname [ ":" port ]
|
||||
; client app server, e.g. simplex.chat
|
||||
versionRange = 1*DIGIT / 1*DIGIT "-" 1*DIGIT ; agent version range
|
||||
e2eEncryption = <e2e encryption parameters for double ratchet>
|
||||
smpQueues = smpQueue *(";" smpQueue) ; SMP queues for the connection (semicolon-separated)
|
||||
e2eEncryption = encryptionScheme ":" publicKey
|
||||
encryptionScheme = %s"rsa" ; end-to-end encryption and key exchange protocols,
|
||||
; the current hybrid encryption scheme (RSA-OAEP/AES-256-GCM-SHA256)
|
||||
; will be replaced with double ratchet protocol and DH key exchange.
|
||||
publicKey = <base64url X509 SPKI key encoding>
|
||||
smpQueues = smpQueue [ "," 1*smpQueue ] ; SMP queues for the connection
|
||||
smpQueue = <URL-encoded queueURI defined in SMP protocol>
|
||||
clientData = <URL-encoded application-specific data>
|
||||
```
|
||||
|
||||
All parameters are passed via URI hash to avoid sending them to the router (in case "https" scheme is used) - they can be used by the client-side code and processed by the client application. Parameters can be present in any order, any unknown additional parameters SHOULD be ignored.
|
||||
All parameters are passed via URI hash to avoid sending them to the server (in case "https" scheme is used) - they can be used by the client-side code and processed by the client application. Parameters `smp` and `e2e` can be present in any order, any unknown additional parameters SHOULD be ignored.
|
||||
|
||||
`clientAppServer` is not an SMP router - it is a server that shows the instruction on how to download the client app that will connect using this connection link. This server can also host a mobile or desktop app manifest so that this link is opened directly in the app if it is installed on the device.
|
||||
`clientAppServer` is not an SMP server - it is a server that shows the instruction on how to download the client app that will connect using this connection link. This server can also host a mobile or desktop app manifest so that this link is opened directly in the app if it is installed on the device.
|
||||
|
||||
"simplex" URI scheme in `connectionProtocol` can be used instead of client app router, to connect without creating any web traffic. Client apps MUST support this URI scheme.
|
||||
"simplex" URI scheme in `connectionProtocol` can be used instead of client app server, to connect without creating any web traffic. Client apps MUST support this URI scheme.
|
||||
|
||||
See SMP protocol [out-of-band messages](./simplex-messaging.md#out-of-band-messages) for syntax of `queueURI`.
|
||||
|
||||
### Short connection link syntax
|
||||
|
||||
Short links provide a more compact representation by storing connection data on the router:
|
||||
|
||||
```
|
||||
shortLink = shortLinkScheme "/" linkType "#" [linkId "/"] linkKey ["?" shortLinkParams]
|
||||
shortLinkScheme = %s"simplex:" / (%s"https://" serverHost)
|
||||
linkType = %s"i" / contactType ; i - invitation, or contact type
|
||||
contactType = %s"a" / %s"c" / %s"g" / %s"r" ; a - contact, c - channel, g - group, r - relay
|
||||
linkId = base64url ; only for invitation links
|
||||
linkKey = base64url ; SHA3-256 hash of fixed data, used to decrypt link data
|
||||
shortLinkParams = hostParam ["&" portParam] ["&" keyHashParam]
|
||||
hostParam = %s"h=" hostList
|
||||
hostList = host *("," host)
|
||||
portParam = %s"p=" port
|
||||
keyHashParam = %s"c=" base64url ; router certificate fingerprint
|
||||
```
|
||||
|
||||
Contact types:
|
||||
- `a` (CCTContact) - direct contact connection
|
||||
- `c` (CCTChannel) - channel connection
|
||||
- `g` (CCTGroup) - group connection
|
||||
- `r` (CCTRelay) - relay connection
|
||||
|
||||
Short links can use either the `simplex:` scheme or `https://` with a router hostname. When using the simplex scheme, router information is included in query parameters.
|
||||
|
||||
## Short links
|
||||
|
||||
Short links provide a compact representation of connection links by storing encrypted connection data on the SMP router. The link key in the URI fragment (after `#`) is never sent to the router, ensuring the router cannot decrypt the stored connection data.
|
||||
|
||||
### Link key derivation
|
||||
|
||||
The link key is derived from the fixed link data using SHA3-256 hash function:
|
||||
|
||||
```
|
||||
linkKey = SHA3-256(fixedLinkData)
|
||||
```
|
||||
|
||||
The fixed link data includes:
|
||||
- Agent version range
|
||||
- Root public key (Ed25519) for signing
|
||||
- SMP queue connection request (router, queue IDs, encryption keys)
|
||||
- Optional link entity ID
|
||||
|
||||
For contact links, the link ID and encryption key are derived from the link key using HKDF:
|
||||
|
||||
```
|
||||
(linkId, encryptionKey) = HKDF(info="SimpleXContactLink", key=linkKey, outputLen=56)
|
||||
; linkId = first 24 bytes, encryptionKey = remaining 32 bytes
|
||||
```
|
||||
|
||||
For invitation links, the link ID is stored separately (usually included in the URI), and only the encryption key is derived:
|
||||
|
||||
```
|
||||
encryptionKey = HKDF(info="SimpleXInvLink", key=linkKey, outputLen=32)
|
||||
```
|
||||
|
||||
### Link data encryption
|
||||
|
||||
Link data stored on the router consists of two encrypted parts: fixed data and user data. Both are encrypted using NaCl secret_box (XSalsa20-Poly1305) with the derived encryption key:
|
||||
|
||||
```abnf
|
||||
queueLinkData = encFixedData encUserData
|
||||
encFixedData = largeString ; encrypted padded(signedFixedData, 2008)
|
||||
encUserData = largeString ; encrypted padded(signedUserData, 13784)
|
||||
|
||||
signedFixedData = signature fixedData
|
||||
signedUserData = signature userData
|
||||
signature = length 64*64 OCTET ; Ed25519 signature
|
||||
|
||||
fixedData = agentVersionRange rootKey linkConnReq [linkEntityId]
|
||||
agentVersionRange = version version ; min and max agent protocol version
|
||||
version = 2*2 OCTET
|
||||
rootKey = length x509encoded ; Ed25519 public key
|
||||
linkConnReq = invitationConnReq / contactConnReq ; binary encoding of connection request
|
||||
invitationConnReq = %s"I" connReqData e2eRatchetParams
|
||||
contactConnReq = %s"C" connReqData
|
||||
linkEntityId = shortString
|
||||
userData = invitationLinkData / contactLinkData
|
||||
invitationLinkData = %s"I" agentVersionRange userLinkData
|
||||
contactLinkData = %s"C" agentVersionRange userContactData
|
||||
userLinkData = shortString / (%xFF largeString) ; opaque application data (e.g., user profile)
|
||||
; shortString length byte 0x00-0xFE (max 254 bytes); 0xFF is reserved as largeString sentinel
|
||||
userContactData = direct ownersList relaysList userLinkData
|
||||
direct = %s"T" / %s"F" ; whether direct connection via connReq is allowed
|
||||
ownersList = length *ownerAuth
|
||||
ownerAuth = shortString ; length-prefixed encoding of (ownerId ownerKey authOwnerSig)
|
||||
ownerId = shortString ; application-specific owner ID (e.g., MemberId)
|
||||
ownerKey = length x509encoded ; Ed25519 public key
|
||||
authOwnerSig = length 64*64 OCTET ; Ed25519 signature of (ownerId || ownerKey) by previous owner
|
||||
relaysList = length *connShortLink ; alternative relay short links
|
||||
|
||||
; Binary encoding of connection request (used in linkConnReq)
|
||||
connReqData = agentVersionRange smpQueueUris clientData
|
||||
smpQueueUris = length 1*smpQueueUri
|
||||
clientData = %s"0" / (%s"1" largeString) ; Maybe (Large ByteString)
|
||||
smpQueueUri = smpClientVersionRange smpServer senderId smpDhPublicKey [queueMode]
|
||||
smpClientVersionRange = version version ; min and max SMP client versions
|
||||
smpServer = hosts port serverKeyHash
|
||||
hosts = length 1*host
|
||||
host = shortString ; text-encoded hostname or IP address
|
||||
port = shortString ; text-encoded port number
|
||||
serverKeyHash = shortString ; CA certificate fingerprint
|
||||
senderId = shortString ; queue sender ID
|
||||
smpDhPublicKey = length x509encoded ; X25519 DH public key
|
||||
queueMode = %s"M" / %s"C" ; messaging or contact (version-dependent trailing field)
|
||||
e2eRatchetParams = e2eVersionRange e2eDhKey e2eDhKey kemParams
|
||||
e2eVersionRange = version version ; min and max e2e encryption versions
|
||||
e2eDhKey = length x509encoded ; X448 DH public key
|
||||
kemParams = %s"0" / (%s"1" ratchetKEMParams)
|
||||
ratchetKEMParams = %s"P" kemPublicKey / %s"A" kemCiphertext kemPublicKey
|
||||
kemPublicKey = largeString ; sntrup761 public key
|
||||
kemCiphertext = largeString ; sntrup761 ciphertext
|
||||
|
||||
; Binary encoding of short link (used in relaysList)
|
||||
connShortLink = invShortLink / contactShortLink
|
||||
invShortLink = %s"I" smpServer linkId linkKey
|
||||
contactShortLink = %s"C" contactConnType smpServer linkKey
|
||||
contactConnType = %s"A" / %s"C" / %s"G" / %s"R" ; contact / channel / group / relay
|
||||
linkId = shortString
|
||||
linkKey = shortString
|
||||
|
||||
x509encoded = *OCTET ; DER-encoded X.509 SubjectPublicKeyInfo
|
||||
largeString = 2*2 OCTET *OCTET ; Word16 length prefix
|
||||
length = 1*1 OCTET
|
||||
shortString = length *OCTET
|
||||
```
|
||||
|
||||
The fixed data is signed with the root key and its hash becomes the link key. The user data is signed either with the root key (for invitations) or with an owner key (for contact addresses).
|
||||
|
||||
### Short link resolution
|
||||
|
||||
When a user receives a short link, the agent resolves it as follows:
|
||||
|
||||
1. Extract the link key from the URI fragment
|
||||
2. Send `LGET` command to the SMP router with the link ID
|
||||
3. Receive encrypted link data from the router
|
||||
4. Decrypt the link data using the link key
|
||||
5. Extract the full connection information (SMP queue URI, encryption keys, profile)
|
||||
6. Proceed with the standard connection procedure using `joinConnection`
|
||||
|
||||
For invitation links, the `LKEY` command is used to set the sender key when getting link data. Repeated `LKEY` would require using the same key.
|
||||
|
||||
### Link data management
|
||||
|
||||
The recipient who created the queue can manage the short link data:
|
||||
|
||||
- **LSET** - Set or update the link data associated with a queue. This is used when creating a short link or updating the user data (e.g., profile changes).
|
||||
- **LDEL** - Delete the link data from the router. This effectively invalidates the short link.
|
||||
|
||||
Short links support different connection modes:
|
||||
- **invitation** - One-time invitation links that can only be used once
|
||||
- **contact** - Reusable contact address links that can be used multiple times
|
||||
|
||||
For contact addresses, the link data includes additional information about the contact type:
|
||||
- **contact** - Direct contact connection
|
||||
- **channel** - Channel connection
|
||||
- **group** - Group connection
|
||||
- **relay** - Relay connection
|
||||
|
||||
The agent maintains the link data and updates it when connection parameters change, ensuring short links remain valid and reflect current connection information.
|
||||
|
||||
## Appendix A: SMP agent API
|
||||
|
||||
The exact specification of agent library API and of the events that the agent sends to the client application is out of scope of the protocol specification.
|
||||
@@ -575,7 +380,7 @@ The list of some of the API functions and events below is supported by the refer
|
||||
|
||||
The list of APIs below is not exhaustive and provided for information only. Please consult the source code for more information.
|
||||
|
||||
#### Create connection
|
||||
#### Create conection
|
||||
|
||||
`createConnection` api is used to create a connection - it returns the connection link that should be sent out-of-band to another protocol user (the joining party). It should be used by the client of the agent that initiates creating a duplex connection (the initiating party).
|
||||
|
||||
@@ -603,13 +408,13 @@ Client can `acceptContact` and `rejectContact`, with `OK` and `ERR` events in ca
|
||||
|
||||
#### Send message
|
||||
|
||||
`sendMessage` api is always asynchronous. The api call returns message ID, `SENT` event once the message is sent to the router, `MWARN` event in case of temporary delivery failure that can be resolved by the user (e.g., by connecting via Tor or by upgrading the client) and `MERR` in case of permanent delivery failure.
|
||||
`sendMessage` api is always asynchronous. The api call returns message ID, `SENT` event once the message is sent to the server, `MWARN` event in case of temporary delivery failure that can be resolved by the user (e.g., by connecting via Tor or by upgrading the client) and `MERR` in case of permanent delivery failure.
|
||||
|
||||
#### Acknowledge received message
|
||||
|
||||
Messages are delivered to the client application via `MSG` event.
|
||||
|
||||
Client application must always `ackMessage` to receive the next one - failure to call it in reference implementation will prevent the delivery of subsequent messages until the client reconnects to the router.
|
||||
Client application must always `ackMessage` to receive the next one - failure to call it in reference implementation will prevent the delivery of subsequent messages until the client reconnects to the server.
|
||||
|
||||
This api is also used to acknowledge message delivery to the sending party - that party client application will receive `RCVD` event.
|
||||
|
||||
@@ -621,17 +426,9 @@ This api is also used to acknowledge message delivery to the sending party - tha
|
||||
|
||||
`getNotificationMessage` is used by push notification subsystem of the client application to receive the message from a specific messaging queue mentioned in the notification. The client application would receive `MSG` and any other events from the agent, and then `MSGNTF` event once the message related to this notification is received.
|
||||
|
||||
#### Set short link data
|
||||
#### Rotate message queue to another server
|
||||
|
||||
`setConnectionLink` api (`LSET` command) is used to set or update short link data associated with a contact address queue. Returns `LINK` event with the short link URI.
|
||||
|
||||
#### Get short link data
|
||||
|
||||
`getConnectionLink` api (`LGET` command) is used to retrieve and decrypt the short link data from the router. Returns `LDATA` event with the decrypted link data.
|
||||
|
||||
#### Rotate message queue to another router
|
||||
|
||||
`switchConnection` api is used to rotate connection queues to another messaging router.
|
||||
`switchConnection` api is used to rotate connection queues to another messaging server.
|
||||
|
||||
#### Renegotiate e2e encryption
|
||||
|
||||
@@ -639,7 +436,7 @@ This api is also used to acknowledge message delivery to the sending party - tha
|
||||
|
||||
#### Delete connection
|
||||
|
||||
`deleteConnection` api is used to delete connection. In case of asynchronous call, the connection deletion will be confirmed with `DEL_RCVQS` and `DEL_CONNS` events.
|
||||
`deleteConnection` api is used to delete connection. In case of asynchronous call, the connection deletion will be confirmed with `DEL_RCVQ` and `DEL_CONN` events.
|
||||
|
||||
#### Suspend connection
|
||||
|
||||
@@ -654,80 +451,25 @@ Agent API uses these events dispatch to notify client application about events r
|
||||
- `INFO` - information from the party that initiated the connection with `createConnection` sent to the party accepting the connection with `joinConnection`.
|
||||
- `CON` - notification that connection is established sent to both parties of the connection.
|
||||
- `END` - notification that connection subscription is terminated when another client subscribed to the same messaging queue.
|
||||
- `DOWN` - notification that connection router is temporarily unavailable.
|
||||
- `UP` - notification that the subscriptions made in the current client session are resumed after the router became available.
|
||||
- `DOWN` - notification that connection server is temporarily unavailable.
|
||||
- `UP` - notification that the subscriptions made in the current client session are resumed after the server became available.
|
||||
- `SWITCH` - notification about queue rotation process.
|
||||
- `RSYNC` - notification about e2e encryption re-negotiation process.
|
||||
- `SENT` - notification to confirm that the message was delivered to at least one of SMP routers. This notification contains the same message ID as returned to `sendMessage` api. `SENT` notification, depending on network availability, can be sent at any time later, potentially in the next client session.
|
||||
- `SENT` - notification to confirm that the message was delivered to at least one of SMP servers. This notification contains the same message ID as returned to `sendMessage` api. `SENT` notification, depending on network availability, can be sent at any time later, potentially in the next client session.
|
||||
- `MWARN` - temporary delivery failure that can be resolved by the user (e.g., by connecting via Tor or by upgrading the client).
|
||||
- `MERR` - notification about permanent message delivery failure.
|
||||
- `MERRS` - notification about permanent message delivery failure for multiple messages (e.g., when multiple messages expire).
|
||||
- `MSG` - sent when agent receives the message from the SMP router.
|
||||
- `MSG` - sent when agent receives the message from the SMP server.
|
||||
- `MSGNTF` - sent after agent received and processed the message referenced in the push notification.
|
||||
- `RCVD` - notification confirming message receipt by another party.
|
||||
- `QCONT` - notification that the agent continued sending messages after queue capacity was exceeded and recipient received all messages.
|
||||
- `LINK` - short link URI created or updated for a contact address.
|
||||
- `LDATA` - decrypted short link data received from the router.
|
||||
- `DELD` - notification that the connection was deleted.
|
||||
- `JOINED` - notification that a member joined via a contact address.
|
||||
- `STAT` - connection statistics event.
|
||||
- `DEL_RCVQS` - confirmation that receiver message queues were deleted.
|
||||
- `DEL_CONNS` - confirmation that connections were deleted.
|
||||
- `DEL_RCVQ` - confirmation that message queue was deleted.
|
||||
- `DEL_CONN` - confirmation that connection was deleted.
|
||||
- `OK` - confirmation that asynchronous api call was successful.
|
||||
- `ERR` - error of asynchronous api call or some other error event.
|
||||
|
||||
This list of events is not exhaustive and provided for information only. Please consult the source code for more information.
|
||||
|
||||
## Threat model
|
||||
|
||||
This threat model complements SimpleX Messaging Protocol [threat model](./security.md#threat-model) with agent-level concerns: duplex connections, end-to-end encryption with [post-quantum double ratchet](./pqdr.md), message integrity, connection establishment and queue rotation. Only additional properties not covered in the SMP threat model are listed below.
|
||||
|
||||
#### Additional global assumptions
|
||||
|
||||
- The connection link is shared via a trusted out-of-band channel.
|
||||
- Both agents support post-quantum double ratchet (PQDR).
|
||||
|
||||
#### A passive adversary
|
||||
|
||||
*cannot:*
|
||||
- learn the contents of packets, which are additionally encrypted with the double ratchet independently from per-queue encryption.
|
||||
|
||||
#### Destination router (chosen by the receiving client application)
|
||||
|
||||
*can:*
|
||||
- correlate queues belonging to the same duplex connection when queue rotation creates a new queue on the same router.
|
||||
- when both peers of a connection chose the same router, correlate the two directions of the duplex connection.
|
||||
|
||||
*cannot:*
|
||||
- compromise end-to-end encryption even with full access to the per-queue NaCl DH secret.
|
||||
- correlate queues belonging to the same connection after queue rotation to a different router.
|
||||
|
||||
#### An attacker who obtained a client application's (decrypted) database
|
||||
|
||||
*can:*
|
||||
- learn the full communication graph: all communication peers, associated router addresses, and queue identifiers.
|
||||
|
||||
*cannot:*
|
||||
- decrypt future messages once the client application resumes communication and the double ratchet completes a new ratchet step, provided PQDR is active.
|
||||
|
||||
#### A communication peer
|
||||
|
||||
*can:*
|
||||
- send malformed agent messages that may affect the client application processing them.
|
||||
- skip message IDs, causing the recipient to generate and store excessive intermediate ratchet keys.
|
||||
- prevent double ratchet advancement by not sending messages, delaying break-in recovery.
|
||||
|
||||
*cannot:*
|
||||
- disrupt packet delivery in other queues.
|
||||
|
||||
#### An attacker who obtained a connection link
|
||||
|
||||
*can:*
|
||||
- learn the initiating party's chosen router address and public keys.
|
||||
|
||||
*cannot:*
|
||||
- use the link after the intended recipient has completed the connection.
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/End-to-end_encryption
|
||||
[2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack
|
||||
[3]: https://tools.ietf.org/html/rfc5234
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Revision 4, 2026-03-09
|
||||
Revision 2, 2024-06-22
|
||||
|
||||
Evgeny Poberezkin
|
||||
|
||||
@@ -8,17 +8,16 @@ Evgeny Poberezkin
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [What is SimpleX](#what-is-simplex)
|
||||
- [Network model](#network-model)
|
||||
- [Applications](#applications)
|
||||
- [SimpleX objectives](#simplex-objectives)
|
||||
- [In Comparison](#in-comparison)
|
||||
- [Technical Details](#technical-details)
|
||||
- [Trust in Routers](#trust-in-routers)
|
||||
- [Client -> Router Communication](#client---router-communication)
|
||||
- [Trust in Servers](#trust-in-servers)
|
||||
- [Client -> Server Communication](#client---server-communication)
|
||||
- [2-hop Onion Message Routing](#2-hop-onion-message-routing)
|
||||
- [SimpleX Messaging Protocol](#simplex-messaging-protocol)
|
||||
- [SimpleX Agents](#simplex-agents)
|
||||
- [Security](#security)
|
||||
- [Encryption Primitives Used](#encryption-primitives-used)
|
||||
- [Threat model](#threat-model)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
|
||||
|
||||
@@ -28,27 +27,27 @@ Evgeny Poberezkin
|
||||
|
||||
SimpleX as a whole is a platform upon which applications can be built. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is one such application that also serves as an example and reference application.
|
||||
|
||||
- [SimpleX Messaging Protocol](./simplex-messaging.md) (SMP) is a protocol to send messages in one direction to a recipient, relying on a router in-between. The messages are delivered via uni-directional queues created by recipients.
|
||||
|
||||
- SMP protocol allows to send message via a SMP router playing proxy role using 2-hop onion routing (referred to as "private routing" in messaging clients) to protect transport information of the sender (IP address and session) from the router chosen (and possibly controlled) by the recipient.
|
||||
- [SimpleX Messaging Protocol](./simplex-messaging.md) (SMP) is a protocol to send messages in one direction to a recipient, relying on a server in-between. The messages are delivered via uni-directional queues created by recipients.
|
||||
|
||||
- SMP protocol allows to send message via a SMP server playing proxy role using 2-hop onion routing (referred to as "private routing" in messaging clients) to protect transport information of the sender (IP address and session) from the server chosen (and possibly controlled) by the recipient.
|
||||
|
||||
- SMP runs over a transport protocol (shown below as TLS) that provides integrity, server authentication, confidentiality, and transport channel binding.
|
||||
|
||||
- A SimpleX router is one of those routers.
|
||||
- A SimpleX Server is one of those servers.
|
||||
|
||||
- The SimpleX Network is the term used for the collective of SimpleX routers that facilitate SMP.
|
||||
- The SimpleX Network is the term used for the collective of SimpleX Servers that facilitate SMP.
|
||||
|
||||
- SimpleX Client libraries speak SMP to SimpleX routers and provide a low-level API not generally intended to be used by applications.
|
||||
- SimpleX Client libraries speak SMP to SimpleX Servers and provide a low-level API not generally intended to be used by applications.
|
||||
|
||||
- SimpleX Agents interface with SimpleX Clients to provide a more high-level API intended to be used by applications. Typically they are embedded as libraries, but can also be abstracted into local services.
|
||||
|
||||
- SimpleX Agents communicate with other agents inside e2e encrypted envelopes provided by SMP protocol - the syntax and semantics of the messages exchanged by the agent are defined by [SMP agent protocol](./agent-protocol.md)
|
||||
|
||||
|
||||
*Diagram showing the SimpleX Chat app, with logical layers of the chat application interfacing with a SimpleX Agent library, which in turn interfaces with a SimpleX Client library. The Client library in turn speaks the Messaging Protocol to a SimpleX router.*
|
||||
*Diagram showing the SimpleX Chat app, with logical layers of the chat application interfacing with a SimpleX Agent library, which in turn interfaces with a SimpleX Client library. The Client library in turn speaks the Messaging Protocol to a SimpleX Server.*
|
||||
|
||||
```
|
||||
User's Computer Internet Third-Party Router
|
||||
User's Computer Internet Third-Party Server
|
||||
------------------ | ---------------------- | -------------------------
|
||||
| |
|
||||
SimpleX Chat | |
|
||||
@@ -58,43 +57,11 @@ SimpleX as a whole is a platform upon which applications can be built. [SimpleX
|
||||
+----------------+ | |
|
||||
| SimpleX Agent | | |
|
||||
+----------------+ -------------- TLS ---------------- +----------------+
|
||||
| SimpleX Client | ------ SimpleX Messaging Protocol ------> | SimpleX router |
|
||||
| SimpleX Client | ------ SimpleX Messaging Protocol ------> | SimpleX Server |
|
||||
+----------------+ ----------------------------------- +----------------+
|
||||
| |
|
||||
```
|
||||
|
||||
#### Network model
|
||||
|
||||
SimpleX is a general-purpose packet routing network built on top of the Internet. Network endpoints — end-user devices, automated services, AI-enabled applications, IoT devices — exchange data packets through SimpleX network nodes (SMP routers), which accept, buffer, and deliver packets. Each router operates independently and can be operated by any party on standard computing hardware.
|
||||
|
||||
SimpleX routers use resource-based addressing: each address identifies a resource on a router, similar to how the World Wide Web addresses resources via URLs. Internet routers, by comparison, use endpoint-based addressing, where IP addresses identify destination devices. Because of this design, SimpleX network participants do not need globally unique addresses to communicate.
|
||||
|
||||
SimpleX network has two resource-based addressing schemes:
|
||||
|
||||
- *Messaging queues* ([SMP](./simplex-messaging.md)). A queue is a unidirectional, ordered sequence of fixed-size data packets (16,384 bytes each). Each queue has a resource address on a specific router, gated by cryptographic credentials that separately authorize sending and receiving.
|
||||
|
||||
- *Data packets* ([XFTP](./xftp.md)). A data packet is an individually addressed block in one of the standard sizes. Each packet has a unique resource address on a specific router, gated by cryptographic credentials. Data packet addressing is more efficient for delivery of larger payloads than queues.
|
||||
|
||||
Packet delivery follows a two-router path. The sending endpoint submits a packet to a first router, which forwards it to a second router, where the receiving endpoint retrieves it. The sending endpoint's IP address is known only to the first router; the receiving endpoint's IP address is known only to the second router. See [2-hop Onion Message Routing](#2-hop-onion-message-routing) for details.
|
||||
|
||||
Routers buffer packets between submission and retrieval — from seconds to days, enabling asynchronous delivery when endpoints are online at different times. Packets are removed after delivery or after a configured expiration period.
|
||||
|
||||
|
||||
#### Applications
|
||||
|
||||
Applications currently using SimpleX network:
|
||||
|
||||
- **SimpleX Chat** — a peer-to-peer messenger using SimpleX network as a transport layer, in the same way that communication applications use WebRTC, Tor, i2p, or Nym. All communication logic — contacts, conversations, groups, message formats, end-to-end encryption — runs on endpoint devices.
|
||||
|
||||
- **IoT devices** — using the SimpleX queue protocol directly for sensor data collection and device control.
|
||||
|
||||
- **AI-based services** — automated services built on the SimpleX Chat application core.
|
||||
|
||||
- **Secure monitoring and control systems** — applications for equipment monitoring and control, including robotics, using the network for command delivery and telemetry collection.
|
||||
|
||||
[SimpleGo](https://simplego.dev), developed by an independent organization, is a microcontroller-based device running a SimpleX Chat-compatible messenger directly on a microcontroller without a general-purpose operating system. Running over 20 days on a single battery charge, it demonstrates the energy efficiency of resource-based addressing: the device receives packets without continuous polling. A microcontroller-based router implementation that functions simultaneously as a WiFi router is also in development.
|
||||
|
||||
|
||||
#### SimpleX objectives
|
||||
|
||||
1. Provide messaging infrastructure for distributed applications. This infrastructure needs to have the following qualities:
|
||||
@@ -103,7 +70,7 @@ Applications currently using SimpleX network:
|
||||
|
||||
- Privacy: protect against traffic correlation attacks to determine the contacts that the users communicate with.
|
||||
|
||||
- Reliability: the messages should be delivered even if some participating network routers or receiving clients fail, with "at least once" delivery guarantee.
|
||||
- Reliability: the messages should be delivered even if some participating network servers or receiving clients fail, with “at least once” delivery guarantee.
|
||||
|
||||
- Integrity: the messages sent in one direction are ordered in a way that sender and recipient agree on; the recipient can detect when a message was removed or changed.
|
||||
|
||||
@@ -111,63 +78,63 @@ Applications currently using SimpleX network:
|
||||
|
||||
- Low latency: the delay introduced by the network should not be higher than 100ms-1s in addition to the underlying TCP network latency.
|
||||
|
||||
2. Provide better communication security and privacy than the alternative instant messaging solutions. In particular SimpleX provides better privacy of metadata (who talks to whom and when) and better security against active network attackers and malicious routers.
|
||||
2. Provide better communication security and privacy than the alternative instant messaging solutions. In particular SimpleX provides better privacy of metadata (who talks to whom and when) and better security against active network attackers and malicious servers.
|
||||
|
||||
3. Balance user experience with privacy requirements, prioritizing experience of mobile device users.
|
||||
|
||||
|
||||
#### In Comparison
|
||||
|
||||
SimpleX network has a design similar to P2P networks, but unlike most P2P networks it consists of clients and routers without depending on any centralized component.
|
||||
SimpleX network has a design similar to P2P networks, but unlike most P2P networks it consists of clients and servers without depending on any centralized component.
|
||||
In comparison to more traditional messaging applications (e.g. WhatsApp, Signal, Telegram) the key differences of SimpleX network are:
|
||||
|
||||
- participants do not need to have globally unique addresses to communicate, instead they use redundant unidirectional (simplex) messaging queues, with a separate set of queues for each contact.
|
||||
|
||||
- connection requests are passed out-of-band, non-optionally protecting key exchange against man-in-the-middle attack.
|
||||
|
||||
- simple message queues provided by network routers are used by the clients to create more complex communication scenarios, such as duplex one-to-one communication, transmitting files, group communication without central routers, and content/communication channels.
|
||||
- simple message queues provided by network servers are used by the clients to create more complex communication scenarios, such as duplex one-to-one communication, transmitting files, group communication without central servers, and content/communication channels.
|
||||
|
||||
- routers do not store any user information (no user profiles or contacts, or messages once they are delivered), and primarily use in-memory persistence.
|
||||
- servers do not store any user information (no user profiles or contacts, or messages once they are delivered), and primarily use in-memory persistence.
|
||||
|
||||
- users can change routers with minimal disruption - even after an in-use router disappears, simply by changing the configuration on which routers the new queues are created.
|
||||
- users can change servers with minimal disruption - even after an in-use server disappears, simply by changing the configuration on which servers the new queues are created.
|
||||
|
||||
|
||||
## Technical Details
|
||||
|
||||
#### Trust in Routers
|
||||
#### Trust in Servers
|
||||
|
||||
Clients communicate directly with routers (but not with other clients) using SimpleX Messaging Protocol (SMP) running over some transport protocol that provides integrity, server authentication, confidentiality, and transport channel binding. By default, we assume this transport protocol is TLS.
|
||||
Clients communicate directly with servers (but not with other clients) using SimpleX Messaging Protocol (SMP) running over some transport protocol that provides integrity, server authentication, confidentiality, and transport channel binding. By default, we assume this transport protocol is TLS.
|
||||
|
||||
Users use multiple routers, and choose where to receive their messages. Accordingly, they send messages to their communication partners' chosen routers either directly, if this is a known/trusted router, or via another SMP router providing proxy functionality to protect IP address and session of the sender.
|
||||
Users use multiple servers, and choose where to receive their messages. Accordingly, they send messages to their communication partners' chosen servers either directly, if this is a known/trusted server, or via another SMP server providing proxy functionality to protect IP address and session of the sender.
|
||||
|
||||
Although end-to-end encryption is always present, users place a degree of trust in routers they connect to. This trust decision is very similar to a user's choice of email provider; however the trust placed in a SimpleX router is significantly less. Notably, there is no re-used identifier or credential between queues on the same (or different) routers. While a user *may* re-use a transport connection to fetch messages from multiple queues, or connect to a router from the same IP address, both are choices a user may opt into to break the promise of un-correlatable queues.
|
||||
Although end-to-end encryption is always present, users place a degree of trust in servers they connect to. This trust decision is very similar to a user's choice of email provider; however the trust placed in a SimpleX server is significantly less. Notably, there is no re-used identifier or credential between queues on the same (or different) servers. While a user *may* re-use a transport connection to fetch messages from multiple queues, or connect to a server from the same IP address, both are choices a user may opt into to break the promise of un-correlatable queues.
|
||||
|
||||
Users may trust a router because:
|
||||
Users may trust a server because:
|
||||
|
||||
- They deploy and control the routers themselves from the available open-source code. This has the trade-offs of strong trust in the router but limited metadata obfuscation to a passive network observer. Techniques such as noise traffic, traffic mixing (incurring latency), and using an onion routing transport protocol can mitigate that.
|
||||
- They deploy and control the servers themselves from the available open-source code. This has the trade-offs of strong trust in the server but limited metadata obfuscation to a passive network observer. Techniques such as noise traffic, traffic mixing (incurring latency), and using an onion routing transport protocol can mitigate that.
|
||||
|
||||
- They use routers from a trusted commercial provider. The more clients the provider has, the less metadata about the communication times is leaked to the network observers.
|
||||
- They use servers from a trusted commercial provider. The more clients the provider has, the less metadata about the communication times is leaked to the network observers.
|
||||
|
||||
By default, routers do not retain access logs, and permanently delete messages and queues when requested. Messages persist in memory or in a database until they cross a threshold of time, typically on the order of days.[0] There is still a risk that a router maliciously records all queues and messages (even though encrypted) sent via the same transport connection to gain a partial knowledge of the user's communications graph and other meta-data.
|
||||
By default, servers do not retain access logs, and permanently delete messages and queues when requested. Messages persist only in memory until they cross a threshold of time, typically on the order of days.[0] There is still a risk that a server maliciously records all queues and messages (even though encrypted) sent via the same transport connection to gain a partial knowledge of the user’s communications graph and other meta-data.
|
||||
|
||||
SimpleX supports measures (managed transparently to the user at the agent level) to mitigate the trust placed in routers. These include rotating the queues in use between users, noise traffic, supporting overlay networks such as Tor, and isolating traffic to different queues to different transport connections (and Tor circuits, if Tor is used).
|
||||
SimpleX supports measures (managed transparently to the user at the agent level) to mitigate the trust placed in servers. These include rotating the queues in use between users, noise traffic, supporting overlay networks such as Tor, and isolating traffic to different queues to different transport connections (and Tor circuits, if Tor is used).
|
||||
|
||||
[0] While configurable by routers, a minimum value is enforced by the default software. SimpleX Agents can provide redundant routing over queues to mitigate against message loss.
|
||||
[0] While configurable by servers, a minimum value is enforced by the default software. SimpleX Agents can provide redundant routing over queues to mitigate against message loss.
|
||||
|
||||
|
||||
#### Client -> Router Communication
|
||||
#### Client -> Server Communication
|
||||
|
||||
Utilizing TLS grants the SimpleX Messaging Protocol (SMP) server authentication and metadata protection to a passive network observer. But SMP does not rely on the transport protocol for message confidentiality or client authentication. The SMP protocol itself provides end-to-end confidentiality, authentication, and integrity of messages between communicating parties.
|
||||
|
||||
Routers have long-lived, self-signed, offline certificates whose hash is pre-shared with clients over secure channels - either provided with the client library or provided in the secure introduction between clients, as part of the router address. The offline certificate signs an online certificate used in the transport protocol handshake. [0]
|
||||
Servers have long-lived, self-signed, offline certificates whose hash is pre-shared with clients over secure channels - either provided with the client library or provided in the secure introduction between clients, as part of the server address. The offline certificate signs an online certificate used in the transport protocol handshake. [0]
|
||||
|
||||
If the transport protocol's confidentiality is broken, incoming and outgoing messages to the router cannot be correlated by message contents. Additionally, because of encryption at the SMP layer, impersonating the router is not sufficient to pass (and therefore correlate) a message from a sender to recipient - the only attack possible is to drop the messages. Only by additionally *compromising* the router can one pass and correlate messages.
|
||||
If the transport protocol's confidentiality is broken, incoming and outgoing messages to the server cannot be correlated by message contents. Additionally, because of encryption at the SMP layer, impersonating the server is not sufficient to pass (and therefore correlate) a message from a sender to recipient - the only attack possible is to drop the messages. Only by additionally *compromising* the server can one pass and correlate messages.
|
||||
|
||||
It's important to note that the SMP protocol does not do server authentication. Instead we rely upon the fact that an attacker who tricks the transport protocol into authenticating the router incorrectly cannot do anything with the SMP messages except drop them.
|
||||
It's important to note that the SMP protocol does not do server authentication. Instead we rely upon the fact that an attacker who tricks the transport protocol into authenticating the server incorrectly cannot do anything with the SMP messages except drop them.
|
||||
|
||||
After the connection is established, the client sends blocks of a fixed size 16KB, and the router replies with the blocks of the same size to reduce metadata observable to a network adversary. The protocol has been designed to make traffic correlation attacks difficult, adapting ideas from Tor, remailers, and more general onion and mix networks. It does not try to replace Tor though - SimpleX routers can be deployed as onion services and SimpleX clients can communicate with routers over Tor to further improve participants privacy.
|
||||
After the connection is established, the client sends blocks of a fixed size 16KB, and the server replies with the blocks of the same size to reduce metadata observable to a network adversary. The protocol has been designed to make traffic correlation attacks difficult, adapting ideas from Tor, remailers, and more general onion and mix networks. It does not try to replace Tor though - SimpleX servers can be deployed as onion services and SimpleX clients can communicate with servers over Tor to further improve participants privacy.
|
||||
|
||||
By using fixed-size blocks, oversized for the expected content, the vast majority of traffic is uniform in nature. When enough traffic is transiting a router simultaneously, the router acts as a low-latency mix node. We can't rely on this behavior to make a security claim, but we have engineered to take advantage of it when we can. As mentioned, this holds true even if the transport connection is compromised.
|
||||
By using fixed-size blocks, oversized for the expected content, the vast majority of traffic is uniform in nature. When enough traffic is transiting a server simultaneously, the server acts as a low-latency mix node. We can't rely on this behavior to make a security claim, but we have engineered to take advantage of it when we can. As mentioned, this holds true even if the transport connection is compromised.
|
||||
|
||||
The protocol does not protect against attacks targeted at particular users with known identities - e.g., if the attacker wants to prove that two known users are communicating, they can achieve it by observing their local traffic. At the same time, it substantially complicates large-scale traffic correlation, making determining the real user identities much less effective.
|
||||
|
||||
@@ -176,39 +143,39 @@ The protocol does not protect against attacks targeted at particular users with
|
||||
|
||||
#### 2-hop Onion Message Routing
|
||||
|
||||
As SimpleX Messaging Protocol routers providing messaging queues are chosen by the recipients, in case senders connect to these routers directly the router owners (who potentially can be the recipients themselves) can learn senders' IP addresses (if Tor is not used) and which other queues on the same router are accessed by the user in the same transport connection (even if Tor is used).
|
||||
As SimpleX Messaging Protocol servers providing messaging queues are chosen by the recipients, in case senders connect to these servers directly the server owners (who potentially can be the recipients themselves) can learn senders' IP addresses (if Tor is not used) and which other queues on the same server are accessed by the user in the same transport connection (even if Tor is used).
|
||||
|
||||
While the clients support isolating the messages sent to different queues into different transport connections (and Tor circuits), this is not practical, as it consumes additional traffic and system resources.
|
||||
|
||||
To mitigate this problem SimpleX Messaging Protocol routers support 2-hop onion message routing when the SMP router chosen by the sender forwards the messages to the routers chosen by the recipients, thus protecting both the senders IP addresses and sessions, even if connection isolation and Tor are not used.
|
||||
To mitigate this problem SimpleX Messaging Protocol servers support 2-hop onion message routing when the SMP server chosen by the sender forwards the messages to the servers chosen by the recipients, thus protecting both the senders IP addresses and sessions, even if connection isolation and Tor are not used.
|
||||
|
||||
The design of 2-hop onion message routing prevents these potential attacks:
|
||||
|
||||
- MITM by proxy (SMP router that forwards the messages).
|
||||
- MITM by proxy (SMP server that forwards the messages).
|
||||
|
||||
- Identification by the proxy which and how many queues the sender sends messages to (as messages are additionally e2e encrypted between the sender and the destination SMP router).
|
||||
- Identification by the proxy which and how many queues the sender sends messages to (as messages are additionally e2e encrypted between the sender and the destination SMP server).
|
||||
|
||||
- Correlation of messages sent to different queues via the same user session (as random correlation IDs and keys are used for each message).
|
||||
|
||||
See more details about 2-hop onion message routing design in [SimpleX Messaging Protocol](./simplex-messaging.md#proxying-sender-commands)
|
||||
|
||||
Also see [Security](./security.md)
|
||||
Also see [Threat model](#threat-model)
|
||||
|
||||
|
||||
#### SimpleX Messaging Protocol
|
||||
|
||||
SMP is initialized with an in-person or out-of-band introduction message, where Alice provides Bob with details of a router (including IP address or host name, port, and hash of the long-lived offline certificate), a queue ID, and Alice's public keys to agree e2e encryption. These introductions are similar to the PANDA key-exchange, in that if observed, the adversary can race to establish the communication channel instead of the intended participant. [0]
|
||||
SMP is initialized with an in-person or out-of-band introduction message, where Alice provides Bob with details of a server (including IP address or host name, port, and hash of the long-lived offline certificate), a queue ID, and Alice's public keys to agree e2e encryption. These introductions are similar to the PANDA key-exchange, in that if observed, the adversary can race to establish the communication channel instead of the intended participant. [0]
|
||||
|
||||
Because queues are uni-directional, Bob provides an identically-formatted introduction message to Alice over Alice's now-established receiving queue.
|
||||
|
||||
When setting up a queue, the router will create separate sender and recipient queue IDs (provided to Alice during set-up and Bob during initial connection). Additionally, during set-up Alice will perform a DH exchange with the router to agree upon a shared secret. This secret will be used to re-encrypt Bob's incoming message before Alice receives it, creating the anti-correlation property earlier-described should the transport encryption be compromised.
|
||||
When setting up a queue, the server will create separate sender and recipient queue IDs (provided to Alice during set-up and Bob during initial connection). Additionally, during set-up Alice will perform a DH exchange with the server to agree upon a shared secret. This secret will be used to re-encrypt Bob's incoming message before Alice receives it, creating the anti-correlation property earlier-described should the transport encryption be compromised.
|
||||
|
||||
[0] Users can additionally create public 'contact queues' that are only used to receive connection requests.
|
||||
[0] Users can additionally create public 'contact queues' that are only used to receive connection requests.
|
||||
|
||||
|
||||
#### SimpleX Agents
|
||||
|
||||
SimpleX agents provide higher-level operations compared to SimpleX Clients, who are primarily concerned with creating queues and communicating with routers using SMP. Agent operations include:
|
||||
SimpleX agents provide higher-level operations compared to SimpleX Clients, who are primarily concerned with creating queues and communicating with servers using SMP. Agent operations include:
|
||||
|
||||
- Managing sets of bi-directional, redundant queues for communication partners
|
||||
|
||||
@@ -219,21 +186,195 @@ SimpleX agents provide higher-level operations compared to SimpleX Clients, who
|
||||
- Noise traffic
|
||||
|
||||
|
||||
## Security
|
||||
#### Encryption Primitives Used
|
||||
|
||||
For encryption primitives, threat model, and detailed security analysis, see [Security](./security.md).
|
||||
- Ed25519 or Curve25519 to authorize/verify commands to SMP servers (authorization algorithm is set via client/server configuration).
|
||||
- Curve25519 for DH exchange to agree:
|
||||
- the shared secret between server and recipient (to encrypt message bodies - it avoids shared cipher-text in sender and recipient traffic)
|
||||
- the shared secret between sender and recipient (to encrypt messages end-to-end in each queue - it avoids shared cipher-text in redundant queues).
|
||||
- [NaCl crypto_box](https://nacl.cr.yp.to/box.html) encryption scheme (curve25519xsalsa20poly1305) for message body encryption between server and recipient and for E2E per-queue encryption.
|
||||
- SHA256 to validate server offline certificates.
|
||||
- [double ratchet](https://signal.org/docs/specifications/doubleratchet/) protocol for end-to-end message encryption between the agents:
|
||||
- Curve448 keys to agree shared secrets required for double ratchet initialization (using [X3DH](https://signal.org/docs/specifications/x3dh/) key agreement with 2 ephemeral keys for each side),
|
||||
- AES-GCM AEAD cipher,
|
||||
- SHA512-based HKDF for key derivation.
|
||||
|
||||
SimpleX provides these security properties:
|
||||
|
||||
- **End-to-end encryption** using Double Ratchet algorithm with forward secrecy and post-quantum cryptography.
|
||||
## Threat Model
|
||||
|
||||
- **No shared identifiers** across connections — contacts cannot prove they communicate with the same user.
|
||||
#### Global Assumptions
|
||||
|
||||
- **Sender deniability** — neither routers nor recipients can cryptographically prove message origin.
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of servers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
- The user's client uses 2-hop onion message routing.
|
||||
|
||||
- **Transport metadata protection** — fixed-size blocks, 2-hop onion routing, and optional connection isolation frustrate traffic correlation.
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
- **Out-of-band key exchange** — connection requests passed outside the network protect against MITM attacks.
|
||||
*can:*
|
||||
|
||||
- identify that and when a user is using SimpleX.
|
||||
|
||||
- determine which servers the user receives the messages from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- see who sends messages to the user and who the user sends the messages to.
|
||||
|
||||
- determine the servers used by users' contacts.
|
||||
|
||||
#### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- identify who and when is using SimpleX.
|
||||
|
||||
- learn which SimpleX Messaging Protocol servers are used as receive queues for which users.
|
||||
|
||||
- learn when messages are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose
|
||||
|
||||
*cannot, even in case of a compromised transport protocol:*
|
||||
|
||||
- perform traffic correlation attacks with any increase in efficiency over a non-compromised transport protocol
|
||||
|
||||
#### SimpleX Messaging Protocol server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when a queue recipient is online
|
||||
|
||||
- know how many messages are sent via the queue (although some may be noise or not content messages).
|
||||
|
||||
- learn which messages would trigger notifications even if a user does not use [push notifications](./push-notifications.md).
|
||||
|
||||
- perform the correlation of the queue used to receive messages (matching multiple queues to a single user) via either a re-used transport connection, user's IP Address, or connection timing regularities.
|
||||
|
||||
- learn a recipient's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- drop all future messages inserted into a queue, detectable only over other, redundant queues.
|
||||
|
||||
- lie about the state of a queue to the recipient and/or to the sender (e.g. suspended or deleted when it is not).
|
||||
|
||||
- spam a user with invalid messages.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the users' end-to-end encryption with an active attack.
|
||||
|
||||
- learn a sender's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, even if Tor is not used (provided messages are sent via proxy SMP server).
|
||||
|
||||
- perform senders' queue correlation (matching multiple queues to a single sender) via either a re-used transport connection, user's IP Address, or connection timing regularities, unless it has additional information from the proxy SMP server (provided messages are sent via proxy SMP server).
|
||||
|
||||
#### SimpleX Messaging Protocol server that proxies the messages to another SMP server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a sender's IP address, as long as Tor is not used.
|
||||
|
||||
- learn when a sender with a given IP address is online.
|
||||
|
||||
- know how many messages are sent from a given IP address and to a given destination SMP server.
|
||||
|
||||
- drop all messages from a given IP address or to a given destination server.
|
||||
|
||||
- unless destination SMP server detects repeated public DH keys of senders, replay messages to a destination server within a single session, causing either duplicate message delivery (which will be detected and ignored by the receiving clients), or, when receiving client is not connected to SMP server, exhausting capacity of destination queues used within the session.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- perform queue correlation (matching multiple queues to a single user), unless it has additional information from the destination SMP server.
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- learn which messages would trigger notifications.
|
||||
|
||||
- learn the destination queues of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the user's end-to-end encryption with another user via an active attack.
|
||||
|
||||
- compromise the user's end-to-end encryption with the destination SMP servers via an active attack.
|
||||
|
||||
#### An attacker who obtained Alice's (decrypted) chat database
|
||||
|
||||
*can:*
|
||||
|
||||
- see the history of all messages exchanged by Alice with her communication partners.
|
||||
|
||||
- see shared profiles of contacts and groups.
|
||||
|
||||
- surreptitiously receive new messages sent to Alice via existing queues; until communication queues are rotated or the Double-Ratchet advances forward.
|
||||
|
||||
- prevent Alice from receiving all new messages sent to her - either surreptitiously by emptying the queues regularly or overtly by deleting them.
|
||||
|
||||
- send messages from the user to their contacts; recipients will detect it as soon as the user sends the next message, because the previous message hash won’t match (and potentially won’t be able to decrypt them in case they don’t keep the previous ratchet keys).
|
||||
|
||||
*cannot:*
|
||||
|
||||
- impersonate a sender and send messages to the user whose database was stolen. Doing so requires also compromising the server (to place the message in the queue, that is possible until the Double-Ratchet advances forward) or the user's device at a subsequent time (to place the message in the database).
|
||||
|
||||
- undetectably communicate at the same time as Alice with her contacts. Doing so would result in the contact getting different messages with repeated IDs.
|
||||
|
||||
- undetectably monitor message queues in realtime without alerting the user they are doing so, as a second subscription request unsubscribes the first and notifies the second.
|
||||
|
||||
#### A user’s contact
|
||||
|
||||
*can:*
|
||||
|
||||
- spam the user with messages.
|
||||
|
||||
- forever retain messages from the user.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- cryptographically prove to a third-party that a message came from a user (assuming the user’s device is not seized).
|
||||
|
||||
- prove that two contacts they have is the same user.
|
||||
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user.
|
||||
|
||||
#### An attacker who observes Alice showing an introduction message to Bob
|
||||
|
||||
*can:*
|
||||
|
||||
- Impersonate Bob to Alice.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- Impersonate Alice to Bob.
|
||||
|
||||
#### An attacker with Internet access
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service SimpleX messaging servers.
|
||||
|
||||
- spam a user's public “contact queue” with connection requests.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send messages to a user who they are not connected with.
|
||||
|
||||
- enumerate queues on a SimpleX server.
|
||||
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -13,11 +13,6 @@ Version 1, 2024-06-22
|
||||
- [Initialization](#initialization)
|
||||
- [Encrypting messages](#encrypting-messages)
|
||||
- [Decrypting messages](#decrypting-messages)
|
||||
- [Ratchet message wire format](#ratchet-message-wire-format)
|
||||
- [Encrypted ratchet message](#encrypted-ratchet-message)
|
||||
- [Encrypted message header](#encrypted-message-header)
|
||||
- [Plaintext message header](#plaintext-message-header)
|
||||
- [KEM state machine](#kem-state-machine)
|
||||
- [Implementation considerations](#implementation-considerations)
|
||||
- [Chosen KEM algorithm](#chosen-kem-algorithm)
|
||||
- [Summary](#summary)
|
||||
@@ -76,10 +71,11 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb,
|
||||
// below added for post-quantum KEM
|
||||
state.PQRs = GENERATE_PQKEM()
|
||||
state.PQRr = bob_pq_kem_encapsulation_key
|
||||
state.PQRct, state.PQRss = PQKEM-ENC(state.PQRr) // encapsulate: generates shared secret and ciphertext
|
||||
state.PQRss = random // shared secret for KEM
|
||||
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret
|
||||
// above added for KEM
|
||||
// the next line augments DH key agreement with PQ shared secret
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
state.CKr = None
|
||||
state.Ns = 0
|
||||
state.Nr = 0
|
||||
@@ -180,7 +176,8 @@ def DHRatchetPQ2HE(state, header):
|
||||
state.DHRs = GENERATE_DH()
|
||||
// below is added for KEM
|
||||
state.PQRs = GENERATE_PQKEM() // generate new PQ key pair
|
||||
state.PQRct, state.PQRss = PQKEM-ENC(state.PQRr) // encapsulate: generates shared secret and ciphertext KEM #1
|
||||
state.PQRss = random // shared secret for KEM
|
||||
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1
|
||||
// above is added for KEM
|
||||
// use new shared secret with sending ratchet
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
@@ -194,80 +191,6 @@ Other than augmenting DH key agreements with the shared secrets from KEM, the ab
|
||||
|
||||
It is worth noting that while DH agreements work as ping-pong, when the new received DH key is used for both DH agreements (and only the sent DH key is updated for the second DH key agreement), PQ KEM agreements in the proposed scheme work as a "parallel ping-pong", with two balls in play all the time (two KEM agreements run in parallel).
|
||||
|
||||
## Ratchet message wire format
|
||||
|
||||
The pseudocode above describes the algorithm. This section specifies the actual binary encoding used in SimpleX implementation with Curve448 DH keys, sntrup761 KEM and AES-256-GCM AEAD.
|
||||
|
||||
The ratchet-encrypted message has three encoding layers, from outermost to innermost:
|
||||
|
||||
1. **Encrypted ratchet message** — the complete ratchet message envelope, referenced as an opaque encrypted body in [agent protocol](./agent-protocol.md).
|
||||
2. **Encrypted message header** — the encrypted header within the ratchet message, used as associated data for message body encryption.
|
||||
3. **Plaintext message header** — the DH and KEM ratchet keys and counters.
|
||||
|
||||
### Encrypted ratchet message
|
||||
|
||||
The outer envelope contains the encrypted header (used as associated data for body authentication), the body authentication tag, and the encrypted message body.
|
||||
|
||||
The message body is encrypted with AES-256-GCM using the message key derived from the sending chain key (`KDF_CK`). The associated data for body encryption is the concatenation of the ratchet associated data and the encoded encrypted header.
|
||||
|
||||
```abnf
|
||||
encRatchetMessage = versionedLength encMessageHeader msgAuthTag encMsgBody
|
||||
; encMessageHeader is used as associated data for body decryption: AD = rcAD || encMessageHeader
|
||||
msgAuthTag = 16*16 OCTET ; AES-256-GCM authentication tag for the message body
|
||||
encMsgBody = *OCTET ; AES-256-GCM encrypted padded message body (remaining bytes)
|
||||
```
|
||||
|
||||
### Encrypted message header
|
||||
|
||||
The encrypted header wraps the current ratchet e2e encryption version, an initialization vector, an authentication tag, and the encrypted padded header body.
|
||||
|
||||
The header body is encrypted with AES-256-GCM using the header key (`HKs`). The associated data for header encryption is the ratchet associated data. The header is padded before encryption to a fixed size to prevent leaking information about the KEM state.
|
||||
|
||||
```abnf
|
||||
encMessageHeader = currentVersion headerIV headerAuthTag versionedLength encHeaderBody
|
||||
currentVersion = 2*2 OCTET ; Word16, current ratchet e2e encryption version
|
||||
headerIV = 16*16 OCTET ; AES-256 initialization vector for header encryption
|
||||
headerAuthTag = 16*16 OCTET ; AES-256-GCM authentication tag for the header
|
||||
encHeaderBody = *OCTET ; AES-256-GCM encrypted padded header (see plaintext format below)
|
||||
```
|
||||
|
||||
`versionedLength` uses a 2-byte length prefix (Word16) when the current e2e version supports PQ encryption, or a 1-byte length prefix otherwise. The parser distinguishes the two encodings by peeking at the first byte: values below 32 indicate a 2-byte prefix (as the header is always at least 69 bytes).
|
||||
|
||||
```abnf
|
||||
versionedLength = largeLength / length ; 2-byte for PQ versions, 1-byte for pre-PQ versions
|
||||
```
|
||||
|
||||
The padded header sizes before encryption are: 2310 bytes when PQ is supported, 88 bytes when PQ is not supported. Padding uses a 2-byte big-endian length prefix followed by the plaintext header and `#` fill bytes.
|
||||
|
||||
### Plaintext message header
|
||||
|
||||
```abnf
|
||||
msgHeader = maxVersion dhPublicKey [kemParams] prevMsgCount msgCount
|
||||
maxVersion = 2*2 OCTET ; Word16, max supported e2e encryption version
|
||||
dhPublicKey = length x509encoded ; Curve448 public DH ratchet key
|
||||
kemParams = noKEM / proposedKEM / acceptedKEM
|
||||
; present only when current ratchet version >= pqRatchetE2EEncryptVersion
|
||||
noKEM = %x30 ; "0" - no KEM parameters
|
||||
proposedKEM = %x31 %s"P" kemEncapsulationKey ; KEM proposed, not yet accepted
|
||||
acceptedKEM = %x31 %s"A" kemCiphertext kemEncapsulationKey ; KEM accepted
|
||||
kemEncapsulationKey = largeLength 1158*1158 OCTET ; sntrup761 encapsulation key
|
||||
kemCiphertext = largeLength 1039*1039 OCTET ; sntrup761 ciphertext
|
||||
prevMsgCount = 4*4 OCTET ; Word32, number of messages in previous sending chain
|
||||
msgCount = 4*4 OCTET ; Word32, message number in current sending chain
|
||||
length = 1*1 OCTET
|
||||
largeLength = 2*2 OCTET ; Word16
|
||||
```
|
||||
|
||||
### KEM state machine
|
||||
|
||||
PQ encryption can be enabled or disabled during a connection's lifetime. The KEM parameters in the header reflect three states:
|
||||
|
||||
- **No KEM** (`noKEM`): PQ encryption is not active. The header contains only the DH key, as in the original double ratchet.
|
||||
- **Proposed** (`proposedKEM`): One party generated a KEM key pair and includes the encapsulation key in the header, proposing PQ encryption. No ciphertext is included because the other party has not yet sent its encapsulation key.
|
||||
- **Accepted** (`acceptedKEM`): The party received the other's encapsulation key, performed encapsulation (KEM #1), and includes both the ciphertext and its own new encapsulation key (for KEM #2). This is the steady state for active PQ encryption.
|
||||
|
||||
The transition from Proposed to Accepted happens when a party receives a message containing KEM parameters (either Proposed or Accepted) and responds with its own Accepted parameters. Once both parties are in Accepted state, the double PQ KEM augmentation described in the algorithm above operates in each DH ratchet step.
|
||||
|
||||
## Implementation considerations for SimpleX Messaging Protocol
|
||||
|
||||
As SimpleX Messaging Protocol pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme can be compensated for by using ZSTD encryption of JSON bodies and image previews encoded as base64. While there may be some rare cases of random texts that would fail to compress, in all real scenarios it would not cause the message size reduction.
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
Version 3, 2025-01-24
|
||||
Version 2, 2024-06-22
|
||||
|
||||
# Overview of push notifications for SimpleX Messaging Routers
|
||||
|
||||
This document describes Notification Router protocol version 3. Version history:
|
||||
- v1: initial version
|
||||
- v2: authenticated commands, command batching
|
||||
- v3: detailed invalid token reason
|
||||
# Overview of push notifications for SimpleX Messaging Servers
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Participating routers](#participating-routers)
|
||||
- [Participating servers](#participating-servers)
|
||||
- [Register device token to receive push notifications](#register-device-token-to-receive-push-notifications)
|
||||
- [Subscribe to connection notifications](#subscribe-to-connection-notifications)
|
||||
- [SimpleX Notification Router protocol](#simplex-notification-router-protocol)
|
||||
- [SimpleX Notification Server protocol](#simplex-notification-server-protocol)
|
||||
- [Register new notification token](#register-new-notification-token)
|
||||
- [Verify notification token](#verify-notification-token)
|
||||
- [Check notification token status](#check-notification-token-status)
|
||||
@@ -28,35 +23,35 @@ This document describes Notification Router protocol version 3. Version history:
|
||||
|
||||
## Introduction
|
||||
|
||||
SimpleX Messaging routers already operate as push routers and deliver the messages to subscribed clients as soon as they are sent to the routers.
|
||||
SimpleX Messaging servers already operate as push servers and deliver the messages to subscribed clients as soon as they are sent to the servers.
|
||||
|
||||
The reason for push notifications is to support instant message notifications on iOS that does not allow background services.
|
||||
|
||||
## Participating routers
|
||||
## Participating servers
|
||||
|
||||
The diagram below shows which routers participate in message notification delivery.
|
||||
The diagram below shows which servers participate in message notification delivery.
|
||||
|
||||
While push provider (e.g., APN) can learn how many notifications are delivered to the user, it cannot access message content, even encrypted, or any message metadata - the notifications are e2e encrypted between SimpleX Notification Router and the user's device.
|
||||
While push provider (e.g., APN) can learn how many notifications are delivered to the user, it cannot access message content, even encrypted, or any message metadata - the notifications are e2e encrypted between SimpleX Notification Server and the user's device.
|
||||
|
||||
```
|
||||
User's iOS device Internet Routers
|
||||
User's iOS device Internet Servers
|
||||
--------------------- . ------------------------ . -----------------------------
|
||||
. .
|
||||
. . can be self-hosted now
|
||||
+--------------+ . . +----------------+
|
||||
| SimpleX Chat | -------------- TLS --------------- | SimpleX |
|
||||
| client |------> SimpleX Messaging Protocol (SMP) ------> | Messaging |
|
||||
+--------------+ ---------------------------------- | Router |
|
||||
+--------------+ ---------------------------------- | Server |
|
||||
^ | . . +----------------+
|
||||
| | . . . . . | . . .
|
||||
| | . . | V |
|
||||
| | . . |SMP| TLS
|
||||
| | . . | | | SimpleX
|
||||
| | . . . . . V . . . NTF Router
|
||||
| | . . . . . V . . . NTF Server
|
||||
| | . . +----------------------------------+
|
||||
| | . . | +---------------+ |
|
||||
| | -------------- TLS --------------- | | SimpleX | can be |
|
||||
| |-----------> Notification Router Protocol -----> | | Notifications | self-hosted |
|
||||
| |-----------> Notification Server Protocol -----> | | Notifications | self-hosted |
|
||||
| ---------------------------------- | | Subscriber | in the future |
|
||||
| . . | +---------------+ |
|
||||
| . . | | |
|
||||
@@ -64,7 +59,7 @@ While push provider (e.g., APN) can learn how many notifications are delivered t
|
||||
| . . | +---------------+ |
|
||||
| . . | | SimpleX | |
|
||||
| . . | | Push | |
|
||||
| . . | | Router | |
|
||||
| . . | | Server | |
|
||||
| . . | +---------------+ |
|
||||
| . . +----------------------------------+
|
||||
| . . . . . | . . .
|
||||
@@ -90,28 +85,25 @@ This diagram shows the process of subscription to notifications, notification de
|
||||
|
||||

|
||||
|
||||
## SimpleX Notification Router protocol
|
||||
## SimpleX Notification Server protocol
|
||||
|
||||
To manage notification subscriptions to SMP routers, SimpleX Notification Router provides an RPC protocol with a similar design to SimpleX Messaging Protocol router.
|
||||
To manage notification subscriptions to SMP servers, SimpleX Notification Server provides an RPC protocol with a similar design to SimpleX Messaging Protocol server.
|
||||
|
||||
This protocol sends requests and responses in a fixed size blocks of 512 bytes over TLS, uses the same [syntax of protocol transmissions](./simplex-messaging.md#smp-transmission-and-transport-block-structure) as SMP protocol, and has the same transport [handshake syntax](./simplex-messaging.md#transport-handshake) (except the router certificate is not included in the handshake).
|
||||
|
||||
The client and router use ALPN extension with `ntf/1` protocol name to agree handshake version.
|
||||
This protocol sends requests and responses in a fixed size blocks of 512 bytes over TLS, uses the same [syntax of protocol transmissions](./simplex-messaging.md#smp-transmission-and-transport-block-structure) as SMP protocol, and has the same transport [handshake syntax](./simplex-messaging.md#transport-handshake) (except the server certificate is not included in the handshake).
|
||||
|
||||
Protocol commands have this syntax:
|
||||
|
||||
```abnf
|
||||
ntfRouterTransmission = authorization corrId entityId ntfRouterCmd
|
||||
; same transmission structure as SMP, see simplex-messaging.md
|
||||
ntfRouterCmd = newTokenCmd / verifyTokenCmd / checkTokenCmd /
|
||||
```
|
||||
ntfServerTransmission =
|
||||
ntfServerCmd = newTokenCmd / verifyTokenCmd / checkTokenCmd /
|
||||
replaceTokenCmd / deleteTokenCmd / cronCmd /
|
||||
newSubCmd / checkSubCmd / deleteSubCmd / pingCmd
|
||||
newSubCmd / checkSubCmd / deleteSubCmd
|
||||
```
|
||||
### Register new notification token
|
||||
|
||||
This command should be used after the client app obtains a token from push notifications provider to register the token with the router.
|
||||
This command should be used after the client app obtains a token from push notifications provider to register the token with the server.
|
||||
|
||||
Having received this command the router will deliver a test notification via the push provider to validate that the client has this token.
|
||||
Having received this command the server will deliver a test notification via the push provider to validate that the client has this token.
|
||||
|
||||
The command syntax:
|
||||
|
||||
@@ -119,24 +111,23 @@ The command syntax:
|
||||
newTokenCmd = %s"TNEW" SP newToken
|
||||
newToken = %s"T" deviceToken authPubKey clientDhPubKey
|
||||
deviceToken = pushProvider tokenString
|
||||
pushProvider = apnsDev / apnsProd / apnsTest / apnsNull
|
||||
pushProvider = apnsDev / apnsProd / apnsNull
|
||||
apnsDev = "AD" ; APNS token for development environment
|
||||
apnsProd = "AP" ; APNS token for production environment
|
||||
apnsTest = "AT" ; APNS token for test environment (mock server)
|
||||
apnsNull = "AN" ; token that does not trigger any notification delivery - used for router testing
|
||||
apnsNull = "AN" ; token that does not trigger any notification delivery - used for server testing
|
||||
tokenString = shortString
|
||||
authPubKey = length x509encoded ; Ed25519 key used to verify clients commands
|
||||
clientDhPubKey = length x509encoded ; X25519 key to agree e2e encryption between the router and client
|
||||
clientDhPubKey = length x509encoded ; X25519 key to agree e2e encryption between the server and client
|
||||
shortString = length *OCTET
|
||||
length = 1*1 OCTET
|
||||
```
|
||||
|
||||
The router response syntax:
|
||||
The server response syntax:
|
||||
|
||||
```abnf
|
||||
tokenIdResp = %s"IDTKN" SP entityId routerDhPubKey
|
||||
tokenIdResp = %s"IDTKN" SP entityId serverDhPubKey
|
||||
entityId = shortString
|
||||
routerDhPubKey = length x509encoded ; X25519 key to agree e2e encryption between the router and client
|
||||
serverDhPubKey = length x509encoded ; X25519 key to agree e2e encryption between the server and client
|
||||
```
|
||||
|
||||
### Verify notification token
|
||||
@@ -168,9 +159,7 @@ The response to this command:
|
||||
|
||||
```abnf
|
||||
tokenStatusResp = %s"TKN" SP tokenStatus
|
||||
tokenStatus = %s"NEW" / %s"REGISTERED" / tokenInvalid / %s"CONFIRMED" / %s"ACTIVE" / %s"EXPIRED"
|
||||
tokenInvalid = %s"INVALID" ["," invalidReason] ; optional reason added in v3
|
||||
invalidReason = %s"BAD" / %s"TOPIC" / %s"EXPIRED" / %s"UNREGISTERED"
|
||||
tokenStatus = %s"NEW" / %s"REGISTERED" / %s"INVALID" / %s"CONFIRMED" / %s"ACTIVE" / %s"EXPIRED"
|
||||
```
|
||||
|
||||
### Replace notification token
|
||||
@@ -211,8 +200,8 @@ After this command all message notification subscriptions will be removed and no
|
||||
This command enables or disables periodic notifications sent to the client device irrespective of message notifications.
|
||||
|
||||
This is useful for two reasons:
|
||||
- it provides better privacy from notification router, as while the router learns the device token, it doesn't learn anything else about user communications.
|
||||
- it allows to receive messages when notifications were dropped by push provider, e.g. while the device was offline, or lost by notification router, e.g. while it was restarting.
|
||||
- it provides better privacy from notification server, as while the server learns the device token, it doesn't learn anything else about user communications.
|
||||
- it allows to receive messages when notifications were dropped by push provider, e.g. while the device was offline, or lost by notification server, e.g. while it was restarting.
|
||||
|
||||
The command syntax:
|
||||
|
||||
@@ -225,18 +214,18 @@ The interval for periodic notifications is set in minutes, with the minimum of 2
|
||||
|
||||
### Create SMP message notification subscription
|
||||
|
||||
This command makes notification router subscribe to message notifications from SMP router and to deliver them to push provider:
|
||||
This command makes notification server subscribe to message notifications from SMP server and to deliver them to push provider:
|
||||
|
||||
```abnf
|
||||
newSubCmd = %s"SNEW" SP newSub
|
||||
newSub = %s"S" tokenId smpRouter notifierId notifierKey
|
||||
newSubCmd = %s"SNEW" newSub
|
||||
newSub = %s "S" tokenId smpServer notifierId notifierKey
|
||||
tokenId = shortString ; returned in response to `TNEW` command
|
||||
smpRouter = hosts port fingerprint
|
||||
smpServer = smpServer = hosts port fingerprint
|
||||
hosts = length 1*host
|
||||
host = shortString
|
||||
port = shortString
|
||||
fingerprint = shortString
|
||||
notifierId = shortString ; returned by SMP router in response to `NKEY` SMP command
|
||||
notifierId = shortString ; returned by SMP server in response to `NKEY` SMP command
|
||||
notifierKey = length x509encoded ; private key used to authorize requests to subscribe to message notifications
|
||||
```
|
||||
|
||||
@@ -258,10 +247,10 @@ The response:
|
||||
|
||||
```abnf
|
||||
subStatusResp = %s"SUB" SP subStatus
|
||||
subStatus = %s"NEW" / %s"PENDING" / ; e.g., after SMP router disconnect/timeout while ntf router is retrying to connect
|
||||
%s"ACTIVE" / %s"INACTIVE" / %s"END" / ; if another router subscribed to notifications
|
||||
%s"AUTH" / %s"DELETED" / %s"SERVICE" / subErrStatus
|
||||
subErrStatus = %s"ERR" SP *OCTET
|
||||
subStatus = %s"NEW" / %s"PENDING" / ; e.g., after SMP server disconnect/timeout while ntf server is retrying to connect
|
||||
%s"ACTIVE" / %s"INACTIVE" / %s"END" / ; if another server subscribed to notifications
|
||||
%s"AUTH" / subErrStatus
|
||||
subErrStatus = %s"ERR" SP shortString
|
||||
```
|
||||
|
||||
### Delete notification subscription
|
||||
@@ -276,17 +265,6 @@ The response to this command is `okResp` or `errorResp`.
|
||||
|
||||
After this command no more message notifications will be sent from this queue.
|
||||
|
||||
### Keep-alive command
|
||||
|
||||
To keep the transport connection alive the clients should use `PING` command:
|
||||
|
||||
```abnf
|
||||
pingCmd = %s"PING"
|
||||
pongResp = %s"PONG"
|
||||
```
|
||||
|
||||
This command is sent unsigned and without entity ID.
|
||||
|
||||
### Error responses
|
||||
|
||||
All commands can return error response:
|
||||
@@ -299,7 +277,7 @@ Where `errorType` has the same syntax as in [SimpleX Messaging Protocol](./simpl
|
||||
|
||||
## Threat Model
|
||||
|
||||
This threat model compliments SimpleX Messaging Protocol [threat model](./security.md#threat-model)
|
||||
This threat model compliments SimpleX Messaging Protocol [threat model](./overview-tjr.md#threat-model)
|
||||
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
@@ -309,21 +287,21 @@ This threat model compliments SimpleX Messaging Protocol [threat model](./securi
|
||||
|
||||
*cannot:*
|
||||
|
||||
- determine which routers a user subscribed to the notifications from.
|
||||
- determine which servers a user subscribed to the notifications from.
|
||||
|
||||
#### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- perform more efficient traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the routers.
|
||||
- perform more efficient traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
#### SimpleX Messaging Protocol router
|
||||
#### SimpleX Messaging Protocol server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn which messages trigger push notifications.
|
||||
|
||||
- learn IP address of SimpleX notification routers used by the user.
|
||||
- learn IP address of SimpleX notification servers used by the user.
|
||||
|
||||
- drop message notifications.
|
||||
|
||||
@@ -335,13 +313,13 @@ This threat model compliments SimpleX Messaging Protocol [threat model](./securi
|
||||
|
||||
- learn which queues belong to the same users with any additional efficiency compared with not using push notifications.
|
||||
|
||||
#### SimpleX Notification Router subscribed to message notifications
|
||||
#### SimpleX Notification Server subscribed to message notifications
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a user device token.
|
||||
|
||||
- learn how many messaging queues and routers a user receives messages from.
|
||||
- learn how many messaging queues and servers a user receives messages from.
|
||||
|
||||
- learn how many message notifications are delivered to the user from each queue.
|
||||
|
||||
@@ -361,7 +339,7 @@ This threat model compliments SimpleX Messaging Protocol [threat model](./securi
|
||||
|
||||
- add, duplicate, or corrupt individual messages that will be shown to the user.
|
||||
|
||||
#### SimpleX Notification Router subscribed ONLY to periodic notifications
|
||||
#### SimpleX Notification Server subscribed ONLY to periodic notifications
|
||||
|
||||
*can:*
|
||||
|
||||
@@ -373,7 +351,7 @@ This threat model compliments SimpleX Messaging Protocol [threat model](./securi
|
||||
|
||||
*cannot:*
|
||||
|
||||
- learn how many messaging queues and routers a user receives messages from.
|
||||
- learn how many messaging queues and servers a user receives messages from.
|
||||
|
||||
- learn how many message notifications are delivered to the user from each queue.
|
||||
|
||||
@@ -405,7 +383,7 @@ This threat model compliments SimpleX Messaging Protocol [threat model](./securi
|
||||
|
||||
*cannot:*
|
||||
|
||||
- learn which SimpleX Messaging Protocol routers are used by a user (notifications are e2e encrypted).
|
||||
- learn which SimpleX Messaging Protocol servers are used by a user (notifications are e2e encrypted).
|
||||
|
||||
- learn which or how many messaging queues a user receives notifications from.
|
||||
|
||||
@@ -417,4 +395,4 @@ This threat model compliments SimpleX Messaging Protocol [threat model](./securi
|
||||
|
||||
- register notification token not present on attacker's device.
|
||||
|
||||
- enumerate tokens or subscriptions on a SimpleX Notification Router.
|
||||
- enumerate tokens or subscriptions on a SimpleX Notification Server.
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
Revision 1, 2026-03-09
|
||||
|
||||
# SimpleX Network: Security
|
||||
|
||||
This document describes the cryptographic primitives and threat model for the SimpleX network. For a general introduction, see [SimpleX: messaging and application platform](./overview-tjr.md).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Encryption primitives](#encryption-primitives)
|
||||
- [Threat model](#threat-model)
|
||||
- [Global Assumptions](#global-assumptions)
|
||||
- [A passive adversary able to monitor the traffic of one user](#a-passive-adversary-able-to-monitor-the-traffic-of-one-user)
|
||||
- [A passive adversary able to monitor a set of senders and recipients](#a-passive-adversary-able-to-monitor-a-set-of-senders-and-recipients)
|
||||
- [SimpleX Messaging Protocol router](#simplex-messaging-protocol-router)
|
||||
- [SimpleX Messaging Protocol router that proxies the messages to another SMP router](#simplex-messaging-protocol-router-that-proxies-the-messages-to-another-smp-router)
|
||||
- [An attacker who obtained Alice's (decrypted) chat database](#an-attacker-who-obtained-alices-decrypted-chat-database)
|
||||
- [A user's contact](#a-users-contact)
|
||||
- [An attacker who observes Alice showing an introduction message to Bob](#an-attacker-who-observes-alice-showing-an-introduction-message-to-bob)
|
||||
- [An attacker with Internet access](#an-attacker-with-internet-access)
|
||||
|
||||
|
||||
## Encryption primitives
|
||||
|
||||
- **Router command authorization**: X25519 DH-based authenticated encryption (SMP v7+), providing sender deniability. Ed25519 signatures used for recipient commands and notifier commands.
|
||||
|
||||
- **Per-queue key agreement**: Curve25519 DH exchange to agree:
|
||||
- the shared secret between router and recipient (to encrypt message bodies — avoids shared ciphertext in sender and recipient traffic),
|
||||
- the shared secret between sender and recipient (to encrypt messages end-to-end in each queue — avoids shared ciphertext in redundant queues).
|
||||
|
||||
- **SMP-layer encryption**: [NaCl crypto_box](https://nacl.cr.yp.to/box.html) (curve25519xsalsa20poly1305) for message body encryption between router and recipient, and for e2e per-queue encryption.
|
||||
|
||||
- **Certificate validation**: SHA256 to validate router offline certificates.
|
||||
|
||||
- **End-to-end encryption**: [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) protocol:
|
||||
- Curve448 keys for shared secret agreement via [X3DH](https://signal.org/docs/specifications/x3dh/) with 2 ephemeral keys per side,
|
||||
- optional [SNTRUP761](https://ntruprime.cr.yp.to/) post-quantum KEM running in parallel with the DH ratchet (see [PQDR](./pqdr.md)), providing post-quantum forward secrecy,
|
||||
- AES-GCM AEAD cipher,
|
||||
- SHA512-based HKDF for key derivation.
|
||||
|
||||
|
||||
## Threat Model
|
||||
|
||||
### Global Assumptions
|
||||
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of routers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
- The user's client uses 2-hop onion message routing.
|
||||
|
||||
### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
*can:*
|
||||
|
||||
- identify that and when a user is using SimpleX.
|
||||
|
||||
- determine which routers the user receives messages from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- see who sends messages to the user and who the user sends messages to.
|
||||
|
||||
- determine the routers used by users' contacts.
|
||||
|
||||
### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- identify who and when is using SimpleX.
|
||||
|
||||
- learn which SimpleX Messaging Protocol routers are used as receive queues for which users.
|
||||
|
||||
- learn when messages are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the routers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot, even in case of a compromised transport protocol:*
|
||||
|
||||
- perform traffic correlation attacks with any increase in efficiency over a non-compromised transport protocol.
|
||||
|
||||
### SimpleX Messaging Protocol router
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when a queue recipient is online.
|
||||
|
||||
- know how many messages are sent via the queue (although some may be noise or not content messages).
|
||||
|
||||
- learn which messages would trigger notifications even if a user does not use [push notifications](./push-notifications.md).
|
||||
|
||||
- perform the correlation of the queue used to receive messages (matching multiple queues to a single user) via either a re-used transport connection, user's IP Address, or connection timing regularities.
|
||||
|
||||
- learn a recipient's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- drop all future messages inserted into a queue, detectable only over other, redundant queues.
|
||||
|
||||
- lie about the state of a queue to the recipient and/or to the sender (e.g. suspended or deleted when it is not).
|
||||
|
||||
- spam a user with invalid messages.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the users' end-to-end encryption with an active attack.
|
||||
|
||||
- learn a sender's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, even if Tor is not used (provided messages are sent via proxy SMP router).
|
||||
|
||||
- perform senders' queue correlation (matching multiple queues to a single sender) via either a re-used transport connection, user's IP Address, or connection timing regularities, unless it has additional information from the proxy SMP router (provided messages are sent via proxy SMP router).
|
||||
|
||||
### SimpleX Messaging Protocol router that proxies the messages to another SMP router
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a sender's IP address, as long as Tor is not used.
|
||||
|
||||
- learn when a sender with a given IP address is online.
|
||||
|
||||
- know how many messages are sent from a given IP address and to a given destination SMP router.
|
||||
|
||||
- drop all messages from a given IP address or to a given destination router.
|
||||
|
||||
- unless destination SMP router detects repeated public DH keys of senders, replay messages to a destination router within a single session, causing either duplicate message delivery (which will be detected and ignored by the receiving clients), or, when receiving client is not connected to SMP router, exhausting capacity of destination queues used within the session.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- perform queue correlation (matching multiple queues to a single user), unless it has additional information from the destination SMP router.
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- learn which messages would trigger notifications.
|
||||
|
||||
- learn the destination queues of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the user's end-to-end encryption with another user via an active attack.
|
||||
|
||||
- compromise the user's end-to-end encryption with the destination SMP routers via an active attack.
|
||||
|
||||
### An attacker who obtained Alice's (decrypted) chat database
|
||||
|
||||
*can:*
|
||||
|
||||
- see the history of all messages exchanged by Alice with her communication partners.
|
||||
|
||||
- see shared profiles of contacts and groups.
|
||||
|
||||
- surreptitiously receive new messages sent to Alice via existing queues; until communication queues are rotated or the Double-Ratchet advances forward.
|
||||
|
||||
- prevent Alice from receiving all new messages sent to her - either surreptitiously by emptying the queues regularly or overtly by deleting them.
|
||||
|
||||
- send messages from the user to their contacts; recipients will detect it as soon as the user sends the next message, because the previous message hash won't match (and potentially won't be able to decrypt them in case they don't keep the previous ratchet keys).
|
||||
|
||||
*cannot:*
|
||||
|
||||
- impersonate a sender and send messages to the user whose database was stolen. Doing so requires also compromising the router (to place the message in the queue, that is possible until the Double-Ratchet advances forward) or the user's device at a subsequent time (to place the message in the database).
|
||||
|
||||
- undetectably communicate at the same time as Alice with her contacts. Doing so would result in the contact getting different messages with repeated IDs.
|
||||
|
||||
- undetectably monitor message queues in realtime without alerting the user they are doing so, as a second subscription request unsubscribes the first and notifies the first.
|
||||
|
||||
### A user's contact
|
||||
|
||||
*can:*
|
||||
|
||||
- spam the user with messages.
|
||||
|
||||
- forever retain messages from the user.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- cryptographically prove to a third-party that a message came from a user (assuming the user's device is not seized).
|
||||
|
||||
- prove that two contacts they have is the same user.
|
||||
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user.
|
||||
|
||||
### An attacker who observes Alice showing an introduction message to Bob
|
||||
|
||||
*can:*
|
||||
|
||||
- Impersonate Bob to Alice.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- Impersonate Alice to Bob.
|
||||
|
||||
### An attacker with Internet access
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service SimpleX messaging routers.
|
||||
|
||||
- spam a user's public "contact queue" with connection requests.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send messages to a user who they are not connected with.
|
||||
|
||||
- enumerate queues on a SimpleX router.
|
||||
@@ -1,4 +1,4 @@
|
||||
Version 3, 2025-01-24
|
||||
Version 2, 2024-06-22
|
||||
|
||||
# SimpleX File Transfer Protocol
|
||||
|
||||
@@ -11,12 +11,12 @@ Version 3, 2025-01-24
|
||||
- [XFTP procedure](#xftp-procedure)
|
||||
- [File description](#file-description)
|
||||
- [URIs syntax](#uris-syntax)
|
||||
- [XFTP router URI](#xftp-router-uri)
|
||||
- [XFTP server URI](#xftp-server-uri)
|
||||
- [File description URI](#file-description-URI)
|
||||
- [XFTP qualities and features](#xftp-qualities-and-features)
|
||||
- [Cryptographic algorithms](#cryptographic-algorithms)
|
||||
- [Data packet IDs](#data-packet-ids)
|
||||
- [Router security requirements](#router-security-requirements)
|
||||
- [File chunk IDs](#file-chunk-ids)
|
||||
- [Server security requirements](#server-security-requirements)
|
||||
- [Transport protocol](#transport-protocol)
|
||||
- [TLS ALPN](#tls-alpn)
|
||||
- [Connection handshake](#connection-handshake)
|
||||
@@ -26,14 +26,13 @@ Version 3, 2025-01-24
|
||||
- [Command authentication](#command-authentication)
|
||||
- [Keep-alive command](#keep-alive-command)
|
||||
- [File sender commands](#file-sender-commands)
|
||||
- [Register new data packet](#register-new-data-packet)
|
||||
- [Add data packet recipients](#add-data-packet-recipients)
|
||||
- [Upload data packet](#upload-data-packet)
|
||||
- [Delete data packet](#delete-data-packet)
|
||||
- [Register new file chunk](#register-new-file-chunk)
|
||||
- [Add file chunk recipients](#add-file-chunk-recipients)
|
||||
- [Upload file chunk](#upload-file-chunk)
|
||||
- [Delete file chunk](#delete-file-chunk)
|
||||
- [File recipient commands](#file-recipient-commands)
|
||||
- [Download data packet](#download-data-packet)
|
||||
- [Acknowledge data packet download](#acknowledge-data-packet-download)
|
||||
- [Error responses](#error-responses)
|
||||
- [Download file chunk](#download-file-chunk)
|
||||
- [Acknowledge file chunk download](#acknowledge-file-chunk-download)
|
||||
- [Threat model](#threat-model)
|
||||
|
||||
## Abstract
|
||||
@@ -46,31 +45,23 @@ It is designed as a application level protocol to solve the problem of secure an
|
||||
|
||||
## Introduction
|
||||
|
||||
The objective of SimpleX File Transfer Protocol (XFTP) is to facilitate the secure and private unidirectional transfer of files from senders to recipients via persistent data packets stored by the xftp router.
|
||||
The objective of SimpleX File Transfer Protocol (XFTP) is to facilitate the secure and private unidirectional transfer of files from senders to recipients via persistent file chunks stored by the xftp server.
|
||||
|
||||
XFTP is implemented as an application level protocol on top of HTTP2 and TLS.
|
||||
|
||||
This document describes XFTP protocol version 3. The version history:
|
||||
The protocol describes the set of commands that senders and recipients can send to XFTP servers to create, upload, download and delete file chunks of several pre-defined sizes. XFTP servers SHOULD support chunks of 4 sizes: 64KB, 256KB, 1MB and 4MB (1KB = 1024 bytes, 1MB = 1024KB).
|
||||
|
||||
- v1: initial version
|
||||
- v2: authenticated commands - added basic auth support for commands
|
||||
- v3: blocked files - added BLOCKED error type for policy violations
|
||||
The protocol is designed with the focus on meta-data privacy and security. While using TLS, the protocol does not rely on TLS security by using additional encryption to achieve that there are no identifiers or ciphertext in common in received and sent server traffic, frustrating traffic correlation even if TLS is compromised.
|
||||
|
||||
The protocol describes the set of commands that senders and recipients can send to XFTP routers to create, upload, download and delete data packets of several pre-defined sizes. XFTP routers SHOULD support packets of 4 sizes: 64KB, 256KB, 1MB and 4MB (1KB = 1024 bytes, 1MB = 1024KB).
|
||||
|
||||
The protocol is designed with the focus on meta-data privacy and security. While using TLS, the protocol does not rely on TLS security by using additional encryption to achieve that there are no identifiers or ciphertext in common in received and sent router traffic, frustrating traffic correlation even if TLS is compromised.
|
||||
|
||||
XFTP does not use any form of participants' identities. It relies on out-of-band passing of "file description" - a human-readable YAML document with the list of data packet locations, hashes and necessary cryptographic keys.
|
||||
|
||||
> **Note:** While this protocol was originally designed for file transfer, it handles generic addressed data packets. File-specific semantics (splitting files into packets, assembly, naming) are application-level concerns defined in the [agent protocol](./agent-protocol.md).
|
||||
XFTP does not use any form of participants' identities. It relies on out-of-band passing of "file description" - a human-readable YAML document with the list of file chunk locations, hashes and necessary cryptographic keys.
|
||||
|
||||
## XFTP Model
|
||||
|
||||
The XFTP model has three communication participants: the recipient, the XFTP router that is chosen and, possibly, controlled by the sender, and the sender.
|
||||
The XFTP model has three communication participants: the recipient, the file server (XFTP server) that is chosen and, possibly, controlled by the sender, and the sender.
|
||||
|
||||
XFTP router allows uploading fixed size data packets, with or without basic authentication. The same party that can be the sender of one data packet can be the recipient of another, without exposing it to the router.
|
||||
XFTP server allows uploading fixed size file chunks, with or without basic authentication. The same party that can be the sender of one file chunk can be the recipient of another, without exposing it to the server.
|
||||
|
||||
Each data packet allows multiple recipients, each recipient can download the same packet multiple times. It allows depending on the threat model use the same recipient credentials for multiple parties, thus reducing router ability to understand the number of intended recipients (but router can still track IP addresses to determine it), or use one unique set of credentials for each recipient, frustrating traffic correlation on the assumption of compromised TLS. In the latter case, senders can create a larger number of recipient credentials to hide the actual number of intended recipients from the routers (which is what SimpleX clients do).
|
||||
Each file chunk allows multiple recipients, each recipient can download the same chunk multiple times. It allows depending on the threat model use the same recipient credentials for multiple parties, thus reducing server ability to understand the number of intended recipients (but server can still track IP addresses to determine it), or use one unique set of credentials for each recipient, frustrating traffic correlation on the assumption of compromised TLS. In the latter case, senders can create a larger number of recipient credentials to hide the actual number of intended recipients from the servers (which is what SimpleX clients do).
|
||||
|
||||
```
|
||||
Sender Internet XFTP relays Internet Recipient
|
||||
@@ -78,7 +69,7 @@ Each data packet allows multiple recipients, each recipient can download the sam
|
||||
| | | |
|
||||
| | (can be self-hosted) | |
|
||||
| | +---------+ | |
|
||||
packet 1 ----- HTTP2 over TLS ------ | XFTP | ---- HTTP2 / TLS ----- packet 1
|
||||
chunk 1 ----- HTTP2 over TLS ------ | XFTP | ---- HTTP2 / TLS ----- chunk 1
|
||||
|---> SimpleX File Transfer Protocol (XFTP) --> | Relay | ---> XFTP ------------->|
|
||||
| --------------------------- +---------+ ---------------------- |
|
||||
| | | | | |
|
||||
@@ -92,21 +83,21 @@ file ---> | XFTP | ------> XFTP ----> | Relay | --->
|
||||
| | | +---------+ | | |
|
||||
| ------- HTTP2 / TLS ------- | XFTP | ---- HTTP2 / TLS ---- |
|
||||
|-------------> XFTP ----> | Relay | ---> XFTP ------------->|
|
||||
packet N --------------------------- +---------+ --------------------- packet N
|
||||
| | (store data packets) | |
|
||||
chunk N --------------------------- +---------+ --------------------- chunk N
|
||||
| | (store file chunks) | |
|
||||
| | | |
|
||||
| | | |
|
||||
```
|
||||
|
||||
When sender client uploads a data packet, it has to register it first with one sender ID and multiple recipient IDs, and one random unique key per ID to authenticate sender and recipients, and also provide its size and hash that will be validated when packet is uploaded.
|
||||
When sender client uploads a file chunk, it has to register it first with one sender ID and multiple recipient IDs, and one random unique key per ID to authenticate sender and recipients, and also provide its size and hash that will be validated when chunk is uploaded.
|
||||
|
||||
To send the actual file, the sender client MUST pad it and encrypt it with a random symmetric key and distribute packets of fixed sized across multiple XFTP routers. Information about packet locations, keys, hashes and required keys is passed to the recipients as "[file description](#file-description)" out-of-band.
|
||||
To send the actual file, the sender client MUST pad it and encrypt it with a random symmetric key and distribute chunks of fixed sized across multiple XFTP servers. Information about chunk locations, keys, hashes and required keys is passed to the recipients as "[file description](#file-description)" out-of-band.
|
||||
|
||||
Creating, uploading, downloading and deleting data packets requires sending commands to the XFTP router - they are described in detail in [XFTP commands](#xftp-commands) section.
|
||||
Creating, uploading, downloading and deleting file chunks requires sending commands to the XFTP server - they are described in detail in [XFTP commands](#xftp-commands) section.
|
||||
|
||||
## Persistence model
|
||||
|
||||
Router stores data packet records in memory, with optional adding to append-only log, to allow restoring them on router restart. Data packet bodies can be stored as files or as objects in any object store (e.g. S3).
|
||||
Server stores file chunk records in memory, with optional adding to append-only log, to allow restoring them on server restart. File chunk bodies can be stored as files or as objects in any object store (e.g. S3).
|
||||
|
||||
## XFTP procedure
|
||||
|
||||
@@ -116,28 +107,28 @@ To send the file, the sender will:
|
||||
|
||||
1) Prepare file
|
||||
- compute its SHA512 digest.
|
||||
- prepend header with the name and pad the file to match the whole number of packets in size. It is RECOMMENDED to use 2 of 4 allowed packet sizes, to balance upload size and metadata privacy.
|
||||
- prepend header with the name and pad the file to match the whole number of chunks in size. It is RECOMMENDED to use 2 of 4 allowed chunk sizes, to balance upload size and metadata privacy.
|
||||
- encrypt it with a randomly chosen symmetric key and IV (e.g., using NaCL secret_box).
|
||||
- split into allowed size packets.
|
||||
- split into allowed size chunks.
|
||||
- generate per-recipient keys. It is recommended that the sending client generates more per-recipient keys than the actual number of recipients, rounding up to a power of 2, to conceal the actual number of intended recipients.
|
||||
|
||||
2) Upload data packets
|
||||
- register each packet record with randomly chosen one or more (for redundancy) XFTP router(s).
|
||||
2) Upload file chunks
|
||||
- register each chunk record with randomly chosen one or more (for redundancy) XFTP server(s).
|
||||
- optionally request additional recipient IDs, if required number of recipient keys didn't fit into register request.
|
||||
- upload each packet to chosen router(s).
|
||||
- upload each chunk to chosen server(s).
|
||||
|
||||
3) Prepare file descriptions, one per recipient.
|
||||
|
||||
The sending client combines addresses of all packets and other information into "file description", different for each file recipient, that will include:
|
||||
The sending client combines addresses of all chunks and other information into "file description", different for each file recipient, that will include:
|
||||
|
||||
- an encryption key used to encrypt/decrypt the full file (the same for all recipients).
|
||||
- file SHA512 digest to validate download.
|
||||
- list of packet descriptions; information for each packet:
|
||||
- private Ed25519 key to sign commands for file transfer router.
|
||||
- packet address (router host and packet ID).
|
||||
- packet sha256 digest.
|
||||
- list of chunk descriptions; information for each chunk:
|
||||
- private Ed25519 key to sign commands for file transfer server.
|
||||
- chunk address (server host and chunk ID).
|
||||
- chunk sha512 digest.
|
||||
|
||||
To reduce the size of file description, packets are grouped by the router host.
|
||||
To reduce the size of file description, chunks are grouped by the server host.
|
||||
|
||||
4) Send file description(s) to the recipient(s) out-of-band, via pre-existing secure and authenticated channel. E.g., SimpleX clients send it as messages via SMP protocol, but it can be done via any other channel.
|
||||
|
||||
@@ -147,16 +138,16 @@ To reduce the size of file description, packets are grouped by the router host.
|
||||
|
||||
Having received the description, the recipient will:
|
||||
|
||||
1) Download all packets.
|
||||
1) Download all chunks.
|
||||
|
||||
The receiving client can fall back to secondary routers, if necessary:
|
||||
- if the router is not available.
|
||||
- if the packet is not present on the router (ERR AUTH response).
|
||||
- if the hash of the downloaded data packet does not match the description.
|
||||
The receiving client can fall back to secondary servers, if necessary:
|
||||
- if the server is not available.
|
||||
- if the chunk is not present on the server (ERR AUTH response).
|
||||
- if the hash of the downloaded file chunk does not match the description.
|
||||
|
||||
Optionally recipient can acknowledge data packet reception to delete file ID from router for this recipient.
|
||||
Optionally recipient can acknowledge file chunk reception to delete file ID from server for this recipient.
|
||||
|
||||
2) Combine the packets into a file.
|
||||
2) Combine the chunks into a file.
|
||||
|
||||
3) Decrypt the file using the key in file description.
|
||||
|
||||
@@ -172,35 +163,35 @@ Optionally recipient can acknowledge data packet reception to delete file ID fro
|
||||
|
||||
It includes these fields:
|
||||
- `party` - "sender" or "recipient". Sender's file description is required to delete the file.
|
||||
- `size` - padded file size equal to total size of all packets, see `fileSize` syntax below.
|
||||
- `size` - padded file size equal to total size of all chunks, see `fileSize` syntax below.
|
||||
- `digest` - SHA512 hash of encrypted file, base64url encoded string.
|
||||
- `key` - symmetric encryption key to decrypt the file, base64url encoded string.
|
||||
- `nonce` - nonce to decrypt the file, base64url encoded string.
|
||||
- `chunkSize` - default packet size, see `fileSize` syntax below.
|
||||
- `replicas` - the array of data packet replicas descriptions.
|
||||
- `chunkSize` - default chunk size, see `fileSize` syntax below.
|
||||
- `replicas` - the array of file chunk replicas descriptions.
|
||||
- `redirect` - optional property for redirect information indicating that the file is itself a description to another file, allowing to use file description as a short URI.
|
||||
|
||||
Each replica description is an object with 2 fields:
|
||||
|
||||
- `chunks` - an array of packet replica descriptions stored on one server.
|
||||
- `server` - [router address](#xftp-router-uri) where the packets can be downloaded from.
|
||||
- `chunks` - and array of chunk replica descriptions stored on one server.
|
||||
- `server` - [server address](#xftp-server-uri) where the chunks can be downloaded from.
|
||||
|
||||
Each router replica description is a string with this syntax:
|
||||
Each server replica description is a string with this syntax:
|
||||
|
||||
```abnf
|
||||
packetReplica = packetNo ":" replicaId ":" replicaKey [":" packetDigest [":" packetSize]]
|
||||
packetNo = 1*DIGIT
|
||||
; a sequential 1-based packet number in the original file.
|
||||
chunkReplica = chunkNo ":" replicaId ":" replicaKey [":" chunkDigest [":" chunkSize]]
|
||||
chunkNo = 1*DIGIT
|
||||
; a sequential 1-based chunk number in the original file.
|
||||
replicaId = base64url
|
||||
; router-assigned random packet replica ID.
|
||||
; server-assigned random chunk replica ID.
|
||||
replicaKey = base64url
|
||||
; sender-generated random key to receive (or to delete, in case of sender's file description) the packet replica.
|
||||
packetDigest = base64url
|
||||
; packet digest that MUST be specified for the first replica of each packet,
|
||||
; sender-generated random key to receive (or to delete, in case of sender's file description) the chunk replica.
|
||||
chunkDigest = base64url
|
||||
; chunk digest that MUST be specified for the first replica of each chunk,
|
||||
; and SHOULD be omitted (or be the same) on the subsequent replicas
|
||||
packetSize = fileSize
|
||||
chunkSize = fileSize
|
||||
fileSize = sizeInBytes / sizeInUnits
|
||||
; packet size SHOULD only be specified on the first replica and only if it is different from default packet size
|
||||
; chunk size SHOULD only be specified on the first replica and only if it is different from default chunk size
|
||||
sizeInBytes = 1*DIGIT
|
||||
sizeInUnits = 1*DIGIT sizeUnit
|
||||
sizeUnit = %s"kb" / %s"mb" / %s"gb"
|
||||
@@ -213,28 +204,28 @@ Optional redirect information has two fields:
|
||||
|
||||
## URIs syntax
|
||||
|
||||
### XFTP router URI
|
||||
### XFTP server URI
|
||||
|
||||
The XFTP router address is a URI with the following syntax:
|
||||
The XFTP server address is a URI with the following syntax:
|
||||
|
||||
```abnf
|
||||
xftpRouterURI = %s"xftp://" xftpRouter
|
||||
xftpRouter = routerIdentity [":" basicAuth] "@" srvHost [":" port]
|
||||
xftpServerURI = %s"xftp://" xftpServer
|
||||
xftpServer = serverIdentity [":" basicAuth] "@" srvHost [":" port]
|
||||
srvHost = <hostname> ; RFC1123, RFC5891
|
||||
port = 1*DIGIT
|
||||
routerIdentity = base64url
|
||||
serverIdentity = base64url
|
||||
basicAuth = base64url
|
||||
```
|
||||
|
||||
### File description URI
|
||||
|
||||
This file description URI can be generated by the client application to share a small file description as a QR code or as a link. Practically, to be able to scan a QR code it should be under 1000 characters, so only file descriptions with 1-2 packets can be used in this case. This is supported with `redirect` property when file description leads to a file which in itself is a larger file description to another file - akin to URL shortener.
|
||||
This file description URI can be generated by the client application to share a small file description as a QR code or as a link. Practically, to be able to scan a QR code it should be under 1000 characters, so only file descriptions with 1-2 chunks can be used in this case. This is supported with `redirect` property when file description leads to a file which in itself is a larger file description to another file - akin to URL shortener.
|
||||
|
||||
File description URI syntax:
|
||||
|
||||
```abnf
|
||||
fileDescriptionURI = serviceScheme "/file" "#/?desc=" description [ "&data=" userData ]
|
||||
serviceScheme = (%s"https://" clientAppServer) / %s"simplex:"
|
||||
serviceScheme = (%s"https://" clientAppServer) | %s"simplex:"
|
||||
clientAppServer = hostname [ ":" port ]
|
||||
; client app server, e.g. simplex.chat
|
||||
description = <URI-escaped YAML file description>
|
||||
@@ -249,50 +240,50 @@ clientAppServer is not a server the client connects to - it is a server that sho
|
||||
|
||||
XFTP stands for SimpleX File Transfer Protocol. Its design is based on the same ideas and has some of the qualities of SimpleX Messaging Protocol:
|
||||
|
||||
- recipient cannot see sender's IP address, as the file fragments (packets) are temporarily stored on multiple XFTP relays.
|
||||
- recipient cannot see sender's IP address, as the file fragments (chunks) are temporarily stored on multiple XFTP relays.
|
||||
- file can be sent asynchronously, without requiring the sender to be online for file to be received.
|
||||
- there is no network of peers that can observe this transfer - sender chooses which XFTP relays to use, and can self-host their own.
|
||||
- XFTP relays do not have any file metadata - they only see individual packets, with access to each packet authorized with anonymous credentials (using Edwards curve cryptographic signature) that are random per packet.
|
||||
- packets have one of the sizes allowed by the routers - 64KB, 256KB, 1MB and 4MB packets, so sending a large file looks indistinguishable from sending many small files to XFTP router. If the same transport connection is reused, router would only know that packets are sent by the same user.
|
||||
- each packet can be downloaded by multiple recipients, but each recipient uses their own key and packet ID to authorize access, and the packet is encrypted by a different key agreed via ephemeral DH keys (NaCl crypto_box (SalsaX20Poly1305 authenticated encryption scheme ) with shared secret derived from Curve25519 key exchange) on the way from the router to each recipient. XFTP protocol as a result has the same quality as SMP protocol - there are no identifiers and ciphertext in common between sent and received traffic inside TLS connection, so even if TLS is compromised, it complicates traffic correlation attacks.
|
||||
- XFTP protocol supports redundancy - each data packet can be sent via multiple relays, and the recipient can choose the one that is available. Current implementation of XFTP protocol in SimpleX Chat does not support redundancy though.
|
||||
- XFTP relays do not have any file metadata - they only see individual chunks, with access to each chunk authorized with anonymous credentials (using Edwards curve cryptographic signature) that are random per chunk.
|
||||
- chunks have one of the sizes allowed by the servers - 64KB, 256KB, 1MB and 4MB chunks, so sending a large file looks indistinguishable from sending many small files to XFTP server. If the same transport connection is reused, server would only know that chunks are sent by the same user.
|
||||
- each chunk can be downloaded by multiple recipients, but each recipient uses their own key and chunk ID to authorize access, and the chunk is encrypted by a different key agreed via ephemeral DH keys (NaCl crypto_box (SalsaX20Poly1305 authenticated encryption scheme ) with shared secret derived from Curve25519 key exchange) on the way from the server to each recipient. XFTP protocol as a result has the same quality as SMP protocol - there are no identifiers and ciphertext in common between sent and received traffic inside TLS connection, so even if TLS is compromised, it complicates traffic correlation attacks.
|
||||
- XFTP protocol supports redundancy - each file chunk can be sent via multiple relays, and the recipient can choose the one that is available. Current implementation of XFTP protocol in SimpleX Chat does not support redundancy though.
|
||||
- the file as a whole is encrypted with a random symmetric key using NaCl secret_box.
|
||||
|
||||
## Cryptographic algorithms
|
||||
|
||||
Clients must cryptographically authorize XFTP commands, see [Command authentication](#command-authentication).
|
||||
|
||||
To authorize/verify transmissions clients and routers MUST use either signature algorithm Ed25519 algorithm defined in RFC8709 or using deniable authentication scheme based on NaCL crypto_box (see Simplex Messaging Protocol).
|
||||
To authorize/verify transmissions clients and servers MUST use either signature algorithm Ed25519 algorithm defined in RFC8709 or using deniable authentication scheme based on NaCL crypto_box (see Simplex Messaging Protocol).
|
||||
|
||||
To encrypt/decrypt data packet bodies delivered to the recipients, routers/clients MUST use NaCL crypto_box.
|
||||
To encrypt/decrypt file chunk bodies delivered to the recipients, servers/clients MUST use NaCL crypto_box.
|
||||
|
||||
Clients MUST encrypt data packet bodies sent via XFTP routers using use NaCL crypto_box.
|
||||
Clients MUST encrypt file chunk bodies sent via XFTP servers using use NaCL crypto_box.
|
||||
|
||||
## Data packet IDs
|
||||
## File chunk IDs
|
||||
|
||||
XFTP routers MUST generate a separate new set of IDs for each new packet - for the sender (that uploads the packet) and for each intended recipient. It is REQUIRED that:
|
||||
XFTP servers MUST generate a separate new set of IDs for each new chunk - for the sender (that uploads the chunk) and for each intended recipient. It is REQUIRED that:
|
||||
|
||||
- These IDs are different and unique within the router.
|
||||
- These IDs are different and unique within the server.
|
||||
- Based on random bytes generated with cryptographically strong pseudo-random number generator.
|
||||
|
||||
## Router security requirements
|
||||
## Server security requirements
|
||||
|
||||
XFTP router implementations MUST NOT create, store or send to any other routers:
|
||||
XFTP server implementations MUST NOT create, store or send to any other servers:
|
||||
|
||||
- Logs of the client commands and transport connections in the production environment.
|
||||
|
||||
- History of retrieved files.
|
||||
|
||||
- Snapshots of the database they use to store data packets (instead clients can manage redundancy by creating packet replicas using more than one XFTP router). In-memory persistence is recommended for data packets records.
|
||||
- Snapshots of the database they use to store file chunks (instead clients can manage redundancy by creating chunk replicas using more than one XFTP server). In-memory persistence is recommended for file chunks records.
|
||||
|
||||
- Any other information that may compromise privacy or [forward secrecy][4] of communication between clients using XFTP routers.
|
||||
- Any other information that may compromise privacy or [forward secrecy][4] of communication between clients using XFTP servers.
|
||||
|
||||
## Transport protocol
|
||||
|
||||
- binary-encoded commands sent as fixed-size padded block in the body of HTTP2 POST request, similar to SMP and notifications router protocol transmission encodings.
|
||||
- binary-encoded commands sent as fixed-size padded block in the body of HTTP2 POST request, similar to SMP and notifications server protocol transmission encodings.
|
||||
- HTTP2 POST with a fixed size padded block body for file upload and download.
|
||||
|
||||
Block size - 16384 bytes (it would fit ~350 Ed25519 recipient keys).
|
||||
Block size - 4096 bytes (it would fit ~120 Ed25519 recipient keys).
|
||||
|
||||
The reasons to use HTTP2:
|
||||
|
||||
@@ -308,41 +299,40 @@ The reason not to use URI segments / HTTP verbs / REST semantics is to have cons
|
||||
|
||||
### ALPN to agree handshake version
|
||||
|
||||
Client and router use [ALPN extension][18] of TLS to agree handshake version.
|
||||
Client and server use [ALPN extension][18] of TLS to agree handshake version.
|
||||
|
||||
Router SHOULD send `xftp/1` protocol name and the client should confirm this name in order to use the current protocol version. This is added to allow support of older clients without breaking backward compatibility and to extend or modify handshake syntax.
|
||||
Server SHOULD send `xftp/1` protocol name and the client should confirm this name in order to use the current protocol version. This is added to allow support of older clients without breaking backward compatibility and to extend or modify handshake syntax.
|
||||
|
||||
If the client does not confirm this protocol name, the router would fall back to v1 of XFTP protocol.
|
||||
If the client does not confirm this protocol name, the server would fall back to v1 of XFTP protocol.
|
||||
|
||||
### Transport handshake
|
||||
|
||||
When a client and a router agree on handshake version using ALPN extension, they should proceed with XFTP handshake.
|
||||
When a client and a server agree on handshake version using ALPN extension, they should proceed with XFTP handshake.
|
||||
|
||||
As with SMP, a client doesn't reveal its version range to avoid version fingerprinting. Unlike SMP, XFTP runs a HTTP2 protocol over TLS and the router can't just send its handshake right away. So a session handshake is driven by client-sent requests:
|
||||
As with SMP, a client doesn't reveal its version range to avoid version fingerprinting. Unlike SMP, XFTP runs a HTTP2 protocol over TLS and the server can't just send its handshake right away. So a session handshake is driven by client-sent requests:
|
||||
|
||||
1. To pass initiative to the router, the client sends a request with empty body.
|
||||
2. Router responds with its `paddedRouterHello` block.
|
||||
1. To pass initiative to the server, the client sends a request with empty body.
|
||||
2. Server responds with its `paddedServerHello` block.
|
||||
3. Clients sends a request containing `paddedClientHello` block,
|
||||
4. Router sends an empty response, finalizing the handshake.
|
||||
4. Server sends an empty response, finalizing the handshake.
|
||||
|
||||
Once TLS handshake is complete, client and router will exchange blocks of fixed size (16384 bytes).
|
||||
Once TLS handshake is complete, client and server will exchange blocks of fixed size (16384 bytes).
|
||||
|
||||
```abnf
|
||||
paddedRouterHello = <padded(routerHello, 16384)>
|
||||
routerHello = xftpVersionRange sessionIdentifier routerCerts signedRouterKey ignoredPart
|
||||
paddedServerHello = <padded(serverHello, 16384)>
|
||||
serverHello = xftpVersionRange sessionIdentifier serverCert signedServerKey ignoredPart
|
||||
xftpVersionRange = minXftpVersion maxXftpVersion
|
||||
minXftpVersion = xftpVersion
|
||||
maxXftpVersion = xftpVersion
|
||||
sessionIdentifier = shortString
|
||||
; unique session identifier derived from transport connection handshake
|
||||
routerCerts = length 1*routerCert ; NonEmpty list of certificates in chain
|
||||
routerCert = originalLength <x509encoded>
|
||||
signedRouterKey = originalLength <x509encoded> ; signed by router certificate
|
||||
serverCert = originalLength <x509encoded>
|
||||
signedServerKey = originalLength <x509encoded> ; signed by server certificate
|
||||
|
||||
paddedClientHello = <padded(clientHello, 16384)>
|
||||
clientHello = xftpVersion keyHash ignoredPart
|
||||
; chosen XFTP protocol version - must be the maximum supported version
|
||||
; within the range offered by the router
|
||||
; within the range offered by the server
|
||||
|
||||
xftpVersion = 2*2OCTET ; Word16 version number
|
||||
keyHash = shortString
|
||||
@@ -352,47 +342,47 @@ originalLength = 2*2OCTET
|
||||
ignoredPart = *OCTET
|
||||
```
|
||||
|
||||
In XFTP v2 the handshake is only used for version negotiation, but `routerCert` and `signedRouterKey` must be validated by the client.
|
||||
In XFTP v2 the handshake is only used for version negotiation, but `serverCert` and `signedServerKey` must be validated by the client.
|
||||
|
||||
`keyHash` is the CA fingerprint used by client to validate TLS certificate chain and is checked by a router against its own key.
|
||||
`keyHash` is the CA fingerprint used by client to validate TLS certificate chain and is checked by a server against its own key.
|
||||
|
||||
`ignoredPart` in handshake allows to add additional parameters in handshake without changing protocol version - the client and routers must ignore any extra bytes within the original block length.
|
||||
`ignoredPart` in handshake allows to add additional parameters in handshake without changing protocol version - the client and servers must ignore any extra bytes within the original block length.
|
||||
|
||||
For TLS transport client should assert that `sessionIdentifier` is equal to `tls-unique` channel binding defined in [RFC 5929][14] (TLS Finished message struct); we pass it in `routerHello` block to allow communication over some other transport protocol (possibly, with another channel binding).
|
||||
For TLS transport client should assert that `sessionIdentifier` is equal to `tls-unique` channel binding defined in [RFC 5929][14] (TLS Finished message struct); we pass it in `serverHello` block to allow communication over some other transport protocol (possibly, with another channel binding).
|
||||
|
||||
### Requests and responses
|
||||
|
||||
- File sender:
|
||||
- create data packet record.
|
||||
- create file chunk record.
|
||||
- Parameters:
|
||||
- Ed25519 key for subsequent sender commands and Ed25519 keys for commands of each recipient.
|
||||
- packet size.
|
||||
- chunk size.
|
||||
- Response:
|
||||
- packet ID for the sender and different IDs for all recipients.
|
||||
- add recipients to data packet
|
||||
- chunk ID for the sender and different IDs for all recipients.
|
||||
- add recipients to file chunk
|
||||
- Parameters:
|
||||
- sender's packet ID
|
||||
- sender's chunk ID
|
||||
- Ed25519 keys for commands of each recipient.
|
||||
- Response:
|
||||
- packet IDs for new recipients.
|
||||
- upload data packet.
|
||||
- delete data packet (invalidates all recipient IDs).
|
||||
- chunk IDs for new recipients.
|
||||
- upload file chunk.
|
||||
- delete file chunk (invalidates all recipient IDs).
|
||||
- File recipient:
|
||||
- download data packet:
|
||||
- packet ID
|
||||
- DH key for additional encryption of the packet.
|
||||
- command should be signed with the key passed by the sender when creating packet record.
|
||||
- delete data packet ID (only for one recipient): signed with the same key.
|
||||
- download file chunk:
|
||||
- chunk ID
|
||||
- DH key for additional encryption of the chunk.
|
||||
- command should be signed with the key passed by the sender when creating chunk record.
|
||||
- delete file chunk ID (only for one recipient): signed with the same key.
|
||||
|
||||
## XFTP commands
|
||||
|
||||
Commands syntax below is provided using ABNF with case-sensitive strings extension.
|
||||
|
||||
```abnf
|
||||
xftpCommand = ping / senderCommand / recipientCmd / routerMsg
|
||||
xftpCommand = ping / senderCommand / recipientCmd / serverMsg
|
||||
senderCommand = register / add / put / delete
|
||||
recipientCmd = get / ack
|
||||
routerMsg = pong / sndIds / rcvIds / ok / file / error
|
||||
serverMsg = pong / sndIds / rcvIds / ok / file
|
||||
```
|
||||
|
||||
The syntax of specific commands and responses is defined below.
|
||||
@@ -403,11 +393,11 @@ Commands are made via HTTP2 requests, responses to commands are correlated as HT
|
||||
|
||||
### Command authentication
|
||||
|
||||
XFTP routers must authenticate all transmissions (excluding `ping`) by verifying the client signatures. Command signature should be generated by applying the algorithm specified for the file to the `signed` block of the transmission, using the key associated with the data packet ID (recipient's or sender's depending on which data packet ID is used).
|
||||
XFTP servers must authenticate all transmissions (excluding `ping`) by verifying the client signatures. Command signature should be generated by applying the algorithm specified for the file to the `signed` block of the transmission, using the key associated with the file chunk ID (recipient's or sender's depending on which file chunk ID is used).
|
||||
|
||||
### Keep-alive command
|
||||
|
||||
To keep the transport connection alive and to generate noise traffic the clients should use `ping` command to which the router responds with `pong` response. This command should be sent unsigned and without data packet ID.
|
||||
To keep the transport connection alive and to generate noise traffic the clients should use `ping` command to which the server responds with `pong` response. This command should be sent unsigned and without file chunk ID.
|
||||
|
||||
```abnf
|
||||
ping = %s"PING"
|
||||
@@ -415,19 +405,21 @@ ping = %s"PING"
|
||||
|
||||
This command is always sent unsigned.
|
||||
|
||||
data FileResponse = ... | FRPong | ...
|
||||
|
||||
```abnf
|
||||
pong = %s"PONG"
|
||||
```
|
||||
|
||||
### File sender commands
|
||||
|
||||
Sending any of the commands in this section (other than `register`, that is sent without data packet ID) is only allowed with sender's ID. The `register` command must be signed (using `sndKey` included in `fileInfo` for verification) but must NOT include a data packet ID.
|
||||
Sending any of the commands in this section (other than `register`, that is sent without file chunk ID) is only allowed with sender's ID.
|
||||
|
||||
#### Register new data packet
|
||||
#### Register new file chunk
|
||||
|
||||
This command is sent by the sender to the XFTP router to register a new data packet.
|
||||
This command is sent by the sender to the XFTP server to register a new file chunk.
|
||||
|
||||
Routers SHOULD support basic auth with this command, to allow only router owners and trusted users to create data packets on the routers.
|
||||
Servers SHOULD support basic auth with this command, to allow only server owners and trusted users to create file chunks on the servers.
|
||||
|
||||
The syntax is:
|
||||
|
||||
@@ -435,7 +427,7 @@ The syntax is:
|
||||
register = %s"FNEW " fileInfo rcvPublicAuthKeys basicAuth
|
||||
fileInfo = sndKey size digest
|
||||
sndKey = length x509encoded
|
||||
size = 4*4 OCTET ; Word32 big-endian
|
||||
size = 1*DIGIT
|
||||
digest = length *OCTET
|
||||
rcvPublicAuthKeys = length 1*rcvPublicAuthKey
|
||||
rcvPublicAuthKey = length x509encoded
|
||||
@@ -446,7 +438,7 @@ x509encoded = <binary X509 key encoding>
|
||||
length = 1*1 OCTET
|
||||
```
|
||||
|
||||
If the data packet is registered successfully, the router must send `sndIds` response with the sender's and recipients' data packet IDs:
|
||||
If the file chunk is registered successfully, the server must send `sndIds` response with the sender's and recipients' file chunk IDs:
|
||||
|
||||
```abnf
|
||||
sndIds = %s"SIDS " senderId recipientIds
|
||||
@@ -455,9 +447,9 @@ recipientIds = length 1*recipientId
|
||||
recipientId = length *OCTET
|
||||
```
|
||||
|
||||
#### Add data packet recipients
|
||||
#### Add file chunk recipients
|
||||
|
||||
This command is sent by the sender to the XFTP router to add additional recipient keys to the data packet record, in case number of keys requested by client didn't fit into `register` command. The syntax is:
|
||||
This command is sent by the sender to the XFTP server to add additional recipient keys to the file chunk record, in case number of keys requested by client didn't fit into `register` command. The syntax is:
|
||||
|
||||
```abnf
|
||||
add = %s"FADD " rcvPublicAuthKeys
|
||||
@@ -465,7 +457,7 @@ rcvPublicAuthKeys = length 1*rcvPublicAuthKey
|
||||
rcvPublicAuthKey = length x509encoded
|
||||
```
|
||||
|
||||
If additional keys were added successfully, the router must send `rcvIds` response with the added recipients' data packet IDs:
|
||||
If additional keys were added successfully, the server must send `rcvIds` response with the added recipients' file chunk IDs:
|
||||
|
||||
```abnf
|
||||
rcvIds = %s"RIDS " recipientIds
|
||||
@@ -473,100 +465,66 @@ recipientIds = length 1*recipientId
|
||||
recipientId = length *OCTET
|
||||
```
|
||||
|
||||
#### Upload data packet
|
||||
#### Upload file chunk
|
||||
|
||||
This command is sent by the sender to the XFTP router to upload data packet body to router. The syntax is:
|
||||
This command is sent by the sender to the XFTP server to upload file chunk body to server. The syntax is:
|
||||
|
||||
```abnf
|
||||
put = %s"FPUT"
|
||||
```
|
||||
|
||||
Packet body is streamed via HTTP2 request.
|
||||
Chunk body is streamed via HTTP2 request.
|
||||
|
||||
If data packet body was successfully received, the router must send `ok` response.
|
||||
If file chunk body was successfully received, the server must send `ok` response.
|
||||
|
||||
```abnf
|
||||
ok = %s"OK"
|
||||
```
|
||||
|
||||
#### Delete data packet
|
||||
#### Delete file chunk
|
||||
|
||||
This command is sent by the sender to the XFTP router to delete data packet from the router. The syntax is:
|
||||
This command is sent by the sender to the XFTP server to delete file chunk from the server. The syntax is:
|
||||
|
||||
```abnf
|
||||
delete = %s"FDEL"
|
||||
```
|
||||
|
||||
Router should delete data packet record, invalidating all recipient IDs, and delete file body from file storage. If data packet was successfully deleted, the router must send `ok` response.
|
||||
Server should delete file chunk record, invalidating all recipient IDs, and delete file body from file storage. If file chunk was successfully deleted, the server must send `ok` response.
|
||||
|
||||
### File recipient commands
|
||||
|
||||
Sending any of the commands in this section is only allowed with recipient's ID.
|
||||
|
||||
#### Download data packet
|
||||
#### Download file chunk
|
||||
|
||||
This command is sent by the recipient to the XFTP router to download data packet body from the router. The syntax is:
|
||||
This command is sent by the recipient to the XFTP server to download file chunk body from the server. The syntax is:
|
||||
|
||||
```abnf
|
||||
get = %s"FGET " rDhKey
|
||||
rDhKey = length x509encoded
|
||||
```
|
||||
|
||||
If requested file is successfully located, the router must send `file` response. Data packet body is sent as HTTP2 response body.
|
||||
If requested file is successfully located, the server must send `file` response. File chunk body is sent as HTTP2 response body.
|
||||
|
||||
```abnf
|
||||
file = %s"FILE " sDhKey cbNonce
|
||||
sDhKey = length x509encoded
|
||||
cbNonce = 24*24 OCTET ; NaCl crypto_box nonce
|
||||
cbNonce = <nonce used in NaCl crypto_box encryption scheme>
|
||||
```
|
||||
|
||||
Packet is additionally encrypted on the way from the router to the recipient using a key agreed via ephemeral DH keys `rDhKey` and `sDhKey`, so there is no ciphertext in common between sent and received traffic inside TLS connection, in order to complicate traffic correlation attacks, if TLS is compromised.
|
||||
Chunk is additionally encrypted on the way from the server to the recipient using a key agreed via ephemeral DH keys `rDhKey` and `sDhKey`, so there is no ciphertext in common between sent and received traffic inside TLS connection, in order to complicate traffic correlation attacks, if TLS is compromised.
|
||||
|
||||
#### Acknowledge data packet download
|
||||
#### Acknowledge file chunk download
|
||||
|
||||
This command is sent by the recipient to the XFTP router to acknowledge file reception, deleting file ID from router for this recipient. The syntax is:
|
||||
This command is sent by the recipient to the XFTP server to acknowledge file reception, deleting file ID from server for this recipient. The syntax is:
|
||||
|
||||
```abnf
|
||||
ack = %s"FACK"
|
||||
```
|
||||
|
||||
If file recipient ID is successfully deleted, the router must send `ok` response.
|
||||
If file recipient ID is successfully deleted, the server must send `ok` response.
|
||||
|
||||
In current implementation of XFTP protocol in SimpleX Chat clients don't use FACK command. Files are automatically expired on routers after configured time interval.
|
||||
|
||||
### Error responses
|
||||
|
||||
The router responds with `ERR` followed by the error type:
|
||||
|
||||
```abnf
|
||||
error = %s"ERR " errorType
|
||||
errorType = %s"BLOCK" / %s"SESSION" / %s"HANDSHAKE" /
|
||||
%s"CMD" SP cmdError / %s"AUTH" / %s"BLOCKED" SP blockingInfo /
|
||||
%s"SIZE" / %s"QUOTA" / %s"DIGEST" / %s"CRYPTO" /
|
||||
%s"NO_FILE" / %s"HAS_FILE" / %s"FILE_IO" /
|
||||
%s"TIMEOUT" / %s"INTERNAL"
|
||||
cmdError = %s"UNKNOWN" / %s"SYNTAX" / %s"PROHIBITED" / %s"NO_AUTH" / %s"HAS_AUTH" / %s"NO_ENTITY"
|
||||
blockingInfo = %s"reason=" blockingReason ["," %s"notice=" jsonNotice]
|
||||
blockingReason = %s"spam" / %s"content"
|
||||
jsonNotice = *OCTET ; JSON-encoded notice object
|
||||
```
|
||||
|
||||
Error types:
|
||||
- `BLOCK` - incorrect block format, encoding or signature size.
|
||||
- `SESSION` - incorrect session ID (TLS Finished message / tls-unique binding).
|
||||
- `HANDSHAKE` - incorrect handshake command.
|
||||
- `CMD` - command syntax errors (UNKNOWN, SYNTAX, PROHIBITED, NO_AUTH, HAS_AUTH, NO_ENTITY).
|
||||
- `AUTH` - command authorization error - bad signature or non-existing data packet.
|
||||
- `BLOCKED` - data packet was blocked due to policy violation (added in v3). Contains blocking reason and optional notice.
|
||||
- `SIZE` - incorrect file size.
|
||||
- `QUOTA` - storage quota exceeded.
|
||||
- `DIGEST` - incorrect file digest.
|
||||
- `CRYPTO` - file encryption/decryption failed.
|
||||
- `NO_FILE` - no expected file body in request/response or no file on the router.
|
||||
- `HAS_FILE` - unexpected file body.
|
||||
- `FILE_IO` - file IO error.
|
||||
- `TIMEOUT` - file sending or receiving timeout.
|
||||
- `INTERNAL` - internal router error.
|
||||
In current implementation of XFTP protocol in SimpleX Chat clients don't use FACK command. Files are automatically expired on servers after configured time interval.
|
||||
|
||||
## Threat model
|
||||
|
||||
@@ -575,7 +533,7 @@ Error types:
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of routers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
- A user's choice of servers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
@@ -583,7 +541,7 @@ Error types:
|
||||
|
||||
- identify that and when a user is sending files over XFTP protocol.
|
||||
|
||||
- determine which routers the user sends/receives files to/from.
|
||||
- determine which servers the user sends/receives files to/from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
@@ -595,11 +553,11 @@ Error types:
|
||||
|
||||
*can:*
|
||||
|
||||
- learn which XFTP routers are used to send and receive files for which users.
|
||||
- learn which XFTP servers are used to send and receive files for which users.
|
||||
|
||||
- learn when files are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the routers.
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
@@ -609,31 +567,31 @@ Error types:
|
||||
|
||||
- perform traffic correlation attacks.
|
||||
|
||||
#### XFTP router
|
||||
#### XFTP server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when file senders and recipients are online.
|
||||
|
||||
- know how many data packets and packet sizes are sent via the router.
|
||||
- know how many file chunks and chunk sizes are sent via the server.
|
||||
|
||||
- perform the correlation of the data packets as belonging to one file via either a re-used transport connection, user's IP address, or connection timing regularities.
|
||||
- perform the correlation of the file chunks as belonging to one file via either a re-used transport connection, user's IP address, or connection timing regularities.
|
||||
|
||||
- learn file senders' and recipients' IP addresses, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- delete data packets, preventing file delivery, as long as redundant delivery is not used.
|
||||
- delete file chunks, preventing file delivery, as long as redundant delivery is not used.
|
||||
|
||||
- lie about the state of a data packet to the recipient and/or to the sender (e.g. deleted when it is not).
|
||||
- lie about the state of a file chunk to the recipient and/or to the sender (e.g. deleted when it is not).
|
||||
|
||||
- refuse deleting the file when instructed by the sender.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably corrupt data packets.
|
||||
- undetectably corrupt file chunks.
|
||||
|
||||
- learn the contents, name or the exact size of sent files.
|
||||
|
||||
- learn approximate size of sent files, as long as more than one router is used to send data packets.
|
||||
- learn approximate size of sent files, as long as more than one server is used to send file chunks.
|
||||
|
||||
- compromise the users' end-to-end encryption of files with an active attack.
|
||||
|
||||
@@ -645,7 +603,7 @@ Error types:
|
||||
|
||||
- receive all files sent and received by Alice that did not expire yet, as long as information about these files was not removed from the database.
|
||||
|
||||
- prevent Alice's contacts from receiving the files she sent by deleting all or some of the data packets from XFTP routers.
|
||||
- prevent Alice's contacts from receiving the files she sent by deleting all or some of the file chunks from XFTP servers.
|
||||
|
||||
#### A user's contact
|
||||
|
||||
@@ -667,10 +625,10 @@ Error types:
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service XFTP routers.
|
||||
- Denial of Service XFTP servers.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send files to a user who they are not connected with.
|
||||
|
||||
- enumerate data packets on an XFTP router.
|
||||
- enumerate file chunks on an XFTP server.
|
||||
|
||||
@@ -10,7 +10,7 @@ Version 1, 2024-06-22
|
||||
- [Session invitation](#session-invitation)
|
||||
- [Establishing TLS connection](#establishing-tls-connection)
|
||||
- [Session verification and protocol negotiation](#session-verification-and-protocol-negotiation)
|
||||
- [Controller/host session operation](#controllerhost-session-operation)
|
||||
- [Controller/host session operation](#сontrollerhost-session-operation)
|
||||
- [Key agreement for announcement packet and for session](#key-agreement-for-announcement-packet-and-for-session)
|
||||
- [Threat model](#threat-model)
|
||||
|
||||
@@ -104,11 +104,12 @@ Multicast session announcement is a binary encoded packet with this syntax:
|
||||
```abnf
|
||||
sessionAddressPacket = dhPubKey nonce encrypted(unpaddedSize sessionAddress packetPad)
|
||||
dhPubKey = length x509encoded ; same as announced
|
||||
nonce = 24*24 OCTET ; NaCl 192-bit nonce, no length prefix
|
||||
sessionAddress = sessionAddressUri ; length given by unpaddedSize
|
||||
nonce = length *OCTET
|
||||
sessionAddress = largeLength sessionAddressUri ; as above
|
||||
length = 1*1 OCTET ; for binary data up to 255 bytes
|
||||
largeLength = 2*2 OCTET ; for binary data up to 65535 bytes
|
||||
packetPad = <pad invitation content to 900 bytes before encryption>
|
||||
packetPad = <pad packet size to 1450 bytes> ; possibly, we may need to move KEM agreement one step later,
|
||||
; with encapsulation key in HELLO block and KEM ciphertext in reply to HELLO.
|
||||
```
|
||||
|
||||
### Establishing TLS connection
|
||||
@@ -142,7 +143,7 @@ hostHello = %s"HELLO " dhPubKey nonce encrypted(unpaddedSize hostHelloJSON hello
|
||||
unpaddedSize = largeLength
|
||||
dhPubKey = length x509encoded
|
||||
pad = <pad block size to 16384 bytes>
|
||||
helloPad = <pad hello size to 12288 bytes>
|
||||
helloPad = <pad hello size to 12888 bytes>
|
||||
largeLength = 2*2 OCTET
|
||||
```
|
||||
|
||||
@@ -156,7 +157,10 @@ The controller decrypts (including the first session) and validates the received
|
||||
{
|
||||
"definitions": {
|
||||
"version": {
|
||||
"type": "uint16"
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "[0-9]+"
|
||||
}
|
||||
},
|
||||
"base64url": {
|
||||
"type": "string",
|
||||
@@ -168,7 +172,9 @@ The controller decrypts (including the first session) and validates the received
|
||||
"properties": {
|
||||
"v": {"ref": "version"},
|
||||
"ca": {"ref": "base64url"},
|
||||
"kem": {"ref": "base64url"},
|
||||
"kem": {"ref": "base64url"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"app": {"properties": {}, "additionalProperties": true}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -184,7 +190,7 @@ ctrlHello = %s"HELLO " kemCiphertext encrypted(unpaddedSize ctrlHelloJSON helloP
|
||||
unpaddedSize = largeLength
|
||||
kemCiphertext = largeLength *OCTET
|
||||
pad = <pad block size to 16384 bytes>
|
||||
helloPad = <pad hello size to 12288 bytes>
|
||||
helloPad = <pad hello size to 12888 bytes>
|
||||
largeLength = 2*2 OCTET
|
||||
|
||||
ctrlError = %s"ERROR " nonce encrypted(unpaddedSize ctrlErrorMessage helloPad) pad
|
||||
@@ -200,7 +206,7 @@ JTD schema for the encrypted part of controller HELLO block `ctrlHelloJSON`:
|
||||
}
|
||||
```
|
||||
|
||||
Controller `hello` block and all subsequent protocol messages are encrypted with the chain keys derived from the hybrid key (see key exchange below) - that is why controller hello block does not include nonce. That provides forward secrecy within the XRCP session. Receiving this `hello` block allows host to compute the same hybrid keys and to derive the same chain keys.
|
||||
Controller `hello` block and all subsequent protocol messages are encrypted with the chain keys derived from the hybrid key (see key exchange below) - that is why conntroller hello block does not include nonce. That provides forward secrecy within the XRCP session. Receiving this `hello` block allows host to compute the same hybrid keys and to derive the same chain keys.
|
||||
|
||||
Once the controller replies HELLO to the valid host HELLO block, it should stop accepting new TCP connections.
|
||||
|
||||
@@ -255,7 +261,7 @@ kemCiphertext(1) = enc(kemSecret(1), kemEncKey(1))
|
||||
kemSecret(1) = dec(kemCiphertext(1), kemDecKey(1))
|
||||
|
||||
// multicast announcement for session n
|
||||
announcementSecret(n) = dhSecret(n')
|
||||
announcementSecret(n) = sha256(dhSecret(n'))
|
||||
dhSecret(n') = dh(hostHelloDhKey(n - 1), controllerDhKey(n))
|
||||
|
||||
// session n
|
||||
@@ -271,11 +277,11 @@ If controller fails to store the new host DH key after receiving HELLO block, th
|
||||
|
||||
To decrypt a multicast announcement, the host should try to decrypt it using the keys of all known (paired) remote controllers.
|
||||
|
||||
Once sessionSecret is agreed for the session, it is used to derive two chain keys, to receive and to send messages:
|
||||
Once kemSecret is agreed for the session, it is used to derive two chain keys, to receive and to send messages:
|
||||
|
||||
```
|
||||
controller: sndKey, rcvKey = HKDF(sessionSecret, "SimpleXSbChainInit", 64)
|
||||
host: rcvKey, sndKey = HKDF(sessionSecret, "SimpleXSbChainInit", 64)
|
||||
host: sndKey, rcvKey = HKDF(kemSecret, "SimpleXSbChainInit", 64)
|
||||
controller: rcvKey, sndKey = HKDF(kemSecret, "SimpleXSbChainInit", 64)
|
||||
```
|
||||
|
||||
where HKDF is based on SHA512, with empty salt.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
## Problem
|
||||
|
||||
When sending an SMP confirmation a network timeout can lead to the following race condition:
|
||||
- router receives the confirmation while the joining party fails to receive the router's response;
|
||||
- server receives the confirmation while the joining party fails to receive the server's response;
|
||||
- joining party deletes the connection together with credentials sent in the confirmation for securing the queue;
|
||||
- initiating party will receive the confirmation from the router and secure the queue;
|
||||
- initiating party will receive the confirmation from the server and secure the queue;
|
||||
- on subsequent attempt to join via the same invitation link initiating party will generate new credentials and fail authorization.
|
||||
|
||||
This renders the joining party permanently unable to join via that invitation link and complete the connection.
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
---
|
||||
Proposed: 2024-02-12
|
||||
Implemented: ~2024 (SMP v11)
|
||||
Standardized: 2026-03-10
|
||||
Protocol: simplex-messaging
|
||||
---
|
||||
|
||||
> **Implementation note:** This RFC was promoted from done/ to standard/ based on verification that the described feature exists in the codebase. The RFC text reflects the original proposal and may not match the actual implementation in all details. The consolidated protocol specifications in `protocol/` are the authoritative reference for current behavior.
|
||||
|
||||
# Transmission encryption
|
||||
|
||||
## Problems
|
||||
|
||||
### Protection of meta-data from sending proxy
|
||||
|
||||
The SEND commands and message queue IDs need to be encrypted so that sending proxy cannot see how many queues exist on each router.
|
||||
The SEND commands and message queue IDs need to be encrypted so that sending proxy cannot see how many queues exist on each server.
|
||||
|
||||
Correlation IDs need to be random and can be re-used as nonces so that the destination relay cannot use the increasing correlation IDs that are sent in v6 of the protocol to track the sender.
|
||||
|
||||
@@ -33,10 +24,10 @@ encRespTransmission = replyNonce encrypted(respTransmission)
|
||||
respTransmission = entityId command
|
||||
```
|
||||
|
||||
The keys to encrypt and decrypt both the command and responses would be computed as curve25519 from the key sent together with command and router session key. For the requests, the nonce has to be random and sent outside of the encrypted envelope, but for the response respNonce would be taken from inside of the encrypted envelope and it would also be used for correlating commands and responses. This way the attacker who could compromise TLS would not be able to correlate the commands and responses, and also observe entity IDs.
|
||||
The keys to encrypt and decrypt both the command and responses would be computed as curve25519 from the key sent together with command and server session key. For the requests, the nonce has to be random and sent outside of the encrypted envelopt, but for the response respNonce would be taken from inside of the encrypted envelope and it would also be used for correlating commands and responses. This way the attacker who could compromise TLS would not be able to correlate the commands and responses, and also observe entity IDs.
|
||||
|
||||
2. The remaining question is to how encrypt and decrypt messages delivered not in response to the commands.
|
||||
|
||||
The possible options are:
|
||||
- restore client session key only for that purpose, but do not forward this key to the destination proxy for sent messages. Then the messages can be sent with a random replyNonce and the key would be computed from session keys. The advantage here is that we won't need to parameterize handles as both client and server would have session keys. The downside that we would have to either somehow differentiate messages and responses, either by some flag that would allow some correlation or just by the absense of replyNonce in the lookup map - that is if the client can find replyNonce, it would use the associated key to decrypt, and if not it would use session key.
|
||||
- use the same key that was sent with SUB or ACK command. This is much more complex, and would only have some upside if we were to introduce receiving proxies (to conceal transport sessions from the receiving routers for the recipients).
|
||||
- use the same key that was sent with SUB or ACK command. This is much more complex, and would only have some upside if we were to introduce receiving proxies (to conceal transport sessions from the receiving relays for the recipients).
|
||||
@@ -1,17 +1,8 @@
|
||||
---
|
||||
Proposed: 2024-03-20
|
||||
Implemented: ~2024
|
||||
Standardized: 2026-03-10
|
||||
Protocol: simplex-messaging
|
||||
---
|
||||
|
||||
> **Implementation note:** This RFC was promoted from done/ to standard/ based on verification that the described feature exists in the codebase. The RFC text reflects the original proposal and may not match the actual implementation in all details. The consolidated protocol specifications in `protocol/` are the authoritative reference for current behavior.
|
||||
|
||||
# Relay metadata and SimpleX network decentralization
|
||||
|
||||
## Problem
|
||||
|
||||
Currently, the clients configure/choose which routers to use, but they cannot see who operates them, in which geography and hosting provider, what is the router source code (in case it was modified from the reference implementation we provide) and also any administrative and feedback contacts.
|
||||
Currently, the clients configure/choose which servers to use, but they cannot see who operates them, in which geography and hosting provider, what is the server source code (in case it was modified from the reference implementation we provide) and also any administrative and feedback contacts.
|
||||
|
||||
Further, we currently use simplex.chat domain to host group links, and as diversity of the groups grows it is beginning to require managing feedback from the users about groups. It is important that this feedback is directed to relay owners and not to us, in case they are not our relays, as we are simply providing software here.
|
||||
|
||||
@@ -30,28 +21,28 @@ While this document is not the end of the journey to decentralize the network, i
|
||||
|
||||
The proposed solution consists of two parts:
|
||||
|
||||
- communicate router metadata via protocol, so it can be observed by the clients.
|
||||
- communicate server metadata via protocol, so it can be observed by the clients.
|
||||
- create home page for the relays, with all the same metadata.
|
||||
- create invitation and address links in the same domain name as the relay.
|
||||
|
||||
The latter point is important so it is clear to the users who operates and owns the relay and where the access point to the content or group is hosted. Even though simplex.chat domain is never accessed by the app, and the meaningful part of the address is never sent to the page hosting router, it creates an impression of centralization, and some dependency on simplex.chat domain for anything other that showing the link QR code.
|
||||
The latter point is important so it is clear to the users who operates and owns the relay and where the access point to the content or group is hosted. Even though simplex.chat domain is never accessed by the app, and the meaningful part of the address is never sent to the page hosting server, it creates an impression of centralization, and some dependency on simplex.chat domain for anything other that showing the link QR code.
|
||||
|
||||
Moving invitation links to the domain of the relay (primary relay, in case the link has redundancy) will both clarify relay ownership, solve the incorrect mis-perception of centralization, remove the dependency on simplex-chat domain without any user effort, and provides the means to submit content complaints to the relay operators (should they wish to receive them, which seems reasonable for large public relays, but may be unnecessary for private relays where unidentified parties cannot create links).
|
||||
|
||||
## Solution details
|
||||
|
||||
Extend router INI file with information section:
|
||||
Extend server INI file with information section:
|
||||
|
||||
```
|
||||
[INFORMATION]
|
||||
# Please note that under AGPLv3 license conditions you MUST make
|
||||
# any source code modifications available to the end users of the router.
|
||||
# any source code modifications available to the end users of the server.
|
||||
# LICENSE: https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE
|
||||
# Not doing so would constitute a license violation.
|
||||
# Declaring an incorrect information here amounts to a fraud.
|
||||
# The license holders reserve the right to prosecute missing or incorrect
|
||||
# information about the server source code to the fullest extent permitted by the law.
|
||||
# The router will show warning on start if this field is absent
|
||||
# The server will show warning on start if this field is absent
|
||||
# and will not launch from v6.0 until this field is added.
|
||||
# If any other information field is present, source code property also MUST be present.
|
||||
source_code: https://github.com/simplex-chat/simplexmq
|
||||
@@ -78,13 +69,13 @@ hosting: Linode / Akamai Inc.
|
||||
hosting_country: US
|
||||
```
|
||||
|
||||
Router home page would show whether queue creation is allowed and/or password protected, router retention policy (e.g., preserve messages on restart or not, and persist connections or not).
|
||||
Server home page would show whether queue creation is allowed and/or password protected, server retention policy (e.g., preserve messages on restart or not, and persist connections or not).
|
||||
|
||||
Router queue address/contact pages will optionally, provide the UI to submit feedback, comments and complaints directly from the web page (not an MVP, initially we would simply show addresses for feedback, and, probably, create link that opens in the app with pre-populated message, and we could also use this addresses defined in router meta-data to submit feedback from inside of the app - it's also out of MVP scope).
|
||||
Server queue address/contact pages will optionally, provide the UI to submit feedback, comments and complaints directly from the web page (not an MVP, initially we would simply show addresses for feedback, and, probably, create link that opens in the app with pre-populated message, and we could also use this addresses defined in server meta-data to submit feedback from inside of the app - it's also out of MVP scope).
|
||||
|
||||
If router is available on .onion address, the web pages would show "open via .onion" in Tor browser.
|
||||
If server is available on .onion address, the web pages would show "open via .onion" in Tor browser.
|
||||
|
||||
Extend router handshake header with these information fields:
|
||||
Extend server handshake header with these information fields:
|
||||
|
||||
```haskell
|
||||
data ServerHandshake = ServerHandshake
|
||||
@@ -102,13 +93,13 @@ data ServerInformation = ServerInformation
|
||||
info :: ServerPublicInfo
|
||||
}
|
||||
|
||||
-- based on router configuration
|
||||
-- based on server configuration
|
||||
data ServerPublicConfig = ServerPublicConfig
|
||||
{ persistence :: SMPServerPersistenceMode,
|
||||
messageExpiration :: Int,
|
||||
statsEnabled :: Bool,
|
||||
newQueuesAllowed :: Bool,
|
||||
basicAuthEnabled :: Bool -- router is private if enabled
|
||||
basicAuthEnabled :: Bool -- server is private if enabled
|
||||
}
|
||||
|
||||
-- based on INFORMATION section of INI file
|
||||
@@ -136,4 +127,4 @@ data ServerContactAddress = ServerContactAddress
|
||||
}
|
||||
```
|
||||
|
||||
This extended router information will be stored in the chat database every time it changes and shown in the UI of the router configuration.
|
||||
This extended server information will be stored in the chat database every time it changes and shown in the UI of the server configuration.
|
||||
@@ -1,12 +1,3 @@
|
||||
---
|
||||
Proposed: 2024-06-01
|
||||
Implemented: ~2024
|
||||
Standardized: 2026-03-10
|
||||
Protocol: agent-protocol
|
||||
---
|
||||
|
||||
> **Implementation note:** This RFC was promoted from done/ to standard/ based on verification that the described feature exists in the codebase. The RFC text reflects the original proposal and may not match the actual implementation in all details. The consolidated protocol specifications in `protocol/` are the authoritative reference for current behavior.
|
||||
|
||||
# Evolving agent API
|
||||
|
||||
## Problem
|
||||
@@ -1,12 +1,3 @@
|
||||
---
|
||||
Proposed: 2024-06-21
|
||||
Implemented: ~2025 (SMP v15)
|
||||
Standardized: 2026-03-10
|
||||
Protocol: simplex-messaging + agent-protocol
|
||||
---
|
||||
|
||||
> **Implementation note:** This RFC was promoted from done/ to standard/ based on verification that the described feature exists in the codebase. The RFC text reflects the original proposal and may not match the actual implementation in all details. The consolidated protocol specifications in `protocol/` are the authoritative reference for current behavior.
|
||||
|
||||
# Short invitation links
|
||||
|
||||
## Problem
|
||||
@@ -23,7 +14,7 @@ Additionally, if we store short links, they can also include chat preferences an
|
||||
|
||||
MITM-resistant link shortening.
|
||||
|
||||
Instead of generating the random address that would resolve into the link - doing so would create the possibility of MITM by the router hosting this link - we can use private key as the link ID that will be passed to the accepting party, and the hash of the public key as ID for the router - the accepting party would present this key itself as ID and it will also be used for router to client encryption (see Protocol below). HKDF will be used to derive symmetric key from private key and used in secret_box together with random nonce (to allow replacing data with the same key but with a different nonce - nonce will be sent to the router too). secret_box construction is authenticated encryption, so it would protect from MITM.
|
||||
Instead of generating the random address that would resolve into the link - doing so would create the possibility of MITM by the server hosting this link - we can use private key as the link ID that will be passed to the accepting party, and the hash of the public key as ID for the server - the accepting party would present this key itself as ID and it will also be used for server to client encryption (see Protocol below). HKDF will be used to derive symmetric key from private key and used in secret_box together with random nonce (to allow replacing data with the same key but with a different nonce - nonce will be sent to the server too). secret_box construction is authenticated encryption, so it would protect from MITM.
|
||||
|
||||
The proposed syntax:
|
||||
|
||||
@@ -38,7 +29,7 @@ srvHosts = <hostname> ["," srvHosts] ; RFC1123, RFC5891
|
||||
linkHash = <base64url encoded SHA256 or SHA512 hash of the original link>
|
||||
```
|
||||
|
||||
If SMP router supports pages, its name can be used as clientAppServer, without repeating it after #, for a shorter link.
|
||||
If SMP server supports pages, its name can be used as clientAppServer, without repeating it after #, for a shorter link.
|
||||
|
||||
Example link:
|
||||
|
||||
@@ -49,12 +40,12 @@ https://simplex.chat/contact/#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.
|
||||
This link has the length of ~136 characters (256 bits), which is shorter than the full contact address (~310 characters) and much shorter than invitation links (~528 characters) even without post-quantum keys added to them.
|
||||
|
||||
This size can be further reduced by
|
||||
- use router domain in the link.
|
||||
- do not include onion address, as the connection happens via proxy anyway, if it's untrusted router.
|
||||
- not pinning router TLS certificate - the downside here is that while the attack that compromises TLS will not be able to substitute the link (because it's hash will not match), it will be able to intercept and to block it.
|
||||
- use server domain in the link.
|
||||
- do not include onion address, as the connection happens via proxy anyway, if it's untrusted server.
|
||||
- not pinning server TLS certificate - the downside here is that while the attack that compromises TLS will not be able to substitute the link (because it's hash will not match), it will be able to intercept and to block it.
|
||||
- using shorter hash, e.g. SHA128 - reducing the collision resistance.
|
||||
|
||||
If the router is known, the client could use its hash and onion address, otherwise it could trust the proxy to use any existing session with the same hostname or to accept the risk of interception - given that there is no risk of substitution.
|
||||
If the server is known, the client could use it's hash and onion address, otherwise it could trust the proxy to use any existing session with the same hostname or to accept the risk of interception - given that there is no risk of substitution.
|
||||
|
||||
With the first two of these "improvements" the link could be ~122 characters:
|
||||
|
||||
@@ -68,13 +59,13 @@ If onion address is preserved the link will be ~184 characters (won't fit in Twi
|
||||
https://smp8.simplex.im/contact/#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU@beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion/abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
If we implement it, the request to resolve the link would be made via proxied SMP command (to avoid the direct connection between the client and the recipient's router).
|
||||
If we implement it, the request to resolve the link would be made via proxied SMP command (to avoid the direct connection between the client and the recipient's server).
|
||||
|
||||
Pros:
|
||||
- a bit shorter link.
|
||||
- possibility to include post-quantum keys into the full link keeping the same shortened link size.
|
||||
- possibility to include chat profile of contact or group, and preferences, for a much better connection experience, and to show this information when the link sent in the conversation (clients can resolve them automatically, without connecting - it can be resolved by the sending clients).
|
||||
- router will not have access to the link.
|
||||
- server will not have access to the link.
|
||||
|
||||
Cons:
|
||||
- protocol complexity.
|
||||
@@ -84,7 +75,7 @@ Pros are a huge improvement of UX of connecting both within and from outside of
|
||||
|
||||
## Protocol
|
||||
|
||||
To support short links, the SMP routers would provide a simple key-value store enabled by three additional commands: `WRT`, `CLR` and `READ`
|
||||
To support short links, the SMP servers would provide a simple key-value store enabled by three additional commands: `WRT`, `CLR` and `READ`
|
||||
|
||||
`WRT` command is used to store and to update values in the store. The size of the value is limited by the same size as sent messages (or, possibly, smaller - as connection information size used in confirmation messages) - the clients would use this fixed size irrespective of the content. `WRT` command will be sent with the data blob ID in the transaction entityId field, public authorization key used to authorize `WRT` and `CLR` commands (subsequent WRT commands to the existing key must use the same key), and the data blob.
|
||||
|
||||
@@ -98,22 +89,22 @@ To support short links, the SMP routers would provide a simple key-value store e
|
||||
|
||||
- the data blob owner generates X25519 key pair: `(k, pk)`.
|
||||
- private key `pk` will be included in the short link shared with the other party (only base64url encoded key bytes, not X509 encoding).
|
||||
- `HKDF(pk)` will be used to encrypt the link data with secret_box before storing it on the router.
|
||||
- `HKDF(pk)` will be used to encrypt the link data with secret_box before storing it on the server.
|
||||
- the hash of public key `sha256(k)` will be used as ID by the owner to store and to remove the data blob (`WRT` and `CLR` commands).
|
||||
|
||||
**Retrieve data blob**
|
||||
|
||||
- the sender uses the public key `k` derived from the private key `pk` included in the link as entity ID to retrieve data blob (the router will compute the ID used by the owner as `sha256(k)` and will be able to look it up). This provides the quality that the traffic of the parties has no shared IDs inside TLS. It also means that unlike message queue creation, the ID to retrieve the blob was never sent to the blob creator, and also is not known to the router in advance (the second part is only an observation, in itself it does not increase security, as router has access to an encrypted blob anyway).
|
||||
- the sender uses the public key `k` derived from the private key `pk` included in the link as entity ID to retrieve data blob (the server will compute the ID used by the owner as `sha256(k)` and will be able to look it up). This provides the quality that the traffic of the parties has no shared IDs inside TLS. It also means that unlike message queue creation, the ID to retrieve the blob was never sent to the blob creator, and also is not known to the server in advance (the second part is only an observation, in itself it does not increase security, as server has access to an encrypted blob anyway).
|
||||
- note that the sender does not authorize the request to retrieve the blob, as it would not increase security unless a different key is used to authorize, and adding a key would increase link size.
|
||||
- router session keys with the sender will be `(sk, spk)`, where `sk` is public key shared with the sender during session handshake, and `spk` is the private key known only to the router.
|
||||
- this public key `k` will also be combined with router session key `spk` using `dh(k, spk)` to encrypt the response, so that there is no ciphertext in common in sent and received traffic for these blobs. Correlation ID will be used as a nonce for this encryption.
|
||||
- server session keys with the sender will be `(sk, spk)`, where `sk` is public key shared with the sender during session handshake, and `spk` is the private key known only to the server.
|
||||
- this public key `k` will also be combined with server session key `spk` using `dh(k, spk)` to encrypt the response, so that there is no ciphertext in common in sent and received traffic for these blobs. Correlation ID will be used as a nonce for this encryption.
|
||||
- having received the blob, the client can now decrypt it using secret_box with `HKDF(pk)`.
|
||||
|
||||
Using the same key as ID for the request, and also to additionally encrypt the response allows to use a single key in the link, without increasing the link size.
|
||||
|
||||
## Threat model
|
||||
|
||||
**Compromised SMP router**
|
||||
**Compromised SMP server**
|
||||
|
||||
can:
|
||||
- delete link data.
|
||||
@@ -3,12 +3,12 @@
|
||||
## Problem
|
||||
|
||||
iOS notifications may fail to deliver for several reasons, but there are two important reasons that we could address:
|
||||
- when notification router is not subscribed to SMP router(s), the notifications can be dropped - it can happen because either notification router restarts or becuase SMP router restarted and some messages are received before notification router resubscribed. We lose approximately 3% of notifications because of this reason.
|
||||
- when notification server is not subscribed to SMP server(s), the notifications can be dropped - it can happen because either notification server restarts or becuase SMP server restarted and some messages are received before notification server resubscribed. We lose approximately 3% of notifications because of this reason.
|
||||
- when user device is offline or has low power condition, Apple does not deliver notification, but puts them to storage. If while the notification is in storage a new one arrives it would overwrite the previous notification. If it was the message to the same message queue, the client will download messages anyway, up to a limit, but if the message was to another queue, it will not be delivered until the app is opened. Apple delivers about 88% of notifications that should be delivered (not accounting for uninstalled apps), the rest is replaced with the newer notifications.
|
||||
|
||||
## Solution
|
||||
|
||||
The first problem can be solved by preserving notifications for a limited time (say 1 hour) in case there is no subscription to notification from notification router. At the very least, they can be preserved in SMP router memory but can also be stored to a file on restart, similar to messages, and be delivered when notification router resubscribes. It is sufficient to store one notification per messaging queue.
|
||||
The first problem can be solved by preserving notifications for a limited time (say 1 hour) in case there is no subscription to notification from notification server. At the very least, they can be preserved in SMP server memory but can also be stored to a file on restart, similar to messages, and be delivered when notification server resubscribes. It is sufficient to store one notification per messaging queue.
|
||||
|
||||
The second problem is both more damaging and more complex to solve. The solution could be to always deliver several last notifications to different queues in one packet (Apple allows up to ~4-5kb notification size, and we are sending packets of fixed size 512 bytes, so we could fit up to 8-10 of them in each notification).
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
# SMP router message storage
|
||||
# SMP server message storage
|
||||
|
||||
## Problem
|
||||
|
||||
Currently SMP routers store all queues in router memory. As the traffic grows, so does the number of undelivered messages. What is worse, Haskell is not avoiding heap fragmentation when messages are allocated and then de-allocated - undelivered messages use ByteString and GC cannot move them around, as they use pinned memory.
|
||||
Currently SMP servers store all queues in server memory. As the traffic grows, so does the number of undelivered messages. What is worse, Haskell is not avoiding heap fragmentation when messages are allocated and then de-allocated - undelivered messages use ByteString and GC cannot move them around, as they use pinned memory.
|
||||
|
||||
## Possible solutions
|
||||
|
||||
@@ -11,7 +10,7 @@ Currently SMP routers store all queues in router memory. As the traffic grows, s
|
||||
|
||||
Move from ByteString to some other primitive to store messages in memory long term, e.g. ShortByteString, or manage allocation/de-allocation of stored messages manually in some other way.
|
||||
|
||||
Pros: the simplest solution that avoids substantial re-engineering of the router.
|
||||
Pros: the simplest solution that avoids substantial re-engineering of the server.
|
||||
|
||||
Cons:
|
||||
- not a long term solution, as memory growth still has limits.
|
||||
@@ -23,12 +22,12 @@ Use files or RocksDB to store messages.
|
||||
|
||||
Pros:
|
||||
- much lower memory usage.
|
||||
- no message loss in case of abnormal router termination (important until clients have delivery redundancy).
|
||||
- no message loss in case of abnormal server termination (important until clients have delivery redundancy).
|
||||
- this is a long term solution, and at some point it might need to be done anyway.
|
||||
|
||||
Cons:
|
||||
- substantial re-engineering costs and risks.
|
||||
- metadata privacy. Currently we only save undelivered messages when router is restarted, with this approach all messages will be stored for some time. this argument is limited, as hosting providers of VMs can make memory snapshots too, on the other hand they are harder to analyze than files. On another hand, with this approach messages will be stored for a shorter time.
|
||||
- metadata privacy. Currently we only save undelivered messages when server is restarted, with this approach all messages will be stored for some time. this argument is limited, as hosting providers of VMs can make memory snapshots too, on the other hand they are harder to analyze than files. On another hand, with this approach messages will be stored for a shorter time.
|
||||
|
||||
#### RocksDB and other key-value stores
|
||||
|
||||
@@ -68,7 +67,7 @@ queueLogLine =
|
||||
%s"write_msg=" digits
|
||||
```
|
||||
|
||||
When queue is first requested by the router:
|
||||
When queue is first requested by the server:
|
||||
|
||||
```c
|
||||
if queue folder exists:
|
||||
@@ -88,7 +87,7 @@ nextReadMsg = read_msg
|
||||
open write_file in AppendMode
|
||||
```
|
||||
|
||||
When message is added to the queue (assumes that queue state is loaded to router memory, if not the previous section will be done first):
|
||||
When message is added to the queue (assumes that queue state is loaded to server memory, if not the previous section will be done first):
|
||||
|
||||
```c
|
||||
if write_msg > max_queue_messages:
|
||||
@@ -129,7 +128,7 @@ else
|
||||
nextReadByte = current position in file
|
||||
```
|
||||
|
||||
When message delivery is acknowledged, the read queue needs to be advanced, and possibly switched to read from the current write queue:
|
||||
When message delivery is acknowledged, the read queue needs to be advanced, and possibly switched to read from the current write_queue:
|
||||
|
||||
```c
|
||||
if nextReadByte == read_byte:
|
||||
@@ -163,9 +162,9 @@ Most Linux systems use EXT4 filesystem where the file lookup time scales linearl
|
||||
|
||||
So storing all queue folders in one folder won't scale.
|
||||
|
||||
To solve this problem we could use recipient queue ID in base64url format not as a folder name, but as a folder path, splitting it to path fragments of some length. The number of fragments can be configurable and migration to a different fragment size can be supported as the number of queues on a given router grows.
|
||||
To solve this problem we could use recipient queue ID in base64url format not as a folder name, but as a folder path, splitting it to path fragments of some length. The number of fragments can be configurable and migration to a different fragment size can be supported as the number of queues on a given server grows.
|
||||
|
||||
Currently, queue ID is 24 bytes random number, thus allowing 2^192 possible queue IDs. If we assume that a router must hold 1b queues, it means that we have ~2^162 possible addresses for each existing queue. 24 bytes in base64 is 32 characters that can be split into say 8 fragments with 4 characters each, so that queue folder path for queue with ID `abcdefghijklmnopqrstuvwxyz012345` would be:
|
||||
Currently, queue ID is 24 bytes random number, thus allowing 2^192 possible queue IDs. If we assume that a server must hold 1b queues, it means that we have ~2^162 possible addresses for each existing queue. 24 bytes in base64 is 32 characters that can be split into say 8 fragments with 4 characters each, so that queue folder path for queue with ID `abcdefghijklmnopqrstuvwxyz012345` would be:
|
||||
|
||||
`/var/opt/simplex/messages/abcd/efgh/ijkl/mnop/qrst/uvwx/yz01/2345`
|
||||
|
||||
@@ -175,6 +174,6 @@ So we could use an unequal split of path, two letters each and the last being lo
|
||||
|
||||
`/var/opt/simplex/messages/ab/cd/ef/ghijklmnopqrstuvwxyz012345`
|
||||
|
||||
The first three levels in this case can have 4096 subfolders each, and it gives 68b possible subfolders (64^2^3), so the last level will be sparse in case of 1b queues on the router. So we could make it 4 levels with 2 letters to never think about it, accounting for a large variance of the random numbers distribution:
|
||||
The first three levels in this case can have 4096 subfolders each, and it gives 68b possible subfolders (64^2^3), so the last level will be sparse in case of 1b queues on the server. So we could make it 4 levels with 2 letters to never think about it, accounting for a large variance of the random numbers distribution:
|
||||
|
||||
`/var/opt/simplex/messages/ab/cd/ef/gh/ijklmnopqrstuvwxyz012345`
|
||||
@@ -8,7 +8,7 @@ See [Short invitation links](./2024-06-21-short-links.md).
|
||||
|
||||
2) clients only delete queue records based on some user action, pending connections do not expire.
|
||||
|
||||
While part 2 should be improved in the client, indefinite storage of queue records becomes a much bigger issue if each of them would result in a permanent storage of 4-16kb blob in router memory, without router-side expiration for short invitation links.
|
||||
While part 2 should be improved in the client, indefinite storage of queue records becomes a much bigger issue if each of them would result in a permanent storage of 4-16kb blob in server memory, without server-side expiration for short invitation links.
|
||||
|
||||
## Possible solutions
|
||||
|
||||
@@ -16,15 +16,15 @@ While part 2 should be improved in the client, indefinite storage of queue recor
|
||||
|
||||
The problem with this approach is that contact addresses are also unsecured queues, and they should not be expired.
|
||||
|
||||
We could set really large expiration time, and require that clients "update" the unsecured queues they need at least every 1-2 years, but it would not solve the problem of storing a large number of blobs in the router memory for unused/abandoned 1-time invitations.
|
||||
We could set really large expiration time, and require that clients "update" the unsecured queues they need at least every 1-2 years, but it would not solve the problem of storing a large number of blobs in the server memory for unused/abandoned 1-time invitations.
|
||||
|
||||
2) Do not store blobs in memory / append-only log, and instead use something like RocksDB. While it may be a correct long term solution, it may be not expedient enough at the current POC stage for this feature. Also, the lack of expiration is wrong in any case and would indefinitely grow router storage.
|
||||
2) Do not store blobs in memory / append-only log, and instead use something like RocksDB. While it may be a correct long term solution, it may be not expedient enough at the current POC stage for this feature. Also, the lack of expiration is wrong in any case and would indefinitely grow server storage.
|
||||
|
||||
3) Add flag allowing the router to differentiate permanent queues used as contact addresses, also using different blob sizes for them. In this case, messaging queues will be expired if not secured after 3 weeks, and contact address queues would be expired if not "updated" by the owner within 2 years.
|
||||
3) Add flag allowing the server to differentiate permanent queues used as contact addresses, also using different blob sizes for them. In this case, messaging queues will be expired if not secured after 3 weeks, and contact address queues would be expired if not "updated" by the owner within 2 years.
|
||||
|
||||
Probably all three solutions need to be used, to avoid creating a non-expiring blob storage in memory, as in case too many of such blobs are created it would not be possible to differentiate between real users and resource exhaustion attacks, and unlike with messages, they won't be expiring too.
|
||||
|
||||
Routers already can differentiate messaging queues and contact address queues, if they want to:
|
||||
Servers already can differentiate messaging queues and contact address queues, if they want to:
|
||||
- with the old 4-message handshake, the confirmation message on a normal queue was different, and also KEY command was eventually used.
|
||||
- with the fast 2-message handshake, while the confirmation message has the same syntax, and the differences are inside encrypted envelope, the client still uses SKEY command.
|
||||
- in both cases, the usual messaging queues are secured, and contact addresses are not, so this difference is visible in the storage as well (although it is not easy to differentiate between abandoned 1-time invitations and contact addresses).
|
||||
@@ -33,7 +33,7 @@ Differentiating these queues can also allow different message retention times -
|
||||
|
||||
## Proposed solution
|
||||
|
||||
1. Add queue updated_at date into queue records. While it adds some metadata, it seems necessary to manage retention and quality of service. It will not include exact time, only date, and the time of creation will be replaced by the time of any update - queue secured, a message is sent, or queue owner subscribes to the queue. To avoid the need to update store log on every message this information can be appended to store log on router termination. Or given that only one update per day is needed it may be ok to make these updates as they happen (temporarily making the sequence and time of these events available in storage).
|
||||
1. Add queue updated_at date into queue records. While it adds some metadata, it seems necessary to manage retention and quality of service. It will not include exact time, only date, and the time of creation will be replaced by the time of any update - queue secured, a message is sent, or queue owner subscribes to the queue. To avoid the need to update store log on every message this information can be appended to store log on server termination. Or given that only one update per day is needed it may be ok to make these updates as they happen (temporarily making the sequence and time of these events available in storage).
|
||||
|
||||
2. Add flag to indicate the queue usage - messaging queue or queue for contact address connection requests. This would result in different queue size and different retention policy for queue and its messages. We already have "sender can secure flag" which is, effectively, this flag - contact address queues are never secured. So this does not increase stored metadata in any way.
|
||||
|
||||
@@ -41,11 +41,11 @@ Differentiating these queues can also allow different message retention times -
|
||||
|
||||
This is a design considerations and a concept, not a design yet.
|
||||
|
||||
Instead of implementing a generic blob storage that can be used as an attack vector, and adds additional failure point (another router storing blob that is necessary to connect to the queue on the current router), but instead adds an extended queue information blobs, most of which could be dropped without the loss of connectivity, so that the attack can be mitigated by deleting these blobs without users losing the ability to connect, as long as the queue and minimal extended information is retained.
|
||||
Instead of implementing a generic blob storage that can be used as an attack vector, and adds additional failure point (another server storing blob that is necessary to connect to the queue on the current server), but instead adds an extended queue information blobs, most of which could be dropped without the loss of connectivity, so that the attack can be mitigated by deleting these blobs without users losing the ability to connect, as long as the queue and minimal extended information is retained.
|
||||
|
||||
So, to make the connection there need to be these elements:
|
||||
|
||||
- queue router and queue ID - mandatory part, that can be included in short link
|
||||
- queue server and queue ID - mandatory part, that can be included in short link
|
||||
- SMP key - mandatory part for all queues. We are considering initializing ratchets earlier for contact addresses, and include ratchet keys and pre-keys into queue data as well, but it is out of scope here.
|
||||
- Ratchet keys - mandatory part for 1-time invitation that won't fit in short link.
|
||||
- PQ key - optional part that can be stored with addresses if ratchet keys are added and with 1-time invitations.
|
||||
@@ -56,8 +56,8 @@ So rather that storing one blob with a large address inside it, not associated w
|
||||
Also, we need the address shared with the sender (party accepting the connection) to be short. We could use a similar approach that was proposed for data blobs, using a single random seed per queues to derive multiple keys and IDs from it. For example:
|
||||
|
||||
1. The queue owner:
|
||||
- generates Ed25529 key pair `(sk, spk)` and X25519 key pair `(dhk, dhpk)` to use with the router, same as now sent in NEW command.
|
||||
- generates queue recipient ID (this ID can still be router-generated).
|
||||
- generates Ed25529 key pair `(sk, spk)` and X25519 key pair `(dhk, dhpk)` to use with the server, same as now sent in NEW command.
|
||||
- generates queue recipient ID (this ID can still be server-generated).
|
||||
- generates X25519 key pair `(k, pk)` to use with the accepting party.
|
||||
- derives from `k`:
|
||||
- sender ID.
|
||||
@@ -73,9 +73,9 @@ The algorithm used to derive key and ID from `k` needs to be cryptographically s
|
||||
So, coupling blob storage with messaging queues has these pros/cons:
|
||||
|
||||
Cons:
|
||||
- no additional layer of privacy - the router used for connection is visible in the link, even after the blobs are removed from the router.
|
||||
- no additional layer of privacy - the server used for connection is visible in the link, even after the blobs are removed from the server.
|
||||
|
||||
Pros:
|
||||
- no additional point of failure in the connection process - the same router will be used to retrieve necessary blobs as for connection.
|
||||
- no additional point of failure in the connection process - the same server will be used to retrieve necessary blobs as for connection.
|
||||
- queue blobs of messaging blobs will be automatically removed once the queue is secured or expired, without additional request from the recipient - reducing the storage and the time these blobs are available.
|
||||
- queue blobs for contact addresses will be structured and some of the large blobs can be removed in case of resource exhaustion attack (and recreated by the client if needed), with the only downside that PQ handshake will be postponed (which is the case now) and profile will not be available at a point of connection.
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
---
|
||||
Proposed: 2024-09-09
|
||||
Implemented: ~2025 (SMP v15)
|
||||
Standardized: 2026-03-10
|
||||
Protocol: simplex-messaging + agent-protocol
|
||||
---
|
||||
|
||||
> **Implementation note:** This RFC was promoted from done/ to standard/ based on verification that the described feature exists in the codebase. The RFC text reflects the original proposal and may not match the actual implementation in all details. The consolidated protocol specifications in `protocol/` are the authoritative reference for current behavior.
|
||||
|
||||
# Blob extensions for SMP queues
|
||||
|
||||
Evolution of the design for short links, see [here](./2024-06-21-short-links.md) and [here](./2024-09-05-queue-storage.md).
|
||||
@@ -20,13 +11,13 @@ Allow storing extended information with SMP queues to improve UX and security of
|
||||
|
||||
## Design
|
||||
|
||||
1. Queue creation/update date is already added to router persistence, allowing to expire queues and blobs, depending on their usage.
|
||||
1. Queue creation/update date is already added to server persistence, allowing to expire queues and blobs, depending on their usage.
|
||||
2. Add "queue type" metadata to NEW command to indicate whether messaging queue is used as public address or as messaging queue (see previous docs on why it doesn't change threat model). While at the moment it would match sndSecure flag there may be future scenarios when they diverge. Initially only "invitation" and "contact" types will be supported.
|
||||
3. Prohibit sndSecure flag for "contact" queues, prohibit securing contact queues.
|
||||
4. Add "queue blobs" to NEW command:
|
||||
- blob0: ratchetKeys up to N0 bytes - priority 0, can't be removed by the router, only in "invitation"
|
||||
- blob1: PQ key up to N1 bytes - priority 1, can be removed by the router, only used in "invitation"
|
||||
- blob2: Application data up to N2 bytes - priority 2, can be removed by the router.
|
||||
- blob0: ratchetKeys up to N0 bytes - priority 0, can't be removed by the server, only in "invitation"
|
||||
- blob1: PQ key up to N1 bytes - priority 1, can be removed by the server, only used in "invitation"
|
||||
- blob2: Application data up to N2 bytes - priority 2, can be removed by the server.
|
||||
5. Add linkId to NEW command
|
||||
6. linkId and blobs will be removed when queue is secured.
|
||||
7. Add recipient command to remove/upsert blob2 for contact queues.
|
||||
@@ -37,7 +28,7 @@ Allow storing extended information with SMP queues to improve UX and security of
|
||||
### Creating a queue:
|
||||
|
||||
The queue owner:
|
||||
- generates Ed25529 key pair `(sk, spk)` and X25519 key pair `(dhk, dhpk)` to use with the router, same as now. `sk` and `dhk` will be sent in NEW command.
|
||||
- generates Ed25529 key pair `(sk, spk)` and X25519 key pair `(dhk, dhpk)` to use with the server, same as now. `sk` and `dhk` will be sent in NEW command.
|
||||
- generates X25519 key pair `(k, pk)` to use with the accepting party to encrypt queue messages.
|
||||
- derives from `k` using HKDF:
|
||||
- symmetric key `bk` for authenticated encryption of blobs.
|
||||
@@ -82,7 +73,7 @@ Response to GET:
|
||||
blobs = %s"BLOB" senderId [ "0" blob0 ] [ "1" blob1 ] [ "2" blob2 ]
|
||||
```
|
||||
|
||||
As blobs are retrieved using a separate linkId, once blobs are removed it will be impossible to find senderId from short link - it is a threat model improvement. Once router storage is compacted, it will be impossible to find queue related to the link even with the access to router data (unless router preserves the data).
|
||||
As blobs are retrieved using a separate linkId, once blobs are removed it will be impossible to find senderId from short link - it is a threat model improvement. Once server storage is compacted, it will be impossible to find queue related to the link even with the access to server data (unless server preserves the data).
|
||||
|
||||
### Possible privacy improvement
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
|
||||
## Problem
|
||||
|
||||
Our current handshake protocol is open to this attack: whoever observes the link exchange, knows on which router connection is being made, and if the traffic on this router is observed, then it can confirm communication between parties. Further, even with the [last proposal](./2024-09-09-smp-blobs.md#possible-privacy-improvement), having real-time access to the router data allows to establish the exact messaging queue that is used to send messages.
|
||||
Our current handshake protocol is open to this attack: whoever observes the link exchange, knows on which server connection is being made, and if the traffic on this server is observed, then it can confirm communication between parties. Further, even with the [last proposal](./2024-09-09-smp-blobs.md#possible-privacy-improvement), having real-time access to the server data allows to establish the exact messaging queue that is used to send messages.
|
||||
|
||||
## Solution
|
||||
|
||||
We could make the initial link exchange more private by making it harder for any observer to discover which router will be used for messaging by hiding this information from the router that hosts the initial link.
|
||||
We could make the initial link exchange more private by making it harder for any observer to discover which server will be used for messaging by hiding this information from the server that hosts the initial link.
|
||||
|
||||
Preliminary, the protocol could be the following:
|
||||
|
||||
1. Connection initiator stores 224-256 bytes of encrypted connection link on a rendezvous router (link contains router host and linkId on another messaging router, not a rendezvous one).
|
||||
1. Connection initiator stores 224-256 bytes of encrypted connection link on a rendezvous server (link contains server host and linkId on another messaging server, not a rendezvous one).
|
||||
|
||||
2. Rendezvous router adds these links to buckets, up to 64 links per bucket. Bucket ID is the timestamp when the bucket was created + a sequential bucket number, in case more than one bucket is created per second.
|
||||
2. Rendezvous server adds these links to buckets, up to 64 links per bucket. Bucket ID is the timestamp when the bucket was created + a sequential bucket number, in case more than one bucket is created per second.
|
||||
|
||||
3. The router responds to the link creator with a bucket ID where this link was added. That bucket ID is its timestamp + a number prevents router "fingerprinting" clients and using say one bucket for each client. If timestamp is different or a bucket number within this timestamp is too large, the client can refuse to use it, depending on the client settings.
|
||||
3. The server responds to the link creator with a bucket ID where this link was added. That bucket ID is its timestamp + a number prevents server "fingerprinting" clients and using say one bucket for each client. If timestamp is different or a bucket number within this timestamp is too large, the client can refuse to use it, depending on the client settings.
|
||||
|
||||
4. The initiating party will pass to the accepting party the rendezvous router host, the hash of this bucket ID (bucket link) and the passphrase to derive the key from. The initiating party has an option to pass a link and passphrase via two channels - in which case the link will only contain the bucket ID.
|
||||
4. The initiating party will pass to the accepting party the rendezvous server host, the hash of this bucket ID (bucket link) and the passphrase to derive the key from. The initiating party has an option to pass a link and passphrase via two channels - in which case the link will only contain the bucket ID.
|
||||
|
||||
5. The accepting party would then request the bucket via its ID hash (the router would store hashes to be able to look up - hash is used to prevent showing time in the link) and attempt to decrypt all contained links using the provided key.
|
||||
5. The accepting party would then request the bucket via its ID hash (the server would store hashes to be able to look up - hash is used to prevent showing time in the link) and attempt to decrypt all contained links using the provided key.
|
||||
The accepting party then will continue the connection via the decrypted link.
|
||||
|
||||
This obviously does not protect accepting party from the initiating party, if it can choose rendezvous router it controls. It also does not protect from the malicious rendezvous router that would collaborate with link observers. I think reunion doesn’t protect from it too.
|
||||
This obviously does not protect accepting party from the initiating party, if it can choose rendezvous server it controls. It also does not protect from the malicious rendezvous server that would collaborate with link observers. I think reunion doesn’t protect from it too.
|
||||
|
||||
But it does protect connection from whoever observes the link, particularly if this link only contains the bucket and the key is passed separately, via some other channel.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
# Sharing protocol ports with HTTPS
|
||||
|
||||
Some networks block all ports other than web ports, including port 5223 used for SMP protocol by default. Running SMP routers on a common web port 443 would allow them to work on more networks. The routers would need to provide an HTTPS page for browsers (and probes).
|
||||
Some networks block all ports other than web ports, including port 5223 used for SMP protocol by default. Running SMP servers on a common web port 443 would allow them to work on more networks. The servers would need to provide an HTTPS page for browsers (and probes).
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -9,7 +8,7 @@ Browsers and tools rely on system CA bundles instead of certificate pinning.
|
||||
The crypto parameters used by HTTPS are different from what the protocols use.
|
||||
Public certificate providers like LetsEncrypt can only sign specific types of keys and Ed25519 isn't one of them.
|
||||
|
||||
This means a router should distinguish browser and protocol clients and adjust its behavior to match.
|
||||
This means a server should distinguish browser and protocol clients and adjust its behavior to match.
|
||||
|
||||
## Solution
|
||||
|
||||
@@ -17,15 +16,15 @@ This means a router should distinguish browser and protocol clients and adjust i
|
||||
|
||||
Since LE certificates are only handed out to domain names, TLS client will be sending the SNI.
|
||||
However client transports are constructed over connected sockets and the SNI wouldn't be present unless explicitly requested.
|
||||
When a client sends SNI, then it's a browser and web credentials should be used.
|
||||
When a client sends SNI, then it's a browser and a web credentials should be used.
|
||||
Otherwise it's a protocol client to be offered the self-signed ca, cert and key.
|
||||
|
||||
When a transport colocated with a HTTPS, its ALPN list should be extended with `h2 http/1.1`.
|
||||
The browsers will send it, and it should be checked before running transport client.
|
||||
If HTTP ALPN is detected, then the client connection is served with HTTP `Application` instead (the same "router information" page).
|
||||
If HTTP ALPN is detected, then the client connection is served with HTTP `Application` instead (the same "server information" page).
|
||||
|
||||
If some client connects to router IP, doesn't send SNI and doesn't send ALPN, it will look like a pre-handshake client.
|
||||
In that case a router will send its handshake first.
|
||||
If some client connects to server IP, doesn't send SNI and doesn't send ALPN, it will look like a pre-handshake client.
|
||||
In that case a server will send its handshake first.
|
||||
This can be mitigated by delaying its handshake and letting the probe to issue its HTTP request.
|
||||
|
||||
## Implementation plan
|
||||
@@ -44,7 +43,7 @@ runServer (tcpPort, ATransport t) = do
|
||||
else runClient serverSignKey t h `runReaderT` env -- performs serverHandshake etc as usual
|
||||
```
|
||||
|
||||
The web app and router live outside, so `runHttp` has to be provided by the `runSMPServer` caller.
|
||||
The web app and server live outside, so `runHttp` has to be provided by the `runSMPServer` caller.
|
||||
Additonally, Warp is using its `InternalInfo` object that's scoped to `withII` bracket.
|
||||
|
||||
```haskell
|
||||
@@ -66,9 +65,11 @@ The implementation relies on a few modification to upstream code:
|
||||
- `warp`: Only the re-export of `serveConnection` is needed.
|
||||
Unfortunately the most recent `warp` version can't be used right away due to dependency cascade around `http-5` and `auto-update-2`.
|
||||
So a fork containing the backported re-export has to be used until the dependencies are refreshed.
|
||||
|
||||
|
||||
### TLS.ServerParams
|
||||
|
||||
When a router has port sharing enabled, a new set of TLS params is loaded and combined with transport params:
|
||||
When a server has port sharing enabled, a new set of TLS params is loaded and combined with transport params:
|
||||
|
||||
```haskell
|
||||
newEnv config = do
|
||||
@@ -128,7 +129,7 @@ key: /etc/opt/simplex/web.key
|
||||
# key: /etc/letsencrypt/live/smp.hostname.tld/privkey.pem
|
||||
```
|
||||
|
||||
When `TRANSPORT.port` matches `WEB.https` the transport router becomes shared.
|
||||
When `TRANSPORT.port` matches `WEB.https` the transport server becomes shared.
|
||||
|
||||
Perhaps a more desirable option would be explicit configuration resulting in additional transported to run:
|
||||
|
||||
@@ -147,16 +148,16 @@ key: /etc/opt/simplex/web.key
|
||||
|
||||
## Caveats
|
||||
|
||||
Serving static files and the protocols together may pose a problem for those who currently use dedicated web servers as they should switch to embedded http handlers.
|
||||
Serving static files and the protocols togother may pose a problem for those who currently use dedicated web servers as they should switch to embedded http handlers.
|
||||
|
||||
As before, using embedded HTTP server is increasing attack surface.
|
||||
|
||||
Users who want to run everything on a single host will have to add an extra IP address and bind routers to specific IPs instead of 0.0.0.0.
|
||||
An amalgamated router binary can be provided that would contain both SMP and XFTP routers, where transport will dispatch connections by handshake ALPN.
|
||||
Users who want to run everything on a single host will have to add and extra IP address and bind servers to specific IPs instead of 0.0.0.0.
|
||||
An amalgamated server binary can be provided that would contain both SMP and XFTP servers, where transport will dispatch connections by handshake ALPN.
|
||||
|
||||
## Alternative: Use transports routable with reverse-proxies
|
||||
|
||||
An "industrial" reverse proxy may do the ALPN routing, serving HTTP by itself and delegating `smp` and `xftp` to protocol servers.
|
||||
Same with the `websockets`.
|
||||
|
||||
Since this in effect does TLS termination, the protocol routers will have to rely on credentials from protocol handshakes.
|
||||
Since this in effect does TLS termination, the protocol servers will have to rely on credentials from protocol handshakes.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Problem
|
||||
|
||||
For iOS notifications to be delivered the client has to create credentials for notification subscription on SMP router using NKEY command and after that create a subscription on notification router using SNEW command. These two commands are sent in sequence, after the connections are created, and for it to happen the client needs to be online and in foreground.
|
||||
For iOS notifications to be delivered the client has to create credentials for notification subscription on SMP server using NKEY command and after that create a subscription on notification server using SNEW command. These two commands are sent in sequence, after the connections are created, and for it to happen the client needs to be online and in foreground.
|
||||
|
||||
iOS users tend to close the app when it is not used, and iOS has very limited permissions for background activities, so these notification subscriptions are created with a substantial delay, and notifications do not work.
|
||||
|
||||
@@ -12,19 +12,19 @@ This problem is distinct from and probably more common than other problems affec
|
||||
|
||||
1. When the new connection is created, the client already knows if it needs to create notification subscription or not, based on the conversation setting (e.g., if the group is muted, the client will not create notification subscription as well.). We should extend NEW command to avoid the need to send additional NKEY command with an option to create notification subscription at the point where connection is created. NDEL would still be used to disable this notification, and NKEY will be used to re-enable it.
|
||||
|
||||
2. In the same way we stopped using SDEL command (NDEL sends notification DELD to subscribed notification router) to delete notificaiton subscriptions from notification router, we should delegate creating notification subscription on notification router to SMP routers. Clients could use keys agreed with ntf router for e2e encryption and for command authorization to encrypt and sign instruction to create notification subscription that will be forwarded to notification router using protocol similar to SMP proxies. This will avoid the need for clients to separately contact notification routers that won't happen until they are online.
|
||||
2. In the same way we stopped using SDEL command (NDEL sends notification DELD to subscribed notification server) to delete notificaiton subscriptions from notification server, we should delegate creating notification subscription on notification server to SMP servers. Clients could use keys agreed with ntf server for e2e encryption and for command authorization to encrypt and sign instruction to create notification subscription that will be forwarded to notification server using protocol similar to SMP proxies. This will avoid the need for clients to separately contact notification servers that won't happen until they are online.
|
||||
|
||||
3. Instead of making Ntf router trust DELD notifications, we could send deletion instructions signed by the client, which will only fail to send in case notification router is down (and they won't be sent later after router restart).
|
||||
3. Instead of making Ntf server trust DELD notifications, we could send deletion instructions signed by the client, which will only fail to send in case notification server is down (and they won't be sent later after server restart).
|
||||
|
||||
Cons:
|
||||
- If SMP routers were to retain in the storage the information about which notification router is used for which queue, it would reduce metadata privacy. While currently it is not an issue, as all notification routers are known and operated by us, once there are other client apps, this can be used for app users fingerprinting, which would act as a deterrence from using new apps – but only if app users use routers of operators who are different from the app provider. To mitigate it, we could only store it in router memory and include notification instruction in subscription commands (SUB) and include notification subscription status in SUB responses. We don't need to mitigate the problem of router being able to store this information, as messaging routers can observe which notification routers connect to them anyway.
|
||||
- If SMP router is restarted before the subscription request is forwared to the notification router, then it will have to be forwarded again, once the client subscribes. The problem here is that if the client is offline, it will neither subscribe to the queue to send notification subscription request, nor receive notifications from this queue. Storing notification router and subscription request would mitigate that, as in this case we could send all pending requests on router start, without depending on client subscriptions.
|
||||
- "Small" agent will need to support connections to ntf routers and manage workers that retry sending pending subscription requests.
|
||||
- Until the client learns the public keys of notification router, it will not be able to decrypt notifications. It potentially can be mitigated by using the public key of the router returned when token is created, in this way different client keys (per-queue) will be combined with the same ntf router key (per-token).
|
||||
- If SMP servers were to retain in the storage the information about which notification server is used for which queue, it would reduce metadata privacy. While currently it is not an issue, as all notification servers are known and operated by us, once there are other client apps, this can be used for app users fingerprinting, which would act as a deterrence from using new apps – but only if app users use servers of operators who are different from the app provider. To mitigate it, we could only store it in server memory and include notification instruction in subscription commands (SUB) and include notification subscription status in SUB responses. We don't need to mitigate the problem of server being able to store this information, as messaging servers can observe which notification servers connect to them anyway.
|
||||
- If SMP server is restarted before the subscription request is forwared to the notification server, then it will have to be forwarded again, once the client subscribes. The problem here is that if the client is offline, it will neither subscribe to the queue to send notification subscription request, nor receive notifications from this queue. Storing notification server and subscription request would mitigate that, as in this case we could send all pending requests on server start, without depending on client subscriptions.
|
||||
- "Small" agent will need to support connections to ntf servers and manage workers that retry sending pending subscription requests.
|
||||
- Until the client learns the public keys of notification server, it will not be able to decrypt notifications. It potentially can be mitigated by using the public key of the server returned when token is created, in this way different client keys (per-queue) will be combined with the same ntf server key (per-token).
|
||||
|
||||
## Implementation details
|
||||
|
||||
1. NEW and NKEY commands will need to be extended to include notification subscription request. As the notifier ID needs to be sent to notification router, this notifier ID will have to be client-generated and supplied as part of NEW command.
|
||||
1. NEW and NKEY commands will need to be extended to include notification subscription request. As the notifier ID needs to be sent to notification server, this notifier ID will have to be client-generated and supplied as part of NEW command.
|
||||
|
||||
now:
|
||||
|
||||
@@ -46,4 +46,4 @@ NKEY :: NtfPublicAuthKey -> RcvNtfPublicDhKey -> Maybe NtfServerRequest -> Comma
|
||||
-- NotifierID is passed in entity ID field of the transmission
|
||||
```
|
||||
|
||||
2. Notification router will need to support an additional command to receive "proxied" subscription commands, `SFWD`, that would include `NtfServerRequest`. This command can include both `SNEW` and `SDEL` commands.
|
||||
2. Notification server will need to support an additional command to receive "proxied" subscription commands, `SFWD`, that would include `NtfServerRequest`. This command can include both `SNEW` and `SDEL` commands.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
# Expiring messages in journal storage
|
||||
|
||||
## Problem
|
||||
|
||||
The journal storage routers recently migrated to do not delete delivered or expired messages, they only update pointers to journal file lines. The messages are actually deleted when the whole journal file is deleted (when fully deleted or fully expired).
|
||||
The journal storage servers recently migrated to do not delete delivered or expired messages, they only update pointers to journal file lines. The messages are actually deleted when the whole journal file is deleted (when fully deleted or fully expired).
|
||||
|
||||
The problem is that in case the queue stops receiving the new messages then writing of messages won't switch to the new journal file, and the current journal file containing delivered or expired messages would never be deleted.
|
||||
|
||||
@@ -5,7 +5,7 @@ This document evolves the design proposed [here](./2024-09-09-smp-blobs.md).
|
||||
## Problems
|
||||
|
||||
In addition to problems in the first doc, we have these issues with in-memory queue record storage:
|
||||
- many queues are idle or rarely used, but they are loaded to memory, and currently just loading all queues uses 20gb RAM on each router, and takes 10 min to process, increasing downtimes during restarts.
|
||||
- many queues are idle or rarely used, but they are loaded to memory, and currently just loading all queues uses 20gb RAM on each server, and takes 10 min to process, increasing downtimes during restarts.
|
||||
- adding blobs to memory would make this problem much worse.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
# Protocol changes for creating and connecting to SMP queues
|
||||
|
||||
## Problems
|
||||
|
||||
This change is related to these problems:
|
||||
- differentiating queue retention time,
|
||||
- supporting MITM-resistant short connection links,
|
||||
- improving notifications.
|
||||
|
||||
This RFC is based on the previous discussions about short links, blob storage and notifications ([1](./2024-06-21-short-links.md), [2](./2024-09-09-smp-blobs.md), [3](./2024-11-25-queue-blobs-2.md), [4](./2024-09-25-ios-notifications-2.md)).
|
||||
|
||||
SMP protocol supports two types of queues - queues to communicate over and queues to send invitations. While SMP protocol was originally "unaware" of these queue types, it could differentiate it by message flow, and with the recent addition of SKEY command to allow securing the queue by the sender this difference became persistent.
|
||||
|
||||
Simply designating queue types would allow to use this information to decide for how long to retain queues, and potentially extending it:
|
||||
- unsecured 1-time invitation queues with sndSecure (support of securing by sender) - e.g., 3 months.
|
||||
- contact address queues without sndSecure - e.g., 3 years without activity.
|
||||
- Possibly, "queues" that prohibit messages and used only as blob storage - they would be used to store group profiles and superpeer addresses for the group.
|
||||
|
||||
This proposal also combines NEW and NKEY command to streamline notifications in preparation to reworking of the notifications protocol.
|
||||
|
||||
## Design objectives
|
||||
|
||||
We want to achieve these objectives:
|
||||
1. no possibility to provide incorrect SenderId inside link data (e.g. from another queue).
|
||||
2. link data cannot be accessed by the server unless it has the link.
|
||||
3. prevent MITM attack by the server, including the server that obtained the link.
|
||||
4. prevent changing of connection request by the user (to prevent MITM via break-in attack in the originating client).
|
||||
5. for one-time links, prevent accessing link data by link observers who did not compromise the server.
|
||||
6. allow changing the user-defined part of link data.
|
||||
7. avoid changing the link when user-defined part of link data changes, while preventing MITM attack by the server on user-defined part, even if it has the link.
|
||||
8. retain the quality that it is impossible to check the existense of secured queue from having any of its temporary visible IDs (sender ID and link ID in 1-time invitations) - it requires that these IDs remain server-generated (contrary to the previous RFCs).
|
||||
|
||||
To achieve these objectives the queue data must have immutable part and mutable part.
|
||||
|
||||
Immutable part would include:
|
||||
- full conection request (the current long link with all keys, including PQ keys). This includes SenderId that must match server response.
|
||||
- public signature key to verify mutable part of link data.
|
||||
|
||||
Signed mutable part would inlcude:
|
||||
- any links to chat relays that should be contacted instead of this queue (not in this RFC), but would allow delegating group connections and contact request connections to prevent spam, hiding online presense, etc.
|
||||
- and user-defined data - user profile or group profile.
|
||||
|
||||
The link itself should include both the key and auth tag from the encryption of immutable part. Accessing one-time link data should require providing sender key and signing the command (`LKEY`).
|
||||
|
||||
## Solution
|
||||
|
||||
Current NEW and NKEY commands:
|
||||
|
||||
```haskell
|
||||
NEW :: RcvPublicAuthKey -> RcvPublicDhKey -> Maybe BasicAuth -> SubscriptionMode -> SenderCanSecure -> Command Recipient
|
||||
|
||||
NKEY :: NtfPublicAuthKey -> RcvNtfPublicDhKey -> Command Recipient
|
||||
|
||||
-- | Queue IDs and keys, returned in IDS response
|
||||
data QueueIdsKeys = QIK
|
||||
{ rcvId :: RecipientId,
|
||||
sndId :: SenderId,
|
||||
rcvPublicDhKey :: RcvPublicDhKey,
|
||||
sndSecure :: SenderCanSecure
|
||||
}
|
||||
```
|
||||
|
||||
Proposed NEW command replaces SenderCanSecure with QueueMode, adds link data, and combines NKEY command:
|
||||
|
||||
```haskell
|
||||
NEW :: NewQueueRequest -> Command Recipient
|
||||
|
||||
data NewQueueReq = NewQueueReq
|
||||
{ rcvAuthKey :: RcvPublicAuthKey,
|
||||
rcvDhKey :: RcvPublicDhKey,
|
||||
auth_ :: Maybe BasicAuth,
|
||||
subMode :: SubscriptionMode,
|
||||
queueData :: Maybe QueueReqData,
|
||||
ntfCreds :: Maybe NewNtfCreds
|
||||
}
|
||||
|
||||
-- Replaces NKEY command
|
||||
-- This avoids additional command required from the client to enable notifications.
|
||||
-- Further changes would move NotifierId generation to the client, and including a signed and encrypted command to be forwarded by SMP server to notification server.
|
||||
data NtfRequest = NtfRequest NtfPublicAuthKey RcvNtfPublicDhKey
|
||||
|
||||
-- QRMessaging implies that sender can secure the queue.
|
||||
-- LinkId is not used with QRMessaging, to prevent the possibility of checking when connection is established by re-using the same link ID when creating another queue – the creating would have to fail if it is used.
|
||||
-- LinkId is required with QRContact, to have shorter link - it will be derived from the link_uri. And in this case we do not need to prevent checks that this queue exists.
|
||||
data QueueReqData = QRMessaging (Maybe QueueLinkData) | QRContact (Maybe (LinkId, QueueLinkData))
|
||||
|
||||
-- SenderId should be computed client-side as the first 24 bytes of sha3-384(correlation_id),
|
||||
-- The server must verify it and reject if it is not.
|
||||
type QueueLinkData = (SenderId, EncImmutableDataBytes, EncUserDataBytes)
|
||||
|
||||
type EncImmutableDataBytes = ByteString
|
||||
|
||||
type EncUserDataBytes = ByteString
|
||||
|
||||
-- We need to use binary encoding for AConnectionRequestUri to reduce its size
|
||||
-- connReq including the full link allows connection redundancy.
|
||||
-- The clients would reject changed immutable data (based on auth tag in the link) and
|
||||
-- AConnectionRequestUri where SenderId of the queue does not match.
|
||||
data ImmutableLinkData = ImmutableLinkData
|
||||
{ signature :: SignatureEd25519, -- signature of the remaining part of immutable data
|
||||
connReq :: AConnectionRequestUri,
|
||||
sigKey :: PublicKeyEd25519
|
||||
}
|
||||
|
||||
-- This part of link data can also include any relays, but possibly we need a separate blob for it
|
||||
data UserLinkData = UserLinkData
|
||||
{ signature :: SignatureEd25519, -- signs the remaining part of the data
|
||||
userData :: ByteString -- the max size needs to be estimated, but it is likely to be ~ 14kb
|
||||
}
|
||||
|
||||
-- | Updated queue IDs and keys, returned in IDS response
|
||||
data QueueIdsKeys = QIK
|
||||
{ rcvId :: RecipientId, -- server-generated
|
||||
sndId :: SenderId, -- server-generated
|
||||
rcvPublicDhKey :: RcvPublicDhKey,
|
||||
sndSecure :: SenderCanSecure, -- possibly, can be removed? or implied?
|
||||
linkId :: Maybe LinkId, -- server-generated
|
||||
serverNtfCreds :: Maybe ServerNtfCreds -- currently returned in NID response
|
||||
}
|
||||
|
||||
data ServerNtfCreds = ServerNtfCreds NotifierId RcvNtfPublicDhKey -- NotifierId is server-generated.
|
||||
```
|
||||
|
||||
In addition to that we add the command allowing to update and also to retrieve and, optionally, secure the queue and get link data in one request, to have only one request:
|
||||
|
||||
```haskell
|
||||
-- This command allows to set all data or to update mutlable part of contact address queue.
|
||||
-- This command should fail on queues that support sndSecure and also on new queues created with QRMessaging.
|
||||
-- This should fail if LinkId or immutable part of data is changed with the update, but will succeed if only mutable part is updated, so it can be retried.
|
||||
-- Entity ID is RecipientId.
|
||||
-- The response to this command is `OK`.
|
||||
LSET :: LinkId -> QueueLinkData -> Command Recipient
|
||||
|
||||
-- Delete should link and associated data
|
||||
-- Entity ID is RecipientId
|
||||
LDEL :: Command Recipient
|
||||
|
||||
-- To be used with 1-time links.
|
||||
-- Sender's key provided on the first request prevents observers from undetectably accessing 1-time link data.
|
||||
-- If queue mode is QRContact (and queue does NOT allow sndSecure) the command will fail, same as SKEY.
|
||||
-- Once queue is secured, the key must be the same in subsequent requests - to allow retries in case of network failures, and to prevent passive attacks.
|
||||
-- The difference with securing queues is that queues allow sending unsecured messages to queues that allow sndSecure (for backwards compatibility), and 1-time links will NOT allow retrieving link data without securing the queue at the same time, preventing undetected access by observers.
|
||||
-- Entity ID is LinkId
|
||||
LKEY :: SndPublicAuthKey -> Command Sender
|
||||
|
||||
-- If queue mode is QRMessaging the command will fail.
|
||||
-- Entity ID is LinkId
|
||||
LGET :: Command Sender
|
||||
|
||||
-- Response to LGET, LSKEY and LSGET
|
||||
-- Entity ID is the same as in the command
|
||||
LNK :: SenderId -> QueueLinkData -> BrokerMsg
|
||||
```
|
||||
|
||||
To both include sender_id into the full link before the server response, and to prevent "oracle attack" when a failure to create the queue with the supplied `sender_id` can be used as a proof of queue existense, it is proposed that `sender_id` is computed client-side as the first 24 bytes of 48 in `sha3-384(correlation_id)` and validated server-side, where `corelation_id` is the transmission correlation ID.
|
||||
|
||||
To allow retries and to avoid regenerating all queue data, NEW command must be idempotent, and `correlation_id` must be preserved in command for queue creation, so that the same `correlation_id` and all other data is used in retries. `correlation_id` should be removed after queue creation success.
|
||||
|
||||
To allow retries, every time the command is sent a new random `correlation_id` and new `sender_id` / `link_id` should be used on each attempt, because other IDs would be generated randomly on the server, and in case the previous command succeeded on the server but failed to be communicated to the client, the retry will fail if the same ID is used.
|
||||
|
||||
Alternative solutions considered and rejected:
|
||||
- additional request to save queue data, after `sender_id` is returned by the server. The scenarios that require short links are interactive - creating user addresses and 1-time invitations - so making two requests instead of one would make the UX worse.
|
||||
- include empty sender_id in the immutable data and have it replaced by the accepting party with `sender_id` received in `LINK` response - both a weird design, and might create possibility for some attacks via server, especially for contact addresses.
|
||||
- making NEW commands idempotent. Doing it would require generating all IDs client-side, not only `sender_id`. It increases complexity, and it is not really necessary as the only scenarios when retries are needed are async NEW commands, that do not require short links. For future short links of chat relays the retries are much less likely, as chat relays will have good network connections.
|
||||
|
||||
## Algorithm to prepare and to interpret queue link data.
|
||||
|
||||
For contact addresses this approach follows the design proposed in [Short links](./2024-06-21-short-links.md) RFC - when link id is derived from the same random binary as key. For 1-time invitations link ID is independent and server-generated, to prevent existense checks.
|
||||
|
||||
**Prepare queue link data**
|
||||
|
||||
- the queue owner generates a random 256 bit `link_key` that will be used in the link URI.
|
||||
- for 1-time links: crypto_box key and 2 nonces to encrypt link data are derived from link_uri using HKDF: `cb_key <> nonce1 <> nonce2 = HKDF(link_key, 80 bytes)` (nonce1 is used for immutable and nonce2 for user-defined parts).
|
||||
- for contact address links: key and 2 nonces and linkId will be derived: `link_id <> cb_key <> nonce1 <> nonce2 = HKDF(link_key, 104 bytes)`
|
||||
- both parts of link data are encrypted with crypto_box, and included into `NEW` or `LNEW` commands.
|
||||
|
||||
**Retrieving queue link data**
|
||||
|
||||
- the sender uses `LinkId` from URI (or derived from URI) as entity ID to retrieve link data.
|
||||
- for one time links the sender must authorize the request to retrieve the data, the key is provided with the first request, preventing undetected access by link observers.
|
||||
- having received the link data, the client can now decrypt it using secret_box.
|
||||
|
||||
## Improved algorithm to prepare and to interpret queue link data.
|
||||
|
||||
This scheme reduces the size of the binary in the link from 48 bytes (72 in case of 1-time links) to 32 bytes (56 bytes for 1-time links).
|
||||
|
||||
For immutable data.
|
||||
|
||||
1. `link_key = SHA3-256(immutable_data)` - used as part of link, and to encrypt content.
|
||||
2. HKDF:
|
||||
1) contact address: `(link_id, key) = HKDF(link_key, 56 bytes)`.
|
||||
2) 1-time invitation: `key = HKDF(link_key, 32 bytes)`, `link-id` - server-generated.
|
||||
3.
|
||||
3. Random `nonce1` (for immutable data), to be stored with the link data.
|
||||
4. Encrypt: `(ct1, tag1) = secret_box(immutable_data, key, nonce1)`.
|
||||
5. Store: `(nonce1, ct1, tag1)` stored as immutable link data.
|
||||
|
||||
For mutable user data:
|
||||
|
||||
1. Random `nonce2` and the same key are used.
|
||||
2. Sign `user_data` with key included in `immutable_data`.
|
||||
3. Encrypt: `(ct2, tag2) = secret_box(signed_used_data, key, nonce2)`.
|
||||
4. Store: `(nonce2, ct2, tag2)`
|
||||
|
||||
Link recipient:
|
||||
|
||||
1. Receives `link_key` in the link, for 1-time invitations also `link_id`.
|
||||
2. HKDF:
|
||||
1) contact address: `(link_id, key) = HKDF(link_key, 56 bytes)`.
|
||||
2) 1-time invitation: `key = HKDF(link_key, 32 bytes)`.
|
||||
3. Retrieves via `link_id`: `(nonce1, ct1, tag1)` and `(nonce2, ct2, tag2)`.
|
||||
4. Decrypt: `immutable_data = decrypt (nonce1, ct1, tag1)`.
|
||||
5. Verify: `SHA3-256(immutable_data) == link_key`, abort if not.
|
||||
6. Decrypt: `signed_used_data = decrypt(nonce2, ct2, tag2)`
|
||||
7. Verify signature with key in immutable data.
|
||||
|
||||
While using content hash as encryption key is unconventional, it is not completely unheard of - e.g., it is used in convergent encryption (although in our case using random nonce makes it not convergent, but other use cases suggest that this approach preserves encryption security). It is particularly acceptable for our use case, as `immutable_data` contains mostly random keys.
|
||||
|
||||
## Threat model
|
||||
|
||||
**Compromised SMP server**
|
||||
|
||||
can:
|
||||
- delete link data.
|
||||
- hide link selectively from some requests.
|
||||
|
||||
cannot:
|
||||
- undetectably replace link data, even if they have the link (objective 3).
|
||||
- access unencrypted link data, whether it was or was not accessed by the accepting party, provided it has no link (objective 2).
|
||||
- observe IP addresses of the users accessing link data, if private routing is used.
|
||||
|
||||
**Passive observer who observed short link**:
|
||||
|
||||
can:
|
||||
- access original unencrypted link data for contact address links.
|
||||
|
||||
cannot:
|
||||
- undetectably access observed 1-time link data, accessing the link would make the link inaccessible to the sender (objective 5).
|
||||
- undetectbly check the existense of messaging queue or 1-time link (objective 8).
|
||||
- replace or delete the link data.
|
||||
|
||||
**Queue owner who did not comprmise the server**:
|
||||
|
||||
cannot:
|
||||
- redirect connecting user to another queue, on the same or on another server (objective 1).
|
||||
- replace connection request in the link (objective 4).
|
||||
|
||||
## Correlation of design objectives with design elements
|
||||
|
||||
1. The presense of `SenderId` in `LINK` response from the server.
|
||||
2. Encryption of link data with crypto_box.
|
||||
3. Auth tag in the link prevents server modification of immutable part of link data. Signature verification key in immutable part, and signing of mutable part prevents server modification of mutable part of link data.
|
||||
4. No server command to change immutable part of link data once it's set.
|
||||
5. 1-time link data can only be accessed with `LKEY` command, that while allows retries to mitigate network failures, will require the same key for retries.
|
||||
6. `LSET` command.
|
||||
7. The link only includes auth tag for immutable part, mutable part includes signature.
|
||||
8. Temporarily public IDs (SenderId and LinkId for 1-time invitations) are generated server-side, and cannot be provided by the clients when creating the queues to check if these IDs are free.
|
||||
|
||||
## Syntax for short links
|
||||
|
||||
The proposed syntax:
|
||||
|
||||
```abnf
|
||||
shortConnectionLink = %s"https://" smpServerHost "/" linkUri [ "?" param *( "&" param ) ]
|
||||
smpServerHost = <hostname> ; RFC1123, RFC5891
|
||||
linkUri = %s"i#" serverInfo oneTimeLinkBytes / %s"c#" serverInfo contactLinkBytes
|
||||
oneTimeLinkBytes = <base64url(linkId | linkKey)> ; 56 bytes / 75 base64 encoded characters
|
||||
contactLinkBytes = <base64url(linkKey)> ; 32 bytes / 43 base64 encoded characters
|
||||
; linkId - 96 bits/24 bytes
|
||||
; linkKey - 256 bits/32 bytes
|
||||
|
||||
serverInfo = [fingerprint "@" [hostnames "/"]] ; not needed for preset servers, required otherwise - the clients must refuse to connect if they don't have fingerprint in the code.
|
||||
|
||||
fingerprint = <base64url(server offline certificate fingerprint)>
|
||||
hostnames = "h=" <hostname> *( "," <hostname> ) ; additional hostnames, e.g. onion
|
||||
```
|
||||
|
||||
To have shorter links fingerpring and additional server hostnames do not need to be specified for preconfigured servers, even if they are disabled - they can be used from the client code. Any user defined servers will require including additional hosts and server fingerprint.
|
||||
|
||||
Example one-time link for preset server (103 characters):
|
||||
|
||||
```
|
||||
https://smp12.simplex.im/i#abcdefghij0123456789abcdefghij0123456789abcdefghij0123456789abcdefghij01234
|
||||
```
|
||||
|
||||
Example contact link for preset server (71 characters):
|
||||
|
||||
```
|
||||
https://smp12.simplex.im/c#abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
Example contact link for user-defined server (with fingerprint, but without onion hostname - 115 characters):
|
||||
|
||||
```
|
||||
https://smp1.example.com/c#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU@abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
Example contact link for user-defined server (with fingerprint ant onion hostname - 178 characters):
|
||||
|
||||
```
|
||||
https://smp1.example.com/c#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU@beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion/abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
For the links to work in the browser the servers must provide server pages.
|
||||