Skip to main content

First: check whether it is on

This whole page applies only when the control plane runs with CUSTOS_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 ephemeral public key is generated per message and is the first 32 bytes of the body. It is never a JSON field and never a header.

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 — on POST /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

Sealing:
Opening:
Note the salt is ordered 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

  1. Build the normal JSON payload.
  2. UTF-8 encode it.
  3. Seal it to the server transport public key.
  4. Send the sealed bytes with Content-Type: application/octet-stream.
  5. Keep sending Authorization: Bearer <access_token> as normal — headers are not encrypted.
Bodies are read through a 1 MiB limit, so an oversized sealed payload fails before decryption. 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:
Go decodes []byte from a base64 string. client_public_key must be a base64 string — not a byte array, not hex, not an object. A number array is the most common way to get a confusing 400.
The response opens to { "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:
The server replaces the session’s stored client key and rotates the refresh token. Store both values from the response — the refresh token you sent is now dead.

Responses

For a 2xx 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_key on 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.