ATProto-based AI Assistant Chat interface.
scn-chat specs encrypted-conversations.md
32 kB · 360 lines

Encrypted Conversations #

Summary #

A user can make a conversation encrypted. Its records are sealed in the browser before they leave the device, so the PDS and the SCN Chat server only ever hold ciphertext. Keys belong to the user's devices, and the owner's browser gives a conversation's key to anyone who needs to read it: the owner's other devices, the people it is shared with, and, if the user allows it, a turn runner in an attested enclave.

A model still has to read the conversation, so every turn is answered by a turn runner that holds the key. There are two: the browser itself, which calls the model directly, and an enclave runner, which is the server's turn logic running inside a hardware enclave whose code the browser verifies before trusting it with a key. Whoever runs the model can still see the prompt, unless the model also runs in an enclave. This is therefore not end-to-end encryption in the messaging sense. The app calls it encrypted conversations, and describes it as unreadable by the PDS and the app server.

The feature is built in four phases, each useful on its own:

  1. Encrypted records, keys on the user's devices, sharing with people, and turns run in the browser with the user's own credentials.
  2. Short-lived model tokens, so browser-run turns can use admin models, and verifying attested inference endpoints from the browser.
  3. The enclave turn runner, which brings back background replies, tools, titles, and file ingestion for encrypted conversations.
  4. Sealing more metadata, and sending tool traffic through Oblivious HTTP relays.

Motivation #

Spaces give access control, not confidentiality: the PDS operator can read everything in a space, and the SCN Chat server reads every conversation it answers. For chats with an AI assistant, which people use for health, legal, financial, and personal questions, that is a lot of trust in two operators the user may never have chosen deliberately.

The founding principle already keeps the server from storing chat content. Encryption goes further: the server should be unable to read content at all, and the PDS should be trusted to store and serve records but not to read them. The user's device is the one place plaintext lives, apart from whatever runs the model.

The lexicons were built for this. encryptedContent and the sealed fields on info and conversationRef exist, and the server already refuses to answer encrypted messages. What is missing is key management, the runners, and the rules for what the server may and may not do with sealed records.

Design #

Trust model #

Party Can read content Relied on for
The web app code Yes Everything. It is served by the appview, and trusted because it is audited.
The SCN Chat server No Availability, relaying ciphertext, and authorizing model use. It sees usage metadata.
The enclave turn runner Yes, inside the enclave, for conversations granted to it Running only approved code, which the browser checks through attestation.
The PDS No Storing and serving records. It can withhold or roll back records, and sees write metadata.
The inference provider Yes, unless it runs in an attested enclave Answering.
Share recipients Yes, for what is shared with them Nothing.

Cryptography #

  • Symmetric sealing. AES-256-GCM with a random 96-bit nonce, through WebCrypto in the browser and node:crypto in the enclave runner. The scheme ID is scn-aes256gcm-v1.
  • Key wrapping. HPKE (RFC 9180) in base mode with the X-Wing hybrid KEM (X25519 combined with ML-KEM-768), HKDF-SHA256, and AES-256-GCM, through @hpke/core and @hpke/hybridkem-x-wing. A wrapped key stays secret as long as either X25519 or ML-KEM holds. The scheme ID is hpke-xwing-sha256-aes256gcm.
  • Signatures. Grants are signed by the owner with both Ed25519 and ML-DSA-65, through @noble/curves and @noble/post-quantum, and a signature is valid only when both verify. The signed message is the DAG-CBOR encoding of { uri, record }, with the grant's own URI and the record without its signature fields. The scheme ID is ed25519+mldsa65.
  • Quantum resistance. Records and grants sit on the PDS indefinitely, so everything that protects them must survive a future quantum computer. AES-256, SHA-256, and the 128-bit recovery code already do. Key wrapping and signatures are hybrid, so they do too, without depending on the newer post-quantum algorithms alone.
  • Padding. Plaintext is padded to the Padmé length for its size, with a 0x80 byte followed by zeros, before sealing. Ciphertext sizes then reveal only a rough size class.
  • Associated data. Every sealed value is bound to where it lives and to the record's clear fields. The associated data is the DAG-CBOR encoding of { uri, record }, where uri is the record's full URI and record is the record with its sealed field removed (content for a message, sealed for the others). A PDS cannot move a ciphertext to another record, or change a clear field such as generation.tools, without decryption failing.
  • Fingerprints. A fingerprint is the first 16 bytes of a SHA-256, in lowercase base32. A KEM key's fingerprint hashes its public key. An account's fingerprint hashes its KEM, Ed25519, and ML-DSA public keys together, and is the one users compare. Record keys and grants name keys by fingerprint.

Keys #

Key Kind One per Who can unwrap it Where it is stored
Account key An X-Wing key pair for receiving grants, plus Ed25519 and ML-DSA-65 key pairs for signing them User The user's devices Public halves in an encryptionKey record in the user's public repo. Private halves in keyBackup records, one per recovery method, and on each device.
Settings key AES-256, with epochs User The user's devices, and the delegation key keyGrant records in the settings space
Conversation key AES-256, with epochs Conversation The owner, each member, and the owner's delegation key if the conversation allows the enclave runner keyGrant records in the conversation space
Delegation key X-Wing key pair User The user's devices, and approved enclave runners keyGrant records in the settings space, wrapped to the account key and to each approved runner key

The delegation key exists so a new runner key costs one grant, not one grant per conversation. Conversation keys are wrapped to the delegation key, and the delegation key is wrapped to the runner.

A key's ID is its kind and epoch: sk:0, ck:0, dk:0. It is written to encryptedContent.keyId and to each grant.

Runner keys are X-Wing key pairs too.

A device never reads its own account's public keys from the PDS. It derives them from the private keys, so a PDS that swaps the published encryptionKey record cannot trick the owner into granting to someone else.

Lexicon additions #

All three are new record types, so they are compatible with published lexicons. keyGrant joins both space declarations, keyBackup joins the settings space, and all three join the permission set: the space permissions for the first two, and a repo permission for encryptionKey.

network.sharedcomputer.chat.encryptionKey, key literal:self, in the public repo:

{
  kemKey: bytes              // X-Wing public key, 1,216 bytes
  ed25519Key: bytes          // 32 bytes
  mldsaKey: bytes            // ML-DSA-65 public key, 1,952 bytes
  fingerprint: string
  createdAt: datetime
}

network.sharedcomputer.chat.keyGrant, key {keyId}.{recipient fingerprint}, in conversation and settings spaces:

{
  keyId: string              // 'ck:2'
  recipient: string          // fingerprint of the key it is wrapped to
  recipientKind: 'account' | 'delegate' | 'runner'
  recipientDid?: did         // for account recipients
  sender: string             // fingerprint of the owner's account key
  scheme: string
  enc: bytes                 // HPKE encapsulated key, 1,120 bytes for X-Wing
  ciphertext: bytes          // the wrapped key
  signatureScheme: string
  ed25519Sig: bytes          // 64 bytes
  mldsaSig: bytes            // 3,309 bytes
  createdAt: datetime
}

network.sharedcomputer.chat.keyBackup, key recovery, passkey-{credential hash}, or link-{tid}, in the settings space:

{
  method: 'recovery-code' | 'passkey' | 'device-link'
  credentialId?: bytes       // passkey only
  salt: bytes
  nonce: bytes
  ciphertext: bytes          // the sealed account private keys
  expiresAt?: datetime       // device-link only
  createdAt: datetime
}

What gets sealed #

Record Sealed In the clear
message content, as encryptedContent, whose plaintext is the plainContent role, parent, generation, model, effort, status, error, usage, createdAt
info title, systemPrompt, in sealed titleSource, createdAt
conversationRef title, tags, in sealed, under the settings key conversation, updatedAt
preferences customInstructions, in a new optional sealed field, under the settings key the rest
Blobs The bytes Size

A conversation is encrypted when its info record has sealed. It is chosen when the conversation is created, defaulting to a new encryptByDefault preference, and never changes. An existing plaintext conversation cannot be converted, since the PDS may keep earlier versions of its records.

Blobs are sealed in the browser as the nonce followed by the AES-GCM ciphertext, with the conversation URI and blob as associated data, and uploaded as application/octet-stream. The sealed parts refer to them with a blob ref carrying the ciphertext's CID and the plaintext's MIME type. The outer encryptedContent.blobs lists the same blobs with their real, octet-stream refs, so the PDS keeps them.

Once a device holds a key, it reads records exactly as before. Sealing happens at one boundary in the browser, apps/web/src/crypto/, and everything above it works with plaintext records.

Account setup and recovery #

Turning encryption on for the first time:

  1. The browser generates the account key and a recovery code of 128 random bits, shown once as 26 base32 characters in groups of four.
  2. It offers to register a passkey with the WebAuthn PRF extension, and seals the account private key under a key from HKDF over the PRF output, as a passkey backup. A passkey synced by the platform then unlocks new devices without the code.
  3. It seals the account private key under HKDF over the recovery code, as the recovery backup.
  4. It publishes the encryptionKey record, creates the settings key, and grants it to the account key.
  5. It keeps the account private key in IndexedDB. The device is trusted, as the browser store already is.

On a new device, after sign-in, a user with an encryptionKey record and no local key sees an unlock screen offering:

  • the passkey,
  • the recovery code, or
  • a link from another device: that device writes a link-{tid} backup under a random secret it shows as a QR code and short code, which expires after ten minutes and is deleted once used.

If every device and recovery method is lost, encrypted conversations cannot be read. The user can reset, which creates a new account key and leaves the old conversations unreadable but deletable. The setup screen says this plainly before creating the key.

Conversation keys and epochs #

  • Creating an encrypted conversation creates ck:0 and grants it to the owner's account key.
  • Sharing with a person grants every epoch to their account key, read from their encryptionKey record, so they can read the history.
  • Removing a person, or turning off the enclave runner for the conversation, creates the next epoch and grants it only to the remaining recipients. New records use the newest epoch. Old records are not re-sealed.
  • A recipient only accepts a grant whose sender is the owner's account key and whose signatures both verify. A grant the PDS wrote on its own fails that check and is ignored, with a warning.

The owner's browser pins each member's account key fingerprint the first time it grants to them, and warns before granting to a changed key. Two users can compare fingerprints out of band from the share dialog.

What the server does with encrypted conversations #

The server holds no keys, and treats sealed records as opaque:

  • Writes. For an encrypted conversation, write routes accept a record the browser has already sealed. The server validates the outer record against the lexicon, as it does now, and never looks inside. Routes that build records from plaintext refuse encrypted conversations with 400.
  • Turns. Chat-turns never claims a turn in an encrypted conversation unless it is the enclave runner and holds a grant for it. It skips the turn without writing anything, so a browser runner can claim it.
  • Sync. The title copy from info to conversationRef is skipped for sealed records. Discovery adds a missing conversationRef for an encrypted conversation without a title, and the owner's browser fills in sealed the next time it syncs.
  • Attachments. POST /api/attachments takes sealed blobs as application/octet-stream, checks only the size, and runs no ingester.
  • Streaming. The server relays encrypted stream events without reading them.
  • Credentials. A user can store a credential sealed under their settings key. The server stores it in provider_credential like any other, but cannot decrypt it, so only runners can use it. API keys stay in the app database, not in records.

Turn runners #

A turn runner answers turns for a conversation it can decrypt. Runners claim turns exactly as today: by creating the pending reply at {userMessageKey}.r{attempt}, so only one runner answers each attempt, whichever it is.

The turn logic moves out of apps/server/src/turns/ into packages/turn-engine, which runs in both Node and the browser. It takes decrypted records, a model, and tools, and produces reply parts and stream events: prompt building, reasoning replay, the stream-part mapping, and hooks. The plaintext server, the browser runner, and the enclave runner all use it, so a conversation behaves the same whichever runner answers.

Browser runner (phase 1). When a user sends a message in an encrypted conversation that does not allow the enclave runner, the browser:

  1. Seals and writes the user message with its generation request.
  2. Claims the turn by writing a sealed, empty pending reply.
  3. Builds the prompt from its local, decrypted copy.
  4. Calls the model directly from the browser and shows the stream.
  5. Seals and writes the final reply.
  6. After the first complete reply, generates a title the same way the titles plugin does, if the user's preferences allow, and writes it sealed to info and conversationRef.

Provider plugins that can run in a browser export a browser entry with the same createModel. The four shipped providers do. Anthropic needs its direct browser access header, and a self-hosted endpoint must allow CORS from the app's origin. The private network guard does not apply, since the requests come from the user's own browser.

The browser runner uses the user's sealed credentials, and admin models through the short-lived tokens of phase 2. It offers no tools, because the shipped tools run on the server, and calling them would give the server the tool input. PDFs are read in the browser with unpdf at upload, and the extracted text is sealed as its own blob. Other ingesters are unavailable until phase 3.

A browser that closes mid-turn leaves a pending reply. When the owner's browser opens a conversation, it rewrites as error, with the message "interrupted", any pending reply that is older than turns.timeoutSeconds plus a minute and is not being run by this browser.

Enclave runner (phase 3). See below.

Short-lived model tokens (phase 2) #

A provider plugin may implement:

issueToken?(args: { modelId: string; user: Did; ttlSeconds: number }): Promise<{ token: string; baseURL: string; expiresAt: string }>

POST /api/models/token with a model reference checks the user's roles, as model resolution does, and returns a token for an admin model whose provider can issue one. The browser runner uses the token and base URL in place of an API key. Tokens last turns.tokenTtlSeconds, default 300, and name one model.

The OpenAI-compatible plugin issues tokens for a self-hosted gateway when configured with tokenSigningKey: an ES256-signed JWT with sub (the user's DID), model, exp, and an optional maxTokens. apps/token-gateway is a small reference gateway that sits in front of vLLM or llama.cpp, checks the token and model, forwards the request, and reports usage back to the server. The server never sees the prompt.

Attested inference from the browser (phase 2) #

A provider plugin may export attestation, a browser module that verifies the provider's attestation and returns the key to encrypt requests to. A model whose provider has attestation is marked in GET /api/models, and an encrypted conversation can require attested models, which the model picker then enforces. This is an interface. Which providers implement it depends on who offers attested inference.

Enclave turn runner (phase 3) #

apps/runner is a separate process that runs the turn engine inside a confidential VM. It holds no OAuth sessions and talks to the PDS only through the server, so everything it sends out of the enclave is sealed.

Its key. On start, the runner gets its runner key and a platform attestation whose report data contains the key's fingerprint. GET /api/runner on the server returns the runner's public key, attestation, and build measurement.

Approving it. The web app ships with an allowlist of approved runner measurements, each matching a reproducible build published to a transparency log. The browser verifies the attestation's signature chain to the hardware vendor's root, checks that the measurement is on the allowlist, and checks that the report data names the key. If everything checks out, it grants the delegation key to the runner key. A new runner key means one new grant, which the browser makes the next time it opens the app.

Allowing it per conversation. A conversation allows the enclave runner when its owner turns on background replies for it, defaulting to a backgroundRepliesByDefault preference. That grants the conversation key to the delegation key. Turning it off rotates the conversation key.

Running a turn. The server hands the runner a conversation URI, the user message key, and, if the user's credential is a server credential, its key. The runner:

  1. Reads the grants and records through the server.
  2. Unwraps the delegation key, then the conversation and settings keys.
  3. Decrypts, claims the turn with a sealed pending reply, and runs the turn engine with plugins loaded inside the enclave: providers, tools, hooks, ingesters, and titles.
  4. Seals each stream event under the conversation key, with the reply URI and event number as associated data, for the server to relay.
  5. Writes the sealed final reply.

The runner holds unwrapped keys in memory only for the length of a turn. Admin API keys are released to the runner by the operator's key management, never to the server. Sealed user credentials are opened with the settings key.

The browser posts turns to the server instead of running them itself when the conversation allows the enclave runner and GET /api/runner returns an approved runner. Direct PDS writes get background replies in those conversations too, since sync finds the turn and the runner holds a grant.

Metadata hardening (phase 4) #

  • Sealed message fields. A new defs#sealedMessage type holds parts plus parent, generation, model, effort, usage, and error. It becomes the plaintext of a message's encryptedContent, and those fields are left out of the clear record. Readers accept both it and plainContent. role, status, and createdAt stay in the clear, since the lexicon requires the first and last and runners need the status to find stale claims.
  • Oblivious HTTP for tools. Tool plugins in the enclave runner send outbound requests through a configured OHTTP relay when the tool's service offers a gateway, so the service sees the query but not which instance or user sent it.

Web UI #

Barebones, like the rest of the UI:

  • An encryption section in settings: set up, show the account fingerprint, manage passkeys, view the recovery code once, link a device, and reset.
  • An unlock screen on a new device.
  • An "Encrypted" checkbox in the new conversation form, and a lock marker on encrypted conversations in the list.
  • In the share dialog: each member's fingerprint, and a warning when a member has no encryptionKey or it has changed.
  • A "Background replies" switch on encrypted conversations, shown only when an approved runner is available.

Conventions for other clients #

lexicons/README.md gains a section on encrypted conversations: the schemes, associated data, padding, key IDs and grants, blob sealing, and that the server answers encrypted turns only through a granted enclave runner. A direct client must be its own runner or grant the delegation key.

Scope Boundaries #

  • No converting existing conversations to or from encrypted.
  • No public sharing of encrypted conversations. Sharing with people only. A link carrying the key in its fragment could come later.
  • No detection of records the PDS withholds, rolls back, or deletes.
  • No encryption of the browser's local database beyond what the browser provides.
  • No hiding of timing, record counts, sharing membership, or approximate sizes from the PDS.
  • No tools in browser-run turns.
  • No protection against a compromised device or a malicious web app build. The audited web app is the root of trust.
  • No encryption of which model or provider a conversation uses until phase 4.

Edge Cases and Decisions #

  • Keys live in the user's settings space and public repo, never in the app database, so the PDS stays the source of truth for everything including keys, and the server has nothing to leak.
  • Conversation keys are granted to a per-user delegation key instead of directly to runner keys, so a new runner costs one grant, not one per conversation.
  • Grants are signed by the owner's account key, so a PDS cannot plant a key it knows as a new epoch. They use a separate hybrid signature instead of HPKE's auth mode, because auth mode only works with Diffie-Hellman KEMs and not with ML-KEM.
  • Key wrapping and signatures are hybrid rather than post-quantum alone, so a flaw found in ML-KEM or ML-DSA leaves the design no weaker than classical X25519 and Ed25519.
  • Post-quantum keys and signatures make each grant about 4.5 KB and each encryptionKey record about 3 KB. Sharing a long-lived conversation with someone writes one grant per epoch, which is still small.
  • ML-KEM and ML-DSA are not in WebCrypto in every browser, so the account private keys are held as raw bytes in IndexedDB rather than as non-extractable WebCrypto keys.
  • The associated data covers the clear fields, so a PDS cannot change generation, role, or parent on a sealed record.
  • The owner's browser derives its own public key from the private key instead of reading encryptionKey, so a swapped record cannot redirect the owner's grants.
  • Removing a member rotates the key but does not re-seal history. The member could already read it and may have kept a copy.
  • The recovery code has 128 bits of entropy, so the backup sealed under it resists offline guessing by the PDS without a slow KDF.
  • Local fallback users can encrypt too. Sealing happens in the browser above the record store, so the local database holds only ciphertext. They have no sharing, so they skip publishing encryptionKey.
  • The browser store holds decrypted content, and search indexes it. The device is trusted.
  • Sealed credentials are stored in the app database, keeping the rule that API keys never go in records, while the server still cannot read them.
  • The main server still sees server credentials passed to the enclave runner. That exposes the API key, not chat content, and users who mind can use sealed credentials.
  • Stream events from the enclave runner are sealed under the conversation key, so where TLS terminates does not matter. Browsers cannot check that TLS ends inside an enclave, and this makes it irrelevant.
  • To verify during implementation: that the alpha PDS accepts an octet-stream blob referenced from encryptedContent.blobs without checking the sealed refs.
  • To verify during implementation: the WebAuthn PRF extension in the browsers we support, with the recovery code as the fallback everywhere.
  • To verify during implementation: the maturity and audit status of @hpke/hybridkem-x-wing and @noble/post-quantum, and which HPKE draft codepoints for X-Wing they follow. Switch to WebCrypto where browsers ship ML-KEM and ML-DSA.
  • To verify during implementation: whether a spaces PDS keeps earlier versions of a record after putRecord, which decides whether converting a conversation could ever be safe.

Changes to other specs #

When this is built, these specs need updating: chat-storage (sealed writes, sync and discovery), chat-turns (runners and skipping encrypted turns), sharing (grants, and refusing public mode), attachments (sealed uploads), titles (runner-generated titles), providers (sealed credentials, tokens, browser entries, attestation), search and browser-store (decrypting on sync), and auth (the permission set additions).

The design gives the strongest guarantees when the appview operator and the inference provider are set up as follows. Neither is under this project's control.

  • The appview operator serves the web app from a reproducible build whose hash is published and independently audited for each release, ideally also as a signed browser extension. It runs apps/runner in a confidential VM (AMD SEV-SNP or Intel TDX), publishes each runner build's measurement to a transparency log, and uses key management that releases the runner key and admin API keys only to enclaves with an approved measurement.
  • The inference provider runs models on confidential-computing GPUs inside a CPU enclave, publishes attestations that cover the serving stack and model weights, keeps no logs outside the enclave, and offers an OHTTP gateway so requests cannot be tied to a client. Its TLS endpoints negotiate hybrid post-quantum key exchange, such as X25519MLKEM768, since prompts reach it in plaintext inside TLS and could otherwise be recorded now and decrypted later.

Even then, the trust ends with the hardware vendors and the physical security of the datacenters.

Acceptance Criteria #

Phase 1:

Phase 2:

Phase 3:

Phase 4:

Files #

Planned:

  • lexicons/network/sharedcomputer/chat/encryptionKey.json, keyGrant.json, keyBackup.json, and changes to defs.json, preferences.json, conversation.json, settings.json, and permissions.json
  • lexicons/README.md
  • packages/turn-engine/
  • apps/web/src/crypto/
  • apps/web/src/runner/
  • apps/server/src/turns/, apps/server/src/storage/, apps/server/src/sync/, apps/server/src/blobs/, apps/server/src/providers/
  • apps/runner/
  • apps/token-gateway/
  • plugins/*/src/browser.ts