First: check whether it is on
This whole page applies only when the control plane runs withCUSTOS_ENCRYPTION on.
If encryption is off, there is nothing to implement. Send plain JSON, set
Content-Type: application/json, read plain JSON back. Every curl example in these docs works as
written. Local development commonly runs this way, and so does the frontend container by default
(CUSTOS_ENCRYPTION=false).
The two sides must agree. The control plane decides, and your client has to match it:
There is no negotiation and no per-request opt-out.
readRequest decrypts unconditionally when the
flag is on, and writeResponse seals unconditionally.
This is an extra layer on top of TLS, not a replacement for it, and it is separate from the vault
encryption that protects secrets at rest. Turning it off disables neither.
The wire format
A sealed body is one self-contained binary blob:The three keypairs
Encrypted transport involves three X25519 keypairs. Keeping them straight is most of the work.Ephemeral
You never configure or store these. Every call to seal generates a fresh pair, uses the private half for the key agreement, and ships the public half as the body’s first 32 bytes. Requests and responses each generate their own.Client session
Your client generates a keypair on login or refresh and keeps the private half in memory only. It sends the public half when it creates or renews a session — onPOST /login and POST /refresh,
inside the sealed body as client_public_key. The control plane stores it on the session and binds
it to each access token minted with that key.
Responses are sealed to the key bound to the access token used by the request, even if a refresh
rotates the session key before authentication or response writing. The refresh response itself is
sealed to the new key supplied in its body. Keep older private keys until requests using their
access tokens have finished.
After login, stop sending client_public_key. The server already has it.
Server transport
The long-lived pair belonging to the control plane.custoscp gen-keys prints both halves: the
private one stays on the server, and the public one — a base64 32-byte X25519 key — is configured on
every client, which uses it as the recipient for request bodies.
Putting it together
The two directions use different recipients, which is why there is no single “the” transport key:Algorithms
ephemeral || recipient in both directions — when opening, the recipient
is you, so it is your own session public key, not the ephemeral one you just read.
Requests
- Build the normal JSON payload.
- UTF-8 encode it.
- Seal it to the server transport public key.
- Send the sealed bytes with
Content-Type: application/octet-stream. - Keep sending
Authorization: Bearer <access_token>as normal — headers are not encrypted.
GET and DELETE requests have no body and need no sealing. Their responses are still sealed,
because the server seals to the key bound to the request’s access token regardless of whether the
request had a body.
Login
The sealed login body carries your fresh session public key:{ "access_token": "...", "refresh_token": "...", "expires_in": 900 }.
Refresh
Refresh when the access token expires, or when the page reloads and the in-memory private key is gone. Generate a new session keypair first:Responses
For a2xx with a body: read the raw bytes, open with the private key associated with the access
token sent on that request (or the key supplied on login/refresh), UTF-8 decode,
JSON parse. A 204 has no body.
Error responses are not sealed. Non-2xx bodies are written with Go’s http.Error, so they
are plain text. Parse them as text, not as encrypted JSON — a client that tries to decrypt an error
will fail on every 403 it ever sees.
When the two sides disagree
All four look alike from the outside: a
400 with a short plain-text body. Check the flag on both
sides first.
Implementing it
The reference client uses@noble/curves for X25519
and WebCrypto for HKDF-SHA256 and AES-GCM, which is a reasonable split in a browser — WebCrypto
has no X25519 everywhere yet, but its HKDF and AES-GCM are fine. Any audited implementation of the
three primitives works. Don’t add a dependency if you already have them.
A minimal client needs:
Do not
- Send plaintext JSON while encryption is on.
- Send
client_public_keyon every request — only on login and refresh. - Put the ephemeral public key in JSON or a header; it is the first 32 bytes of the body.
- Persist the session private key. It is in-memory, per session, by design.
- Log plaintext payloads, private keys, derived keys, nonces, tokens, or sealed bodies.
- Implement daemon WebSocket signing or machine-secret sealing in a client. That is the daemon’s job — see Daemon protocol.