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

# Protocol

> The Encrypted wire protocol: variants, keys, per-message encryption, the envelope, the headers, and decrypting the reply.

This is the protocol the Nito Browser App implements, written down for anyone building their own Encrypted client against `POST /v1/chat/completions`. The gateway relays what you send and checks its shape. The cryptography runs on your device and inside the sealed hardware.

## Variants

The attestation you fetch before the call selects the variant. Read its `proof_format`. The same marker may also appear inside the raw evidence.

| Variant | Selected by | Cipher | Version header | Nonce and timestamp headers | Encrypted tool loop |
| :- | :- | :- | :- | :- | :- |
| **Standard** | Any proof format not listed below | XChaCha20-Poly1305 for ed25519 keys, AES-256-GCM for ecdsa keys, no additional authenticated data | `2` | Omit | Available |
| **Authenticated** | `redpill-attestation-report-v2` | AES-256-GCM with additional authenticated data for both key types | `2` | Required | Rejected |
| **Legacy** | `redpill-attestation-report-v1` or `redpill-report` | As Standard | Omit | Rejected | Rejected |

## Keys

**The model's public key** comes from the attestation as `model_public_key`, with `signing_algo` naming its type: `ed25519` (32 bytes) or `ecdsa` on secp256k1 (64 bytes, or 65 with the `04` prefix). Verify the evidence before using it. See [Verify It Yourself](/privacy/tee-attestation/verify-yourself-recipe).

**Your key pair** matches the model key's type and lives for the conversation:

* `ed25519`: generate an ed25519 key pair. The header carries the 32-byte public key as hex. For key agreement, convert your secret key and the model's public key to their X25519 forms.
* `ecdsa`: generate a secp256k1 key pair. The header carries the uncompressed 65-byte public key as hex. A 64-byte model key is prefixed with `04` before use.

**An ephemeral key per message.** Every encryption generates a fresh ephemeral key pair of the same type, so no two envelopes share a symmetric key.

## Encrypting a Message

For each `messages[].content` string:

1. **Agree a shared secret** between the ephemeral secret key and the model's public key. `ed25519`: X25519 over the converted keys. `ecdsa`: the x coordinate (32 bytes) of the secp256k1 ECDH point.
2. **Derive the symmetric key** with HKDF-SHA256: input the shared secret, no salt, 32 bytes out, with the info string `ed25519_encryption` for ed25519 or `ecdsa_encryption` for ecdsa.
3. **Encrypt** the UTF-8 plaintext. Standard and Legacy with an ed25519 key: XChaCha20-Poly1305 with a random 24-byte nonce. Every other case: AES-256-GCM with a random 12-byte nonce. On the Authenticated variant, pass the request's additional authenticated data (below).
4. **Build the envelope**: the ephemeral public key, the nonce, then the ciphertext with its authentication tag, concatenated and hex encoded. Replace the content string with it.

| Key type | Ephemeral public key | Nonce | Envelope prefix |
| :- | :- | :- | :- |
| ed25519, Standard or Legacy | 32 bytes (X25519) | 24 bytes | 56 bytes |
| ed25519, Authenticated | 32 bytes (X25519) | 12 bytes | 44 bytes |
| ecdsa, any variant | 65 bytes (uncompressed secp256k1) | 12 bytes | 77 bytes |

Content must be a nonblank string. Content arrays are rejected.

## Additional Authenticated Data (Authenticated variant)

Generate one request nonce of 24 random bytes as hex, as the Browser App does (the gateway requires at least 16 characters), and a timestamp of the current Unix time in seconds, which the backend may reject when stale. Send both in the headers and bind them into every envelope:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
v2|req|algo=<ed25519|ecdsa>|model=<model as sent>|m=<message index>|c=<content index or ->|n=<nonce>|ts=<timestamp>
```

`m` is the zero-based position of the message in `messages`, and `c` is `-` for a string content. For `model`, bind the attestation's `provider_model_id` with any privacy suffix removed and, where it carries a backend prefix, rewritten to the model maker's prefix (for example `z-ai/glm-5.3`). That is what the Browser App binds.

## Headers

| Header | Value | Standard | Authenticated | Legacy |
| :- | :- | :- | :- | :- |
| `nito-encrypted` | `1` | Required | Required | Required |
| `nito-encrypted-client-pub-key` | Your public key as hex | Required | Required | Required |
| `nito-encrypted-model-pub-key` | `model_public_key` from the attestation | Required | Required | Required |
| `nito-encrypted-signing-algo` | `ed25519` or `ecdsa`, matching the attestation | Required | Required | Required |
| `nito-encrypted-encryption-version` | `2` | Required | Required | Omit |
| `nito-encrypted-nonce` | The request nonce | Omit | Required | Rejected |
| `nito-encrypted-timestamp` | Unix seconds | Omit | Required | Rejected |
| `nito-encrypted-encrypt-all-fields` | `1` | Optional | Rejected | Rejected |

Older spellings of these headers are accepted during migration. If the same header is sent under two names with different values, the request is rejected.

## Decrypting the Reply

`choices[].message.content` comes back as an envelope of the same shape. Split off the ephemeral public key and the nonce by the lengths above, agree the shared secret between your conversation secret key and that ephemeral public key, derive the symmetric key with the same HKDF parameters, and decrypt. A reasoning summary, where the model returns one, is an envelope too.

On the Authenticated variant the reply's additional authenticated data is:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
v2|resp|algo=<algo>|model=<response model>|id=<response id>|choice=<choice index>|field=content|n=<nonce>|ts=<timestamp>
```

with `id` taken from the response body. The backend's `model` value is not fixed, so try candidates in this order until one authenticates: the response body's `model`, the value you bound on the request, the attestation's `provider_model_id`, and the public model ID, each with any privacy suffix removed and, where it carries a backend prefix, its model-maker alias. Try the response `id` and an empty `id`. For an ecdsa reply the Browser App accepts a key derived with either the `ecdsa_encryption` info string or `secp256k1-aes-256-gcm-hkdf-sha256`, and the algorithm spellings `ecdsa`, `ecdsa-secp256k1`, or `secp256k1-aes-256-gcm-hkdf-sha256`. Mirroring that candidate search is the conservative choice for a new client.

With `stream: true` on the Standard variant, each delta's content is an envelope. Reassemble the stream and decrypt. On the Authenticated variant use the non-streaming path, as the Browser App does.

## Response Signature Evidence

On supported backends, `nito-encrypted-response-evidence: 1` with `stream: false` returns the original upstream request and response bytes plus signature proof. Your client verifies the signature and its binding to fresh attestation before consuming a reply that requires this assurance. See [Response Evidence](/api/api-reference/chat-and-text/response-evidence) for fields, verification steps, and limits.

## The Encrypted Tool Loop

With `nito-encrypted-encrypt-all-fields: 1` on the Standard variant, function tools work with every field encrypted. Encrypt, as separate envelopes, each tool's `function.name`, `function.description`, and `function.parameters` (the JSON schema, serialized to a string), any `tool_choice` function name, and, on later turns, each assistant `tool_calls[].function.name` and `.arguments` and each tool message's `content`. The reply's tool calls arrive with the `id` in plaintext and the name and arguments as envelopes. Decrypt them, run the tool on your side, encrypt the result, and send the next request. Only function tools are accepted in this mode.

## What the Gateway Rejects

All return `400 invalid_request` before any provider call.

* Any Encrypted header without `nito-encrypted: 1`.
* The headers with a model that is not an `:encrypted` entry, or an `:encrypted` model without the headers.
* A signing algorithm other than `ed25519` or `ecdsa`.
* A version other than `2`, or a version on the Legacy variant.
* A nonce or timestamp on the Legacy variant.
* A missing nonce, a nonce shorter than 16 characters, or a missing timestamp on the Authenticated variant.
* `nito-encrypted-encrypt-all-fields` off the Standard variant, or with a value other than `1`.
* Content arrays, a content string that is not nonblank even-length hex, and raw image, file, or document parts.
* `web_search` or `web_fetch` on the request, prompt caching, and `uncensored=true`.
* Encrypted headers on `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, or MCP.

A provider reply whose content, reasoning summary, or tool-call fields are not even-length hex is rejected by the gateway and never returned to you.

## Libraries

The Browser App uses the `@noble/curves`, `@noble/ciphers`, and `@noble/hashes` packages for the operations above.

## Related

<CardGroup cols={3}>
  <Card title="Encrypted on the API" icon="code" href="/api/api-reference/chat-and-text/encrypted">
    The reference page.
  </Card>

  <Card title="Get Attestation" icon="stamp" href="/api/api-reference/attestation/get-attestation">
    Where the key comes from.
  </Card>

  <Card title="How It Works" icon="arrows-left-right-to-line" href="/privacy/encrypted/how-it-works">
    The call, step by step.
  </Card>
</CardGroup>


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