Content Encryption at Rest

Server-side content encryption at rest and browser-keyed locked chats

8 min read

This page covers two independent features:

  • Content encryption at rest (Enterprise, disabled by default) — server-side encryption of stored conversation and tool content, covered by the sections below.
  • Locked chats (disabled by default) — chats encrypted under a key only the user's browser holds.

They work separately or together.

Enterprise feature: Content encryption at rest requires an enterprise license. Contact sales@archestra.ai for licensing information.

Beyond stored secrets, Archestra can encrypt conversation and tool content at rest. Set ARCHESTRA_CONTENT_ENCRYPTION_SECRET — a key separate from the stored-secrets key, so a security team can hold it in their own vault and map it to the environment variable at deploy time. Encryption and decryption are transparent; rows written before enablement are encrypted by a background sweep.

What Is Encrypted

  • LLM proxy request and response payloads — the LLM Logs records.
  • Chat message bodies.
  • MCP tool call arguments and results — the MCP Logs records. A tool result carries whatever the tool returned — an email inbox, for example — so it is treated as content, not metadata.
  • Guardrail analyses and the unsafe context boundary, which quote the content they judged.

Metadata stays in plaintext: timestamps, model names, token counts, tool and server names, and cost figures. Statistics, cost limits, and usage metering keep working unchanged.

Behavior Changes While Encryption Is On

  • Conversation search matches titles only — message bodies are ciphertext.
  • MCP log search matches server names and methods only — tool arguments and results are ciphertext.
  • OTel spans stop carrying message/tool content by default — the ARCHESTRA_OTEL_CAPTURE_CONTENT default flips to false so plaintext content does not reach the telemetry backend while the database copies are encrypted. An explicit true re-enables capture (see the observability docs) and logs a startup warning.

Enabling on a Running Deployment

Enabling takes two rollouts, so replicas never mix encrypted writes with readers that lack the key: first deploy with the key in ARCHESTRA_CONTENT_ENCRYPTION_SECRET_PREVIOUS (decrypt-capable everywhere, writes unchanged), then move it to ARCHESTRA_CONTENT_ENCRYPTION_SECRET. After the second rollout finishes, run pnpm --filter backend db:reencrypt-content once: replicas that had not yet restarted during the rollout may have written a few plaintext rows behind the background sweep, and an explicit run always re-verifies the full table.

Rotating the Key

Rotation is the same shape: add the new key as ..._PREVIOUS and roll out, swap the two variables and roll out again, let the background sweep re-encrypt (or run pnpm --filter backend db:reencrypt-content), then drop ..._PREVIOUS.

Disk Headroom

Plan disk headroom before enabling. Plaintext JSONB payloads compress inside PostgreSQL (typically ~1.5–2×); ciphertext does not, and carries a further ~33% base64 overhead. Expect the interactions, messages, and mcp_tool_calls tables to roughly double on disk once encrypted, with additional transient bloat while autovacuum reclaims the pre-encryption row versions, and a WAL/backup burst on the order of the final encrypted size while the sweep runs. Size the database volume for at least 2.5× the current combined size of those tables before enabling.

Startup Verification

On every startup Archestra verifies the configured key against previously encrypted content and aborts on a mismatch or a missing key. Unlike stored secrets, there is deliberately no accept-new-key override: chat history cannot be re-entered. Disabling encryption after enabling it is not currently supported.

Locked Chats

A locked chat is encrypted under a key that exists only in the browser that created it. The browser generates the key, keeps it in local storage, and sends it with each request. The server uses it in memory to serve the chat and never stores it — no key the platform holds can decrypt the messages.

This is not end-to-end encryption. The server sees content while serving requests: it forwards messages to the LLM provider and runs your security policies on them. The guarantee is at rest — a database dump, a backup, or an operator with the content encryption secret cannot read a locked chat.

Locked chats are off until you configure key escrow. Escrow keeps an offline-recoverable copy of each chat's key.

Escrow is required because a locked chat encrypts its own audit trail. Without an escrowed copy, those records could be read by nobody — not even during an investigation. Remove the escrow key to turn the feature off again.

Users start a locked chat from the composer toggle. If the browser's copy of the key is lost — cleared site data, a different browser or device — the chat opens to a notice that its contents can't be read. The conversation row and its title remain visible. Without key escrow, a lost key is unrecoverable.

What changes while a locked chat is active:

  • Messages are stored encrypted under the chat's own key.
  • LLM request logs, MCP tool call logs, and chat errors are encrypted under the same key. Usage, cost, and model metadata stay in plaintext, so statistics, cost limits, and metering are unaffected.
  • The Logs pages show those records with their content locked. Recovering it takes the escrow private key — see Break-Glass Recovery.
  • The title is fixed to "Locked chat" — no LLM title generation. A manual rename is stored in plaintext.
  • Files you attach are encrypted under the same key — see Attachments.
  • Sandbox commands, sharing, forking, projects, and context compaction are unavailable.

Attachments

You can attach files to a locked chat. The bytes, the filename, and any text Archestra extracts for search are all encrypted under the chat's key, like the messages. The file type, its size, and the upload date stay in plaintext.

Two things work differently from an ordinary chat:

  • The file is not copied into the agent's code sandbox, so run_command cannot open it. The model still reads the file directly when its type allows.
  • The file cannot be added to a knowledge base. Doing so would write a plaintext copy into a repository other people can read.

App Chats

Opening an app from the Apps page starts a chat. Use "Open as locked chat" in the app's menu to start that one locked. The app renders the same way; the conversation around it is encrypted.

Key Escrow

Escrow wraps each new chat's key to an RSA public key whose private half your security team holds offline. Configuring it enables locked chats. Recovery is a deliberate break-glass procedure, not something the platform can do alone. Set ARCHESTRA_LOCKED_CHAT_ESCROW_PUBLIC_KEY to an RSA public key (PEM, at least 2048 bits). Generate a keypair with:

openssl genrsa -out locked-chat-escrow.pem 4096
openssl rsa -in locked-chat-escrow.pem -pubout -out locked-chat-escrow.pub

Keep locked-chat-escrow.pem offline with your security team. Configure only the public half.

Enable escrow in its own rollout, after the release is fully deployed. Until the key is set, no replica writes a locked-chat record, so a mixed fleet never meets one it cannot read.

The wrapped key is stored on the conversation row. Archestra cannot read it — only the offline private key opens it, so a database dump holds content encrypted under one key and the key itself encrypted under another, and yields neither.

Escrow key rotation affects new chats only: each conversation stores its key wrapped to the escrow key configured at creation time.

Break-Glass Recovery

The escrow record is a JSON blob in the conversation row's locked_chat_escrow column, holding the chat key wrapped as RSA-OAEP (SHA-256).

Recovery happens outside the platform, with database access. The holder of the escrow private key decrypts wrappedDek to get the chat key, then decrypts each envelope with AES-256-GCM.

Every envelope uses an AAD of <column>|incognito:<conversation id>, which binds it to both the column and the chat. The incognito part is the feature's former name, kept because it is authenticated into envelopes already written. Use the column the value came from:

TableColumnAAD column part
messagescontentmessages.content
interactionsrequestinteractions.request
interactionsprocessed_requestinteractions.processed_request
interactionsresponseinteractions.response
interactionsdual_llm_analysesinteractions.dual_llm_analyses
interactionsunsafe_context_boundaryinteractions.unsafe_context_boundary
mcp_tool_callstool_callmcp_tool_calls.tool_call
mcp_tool_callstool_resultmcp_tool_calls.tool_result
conversation_chat_errorserrorconversation_chat_errors.error
conversation_attachmentsoriginal_nameconversation_attachments.original_name
conversation_attachmentstext_previewconversation_attachments.text_preview

Rows in interactions and mcp_tool_calls carry the chat they belong to in locked_chat_conversation_id. Select on it to find everything one chat produced — mcp_tool_calls has no other reference to the conversation.

Each envelope decrypts to {"v": <original value>}.

Attachment bytes are the one exception to the envelope format. conversation_attachments.file_data is a bytea holding the raw ciphertext behind a 29-byte header — one version byte, the 12-byte IV, then the 16-byte GCM tag — with an AAD of conversation_attachments.file_data|incognito:<conversation id>. It decrypts to the file itself, with no {"v": …} wrapper. Rows written this way carry locked_chat = true; on those rows content_hash is an HMAC under the chat key rather than a digest of the file, so it cannot be used to recognise a file whose bytes you already hold.

A record whose content reads {"__redacted": "locked_chat"} was never stored and cannot be recovered. Records written before the feature was renamed read {"__redacted": "incognito"} and mean the same thing. Archestra writes that only when it could not encrypt correctly — no key on the request, a key that did not match the chat, or a chat with no escrow record.

Plan who holds the private key and under what procedure before enabling escrow.