> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tofunmiadewuyi.com/custos/llms.txt
> Use this file to discover all available pages before exploring further.

# Encrypted transport

> How to seal request bodies and open response bodies when CUSTOS_ENCRYPTION is on.

## 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:

| `CUSTOS_ENCRYPTION` | Request body | Response body |
| - | - | - |
| off | plain JSON | plain JSON, `application/json` |
| on | sealed bytes, `application/octet-stream` | sealed bytes, `application/octet-stream` |

There is no negotiation and no per-request opt-out. `readRequest` decrypts unconditionally when the
flag is on, and `writeResponse` seals unconditionally.

<Note>
  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.
</Note>

## The wire format

A sealed body is one self-contained binary blob:

```text theme={null}
sealed = ephemeral_public_key(32) || nonce(12) || aes_256_gcm_ciphertext_and_tag
```

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.

| Keypair | Lifetime | Generated by | Role |
| - | - | - | - |
| **Ephemeral** | one message | whoever is sealing | makes each seal unique; its public half is the first 32 bytes of the body |
| **Client session** | until rotation; retained for outstanding requests | the client, held in memory | the server seals **responses** to it |
| **Server transport** | long-lived | `custoscp gen-keys` | the client seals **requests** to it |

### 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:

```text theme={null}
request:   client Seal(server_transport_public,  request_json)
           server Open(server_transport_private, sealed_request)

response:  server Seal(client_session_public,    response_json)
           client Open(client_session_private,   sealed_response)
```

## Algorithms

```text theme={null}
Key agreement: X25519
KDF:           HKDF-SHA256
HKDF info:     custos-hybrid:v1
Cipher:        AES-256-GCM
Nonce:         12 random bytes
AAD:           none
```

Sealing:

```text theme={null}
ephemeral_private, ephemeral_public = X25519 keygen
shared = X25519(ephemeral_private, recipient_public)
salt   = ephemeral_public || recipient_public
key    = HKDF-SHA256(shared, salt, "custos-hybrid:v1", 32)
sealed = ephemeral_public || nonce || AES-256-GCM-SEAL(key, nonce, plaintext)
```

Opening:

```text theme={null}
ephemeral_public = sealed[0:32]
nonce            = sealed[32:44]
ciphertext       = sealed[44:]
shared           = X25519(session_private, ephemeral_public)
salt             = ephemeral_public || session_public
key              = HKDF-SHA256(shared, salt, "custos-hybrid:v1", 32)
plaintext        = AES-256-GCM-OPEN(key, nonce, ciphertext)
```

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:

```json theme={null}
{
  "email": "user@example.com",
  "password": "...",
  "client_public_key": "<base64 session public key>"
}
```

<Warning>
  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`.
</Warning>

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:

```json theme={null}
{
  "refresh_token": "...",
  "client_public_key": "<base64 new session public key>"
}
```

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

| Situation | What you see |
| - | - |
| Encryption on, client sends plain JSON | `400` — decryption fails before unmarshalling |
| Encryption off, client sends sealed bytes | `400` — the blob is not valid JSON |
| Right shape, wrong server key | `400` — GCM tag check fails |
| Client lost its session private key | response bytes will not open; refresh to register a new key |

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`](https://github.com/paulmillr/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:

```ts theme={null}
generateSessionKeypair(): { publicKey: Uint8Array; privateKey: Uint8Array }
sealToServer(jsonValue: unknown): Promise<Uint8Array>
openFromServer<T>(sealed: ArrayBuffer): Promise<T>
login(email: string, password: string): Promise<void>
refresh(): Promise<void>
request<T>(method: string, path: string, body?: unknown): Promise<T>
```

## 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](/custos/custos/internals/daemon-protocol).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.