Status: Phase 1 (AES-GCM + envelope round-trip) shipped in xkg-core v0.9.0. End-to-end multi-device convergence tests live under xkg-server/tests/e2e_three_devices.rs.
Owner: sync@seele.agency
Last revised: 2026-08-21
You should be able to install TabMind on any number of devices, point them at the same cluster-hub (a small REST server you control), and have every device converge on the same conversation history — without any device ever trusting the server with plaintext data.
Concretely, given three devices (A, B, C) and one hub:
Those six properties are exactly what
tests/e2e_three_devices.rs asserts.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Device A │ │ cluster-hub │ │ Device B │
│ xkg-desktop │ ──up──▶ │ /api/sync/* │ ◀──down──│ xkg-mobile │
│ per-device │ │ blind relay │ │ per-device │
│ 32-byte key │ ◀─down──┤ no plaintext │ ──up──▶ │ 32-byte key │
└──────────────┘ └──────────────┘ └──────────────┘
The hub is a blind relay: it stores opaque encrypted bytes and a small metadata header, but it can never read the conversation content. Only a device holding one of the per-device 32-byte AES keys can decrypt an envelope.
Threat model in one sentence. The hub operator is untrusted; network eavesdroppers are untrusted; device-local attackers are out of scope (you lost your laptop, you have bigger problems).
An SyncEnvelope is the wire format between every device and the hub:
{
"device_id": "01JC...",
"from_device_id": "01JC...",
"encrypted_payload": "<b64 ciphertext, no nonce>",
"nonce": "<b64 12 bytes>",
"ciphertext": "<b64 nonce||ciphertext, e2e-friendly>",
"cursor": 12345,
"message_cursor": 67890,
"conv_cursor": 12345, // Option<i64>
"msg_cursor": 67890 // Option<i64>
}
The two cursor fields are redundant aliases. The hub stores whichever shape it
receives; the canonical wire form for new clients is the
ciphertext field, where the 12-byte AES-GCM nonce is prefixed to the
ciphertext so the 2-arg decrypt(key, bytes) can handle a single byte buffer.
{
"conversations": [ { "id": "...", "title": "...", "llm": "chatgpt", ... }, ... ],
"messages": [ { "id": "...", "body": "...", "role": "user", ... }, ... ]
}
Both fields are JSON-serializations of the existing
Conversation / Message rows in the local SQLite store. No
new schema is introduced for sync.
| Primitive | Choice | Why |
|---|---|---|
| Symmetric cipher | AES-256-GCM | AEAD, NIST-approved, hardware-accelerated on every target platform. |
| Nonce | 12 random bytes from OS RNG | NIST SP 800-38D §5.2.1.1, no reuse risk at this volume. |
| Key length | 32 bytes (256 bits) | Matches AES-256 key size. |
| Serialization | JSON | Re-uses the existing schema; no schema-drift surface. |
| Encoding | Base64 (standard alphabet) | URL- and JSON-safe; no padding gotchas in YAML/JSON envs. |
Every device generates its own key on first run with the OS RNG and persists it under the OS keychain (Keychain on macOS, Credential Manager on Windows, libsecret on Linux). The hub never sees the key.
When a device receives a peer's envelope and decrypts it, applying the rows must be idempotent. The contract is:
conversation dedupe key = (id)
message dedupe key = (conversation_id, client_msg_id)
Concretely, CaptureStore::upsert_conversation_from_sync does
INSERT OR IGNORE on the conversation id; if the insert takes, the row is
new; otherwise we update mutable fields and report "existing". The same pattern
holds for messages on (conversation_id, client_msg_id). Re-applying the same
envelope is therefore a no-op, which is what makes convergence tests reliable.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/sync/devices | Register a device, get a canonical record back. |
| POST | /api/sync/upload | Submit an envelope. Returns cursor advance. |
| GET | /api/sync/download | Fetch the most recent envelope. |
| GET | /api/sync/download?since={cursor} | Fetch all envelopes with cursor > since. |
The hub is stateless beyond the envelope store — no conversations, no messages, no users, no billing. Operators can replace or re-implement the hub without touching any client.
bundle_since takes a full
store snapshot. Cursor-based delta sync is a follow-up — see
TASK-xkg-core-20260821-235.Two integration tests cover the wire-level contract:
tests/sync.rs — single-device round-trip against
httpmock. Builds an envelope, uploads it, downloads it, decrypts it,
applies it, asserts FTS5 search finds the same hits on both stores.tests/e2e_three_devices.rs — 3-device convergence against the
same mock transport. Asserts all six properties from §1.Both tests run as part of cargo test --workspace in
xkg-core. They live under tests/deferred/ until the final
API-shape decision lands — see the deferred README for status.
Questions? File an issue on
github.com/griptoad26/xkg-server
or email sync@seele.agency.