DocumentationDecision recordsADR-0043
End-to-end encrypted sync
ADR-0026 replicates versioned domain operations through an ordered per-workspace log in a Cloudflare Worker and Durable Object, with large bodies and media…
accepted
Context
ADR-0026 replicates versioned
domain operations through an ordered per-workspace log in a Cloudflare Worker
and Durable Object, with large bodies and media travelling as
content-addressed chunks (sync content chunks v1).
Every one of those payloads is plaintext today: the Durable Object's
sync_operations.payload_json holds note titles, document bodies, tags,
people, and task text, and R2 holds media bytes and whole workspace archives
as published checkpoints.
That is at odds with the product. Skriuw is local-first: SQLite is canonical, navigation and editing never wait on the network, and ADR-0037 moved convergence entirely client-side onto facts both devices already share. The service therefore never needs to read a payload to do its job. It needs to order operations, refuse duplicates, account for bytes, and hand the same bytes back. Everything else it currently sees, it sees only because nobody sealed it.
docs/specs/cloud-sync-master.md carried this as an open decision before
public beta (issue #342).
Decision
Workspace content is sealed on the client before it is pushed and opened on the client after it is pulled. The sync service stores, orders, and returns opaque bytes.
Key model
- One symmetric workspace content key (32 bytes) seals everything for one workspace.
- The key is derived, not stored anywhere but on the devices that hold it:
key = Argon2id(recovery code, salt = SHA-256("skriuw-sync-e2ee-salt-v1" ‖ workspace id)), with m = 19 MiB, t = 2, p = 1. - The recovery code is 160 bits of platform entropy rendered as 32 Crockford base32 symbols in eight groups of four. It is shown once, at enable time, and never stored. Deriving the key from the code plus the workspace identity is what lets a second device join by typing the code and nothing else — no wrapped key ever has to reach the server.
- The account password is deliberately not an input. Password reset, session theft, and a compromised auth service therefore cannot yield the content key.
key_idis the first 8 bytes ofSHA-256("skriuw-sync-e2ee-key-id-v1" ‖ key), in hex. It travels in the clear so a device holding the wrong code fails with "this device holds key …" instead of an authentication-tag failure.- Sealing is XChaCha20-Poly1305. The AAD binds each blob to its slot
(
skriuw/sync/operation/<workspace>/<device>/<client sequence>/<base server sequence>/<operation id>,…/asset/<workspace>/<operation id>/<content hash>,…/checkpoint/<workspace>/<server sequence>), so ciphertext cannot be replayed into a different operation, asset, or workspace, nor reattributed to another device, sequence, or causal base. - Nonces are derived, not random, and keyed:
nonce_key = BLAKE2b-MAC-256(key, persona "skriuw-nonce-key", "skriuw-sync-e2ee-nonce-subkey-v2"), thennonce = BLAKE2b-MAC-192(nonce_key, persona "skriuw-nonce-v2", len(context) ‖ context ‖ plaintext). Derivation keepsskriuw-cryptofree of any random-number dependency, so it compiles unchanged forwasm32-unknown-unknown, and makes re-sealing an operation byte-identical, which the service's idempotent-push rule (same operation id must carry the same content) requires. Keying it matters: scheme…-v1derived the nonce as an unkeyed hash of the key id, the context, and the plaintext digest — all public except the plaintext — so anyone holding a candidate plaintext could recompute the nonce and confirm the guess offline. v1 is refused as an unsupported scheme; it never shipped, so no sealed v1 content needs migrating.
Metadata boundary
Sealed — the service cannot read any of it:
- note titles, document JSON and Markdown, word counts
- tags, people, note properties, annotations and their comments, tasks, prompts
- the operation type itself
- image and video bytes, their mime types, and their content digests
- entity identifiers (note ids, folder ids, tag ids)
- the whole workspace archive behind a published checkpoint
Visible — the service reads and relies on it:
workspaceId,deviceId,operationId,clientSequence,baseServerSequence,serverSequence- ciphertext size, chunk count, chunk digests (over ciphertext), and total stored bytes for quota
keyId,scheme,nonce- whether the workspace is encrypted, under which key id, and the server sequence encryption began after (see The service's encryption record)
- request timing, and the acknowledged cursor per device
Two consequences of this boundary are explicit, not accidental:
- The service can no longer enforce operation policy. It cannot reject a
device_localoperation or an unsupported operation type by name, because it cannot read the name. Enforcement moves to the client, which validates the queued payload against the replication policy before sealing and validates the opened envelope again after opening. The service keeps enforcing everything that does not require reading: identity, sequencing, duplicate detection, size ceilings, chunk presence, and quota. - The service can no longer cross-check an
attach_imageasset against its manifest, since the declared digest is sealed. The client verifies the opened bytes against the digest the opened operation declares before storing them, which is the check that actually matters.
What deterministic sealing reveals: sealing the same plaintext in the same slot under the same key reproduces the same nonce and ciphertext. The slot binds the workspace, the device, its sequences, and the operation id, and operations are immutable, so in practice this is "re-pushing the same operation looks the same", which the idempotency rule already requires. Across different slots the nonce is a keyed PRF output, so equal bodies in two different notes, or a body the service guesses, cannot be linked or confirmed without the key.
Wire format
A third payload form rides the existing sync protocol version 2 rather than a new protocol version:
{ "form": "sealed",
"operation": { "scheme": "argon2id-xchacha20poly1305-v2",
"keyId": "0f1e2d3c4b5a6978",
"nonce": "…",
"transport": "inline", "ciphertext": "…" },
"assets": [ { "…": "…", "transport": "chunked", "manifest": { "…": "…" } } ] }
Ciphertext above the inline ceiling travels through the existing chunk
transport, with digests taken over ciphertext and the opaque mime type
application/octet-stream. A checkpoint gains an optional seal object; when
present, its content manifest describes ciphertext.
Bumping WORKSPACE_SYNC_PROTOCOL_VERSION was rejected: the client refuses a
response whose version is not the newest it knows, so a bump would break every
released client against the updated Worker, including workspaces that never
enable encryption. A new payload form is inert for those workspaces.
Where the boundary sits
skriuw-sync seals a claimed batch after it leaves the durable outbox and
opens a pulled page before it reaches the apply path. Everything above —
SQLite, reconcile, conflict review, search, export, history — keeps seeing
plaintext, and local canonical state stays unencrypted at rest. Local disk is
not in this threat model: the notes are already readable on the device that
wrote them, and encrypting them there would trade a real recovery story for no
attacker the service does not already exclude.
Enabling and unlocking
Enabling stores the derived key on the device, then asks the service to
record its key id (POST /v1/workspaces/{id}/encryption). The record is
write-once, so the reply names whichever key reached the service first. When
that is not this device's key, another device already encrypted the
workspace: the local key is removed again and the user is told to enter that
device's recovery code. Storing before claiming means a crash between the two
never leaves the cloud encrypted under a key no device holds; the service is
the arbiter, so two devices enabling at once cannot both succeed.
Unlocking reads the record first and derives the key with the workspace's
floor. A code whose key id does not match the recorded one is refused and
nothing is stored, so a mistyped code can never seal new work under a key the
other devices cannot open. A device whose stored key stops matching the
record parks as sealed_content_unreadable and the settings surface keeps
the recovery code field open until a matching code is entered.
The derived key is cached in the device-local sync_encryption table
(migration 0026) so the recovery code is entered once per device. The row
names the workspace it was derived for and is ignored for any other workspace,
so a reset or relink never seals with a stale key. It is never replicated:
WorkspaceOperation has no variant that carries it. It never leaves the
device in a file either: scheduled, manual, and pre-import backups, and every
restored database, are written without it, so a device restored from a backup
parks as encryption_key_required until the recovery code is entered again.
Moving a workspace to another folder on the same device is the one copy that
keeps it, because it is the same device. Key material is held in zeroizing
buffers and redacted from debug output.
The service's encryption record
The service keeps one durable, write-once record per workspace — scheme, key
id, the server sequence encryption began after, and when — in the Durable
Object's sync_encryption table. The first sealed push, the first sealed
checkpoint, or an explicit enable writes it inside the same transaction that
admits that content. From then on the service refuses, with HTTP 423:
- any push that carries an operation not in the
sealedform (workspace_encrypted) - any checkpoint without a
seal(workspace_encrypted) - sealed content, operation or asset, naming a different key id
(
encryption_key_mismatch)
Identical retries of operations the service accepted before the record existed are still answered from the log or the operation index, because they add nothing new.
Clients read the record (GET /v1/workspaces/{id}/encryption) before any
upload — before the push phase whenever local changes are queued, and before
exporting a checkpoint — so a device that holds no key, or holds another key,
parks instead of putting anything on the wire. The 423 is the backstop for a
record that appears between that read and the push. Released clients that
predate this ADR read the 423 as an ordinary rejection and park the change
after three identical answers; they never retry it into the log. They can
still upload plaintext chunks ahead of a refused push, because chunk bytes
are opaque to the service; those chunks are never referenced and the 24-hour
unreferenced-chunk sweep removes them.
Failure behavior
Recovery-relevant failures stay visible, per the repository rules:
a device with no key that pulls sealed content, or that has queued changes for a workspace the service records as encrypted, parks as
blocked { reason: "encryption_key_required" }without uploading thema wrong recovery code, a tampered ciphertext, or an unknown scheme parks as
blocked { reason: "sealed_content_unreadable" }with the crypto layer's actionable messageplaintext where the device's encryption floor requires sealed content — an unsealed operation above the floor, or an unsealed checkpoint once the device holds the key — parks as
blocked { reason: "encryption_downgrade_refused" }
None of them is retried into a loop, and none applies anything.
Encryption floor
The device-local seal record carries encrypted_from_server_sequence: the
server sequence encryption began after. Every pulled operation above it must
arrive sealed; plaintext at or below it is what the workspace replicated
before encryption and still applies. Once a device holds the key it also
refuses every unsealed checkpoint, whatever its sequence. The floor never
moves after it is recorded, so a service that later forges a plaintext
operation or swaps a sealed checkpoint for a readable one is refused rather
than obeyed.
What sealing does not protect
Sealing authenticates each blob and binds it to its slot. It does not make the log as a whole authentic:
- Freshness. The service can withhold new operations, replay an older checkpoint, or roll a device back to an earlier state of the log. Nothing here detects it.
- Omission and reordering. The service chooses which sealed operations a device sees and in which order; each one opens, so none is refused.
- The floor a joining device adopts. A device that joins an already encrypted workspace takes the floor from the service's encryption record. A service that lies about it can admit forged plaintext operations below the claimed floor on that device only.
Closing these gaps needs a signed, hash-chained log head, which is a separate decision.
Migration and recovery
- Enabling encryption on a workspace that already replicated in the clear seals everything pushed from that moment, and forces a sealed checkpoint at the next cycle regardless of the publication interval. The service's existing retention pass compacts the plaintext log below the acknowledged floor once every active device has caught up, and sweeps the unreferenced plaintext chunks after the 24-hour grace period. Until that completes, the operations the service already accepted remain readable; the settings surface says so.
- Turning encryption off is not offered. It would mean re-pushing the whole workspace in the clear, and the honest alternative — stop sealing new work while old sealed work stays unreadable to keyless devices — is worse than not offering it.
- Losing the recovery code loses the cloud copy and nothing else. Local canonical state is untouched, and a device that still holds the key can export a portable archive.
- Rotating the code means deriving a new key and re-pushing, which is the same work as the initial migration. It is not implemented yet.
Consequences
- The Worker and Durable Object cannot be subpoenaed, mis-configured, or
breached into revealing note content for an encrypted workspace. Their
logs already carry no content (
logSyncSecurityEventkeeps only server-chosen codes); now their storage carries none either. - Server-side search, server-side merge, and any future server-rendered preview are permanently unavailable for encrypted workspaces. None of them exist, and ADR-0037 already put merge on the client.
- Argon2id at 19 MiB runs once per device per unlock, off every interaction path, in the sync worker thread or the browser storage worker. Sealing and opening are XChaCha20-Poly1305 over payload-sized buffers on the same background path; the performance contract's navigation and editing budgets are untouched because the cycle never runs on them.
- Inviting collaborators to an encrypted workspace requires distributing the content key to other accounts, which this ADR does not solve. Group key distribution is a follow-up, and it must land before shared workspaces do.
- A device running an older Skriuw build against a workspace that has been encrypted will reject the sealed payload as an unsupported form and park. That is correct: it has no key and could not apply the operation anyway.
- Rollout order matters, and the clients no longer depend on it being right.
A Worker that predates the encryption route answers it with the generic
not_foundcode, which the transports classify as an absent route rather than as theworkspace_access_denieddenial that shares its status. A device that has never held a key for the workspace then keeps replicating in the clear exactly as it did before the route existed — the encryption record and the operation log live in the same service, so a service without the record cannot be holding an encrypted workspace. A device that does hold a key stops instead, visibly, with theserver_too_oldreason: it cannot establish the workspace's key state, and plaintext from it would be a downgrade. Enabling and unlocking against such a Worker fail with a message naming the server and store nothing. The residual risk is a Worker rolled back below the encryption schema while sealed content already exists: a keyless device would then upload plaintext into an encrypted workspace, which the other devices refuse as a downgrade rather than apply, so the failure stays visible instead of corrupting state. - The Worker is still deployed before the clients that need it, and that order
is now enforced rather than remembered: the browser client deploys from
daddythrough Vercel, sodocs/specs/cloud-sync-delivery.mdmakes the order a stop condition,GET /healthreports the deployment's capabilities, and.github/workflows/release-v2.ymlwill not publish a desktop release the deployed Worker cannot serve. Older clients keep working against the new Worker.
On this page