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

# Envelope encryption

> What a data key is, why direct encrypt is the wrong default, and how encryption context binds a ciphertext to its purpose.

## Envelope encryption

<Note>
  **Cryptographic operations are API only.** Encrypt, decrypt,
  generate-data-key, sign and verify have no console controls. The console
  creates keys, inspects them and turns them off; the operations that *use* a
  key run from your application, next to the plaintext they act on.
</Note>

`POST /v1/keys/{key_id}/encrypt` sends your plaintext to KMS and gets
ciphertext back. That is fine for something small and rare — a config value, an
API token. It is the wrong shape for anything else, because every byte crosses
the network twice and every operation costs a round trip.

The alternative is a **data key**: KMS mints a fresh random key, hands you two
copies of it, and never stores it.

```bash theme={null}
POST /v1/keys/{key_id}/generate-data-key
{ "number_of_bytes": 32 }
```

```json theme={null}
{
  "plaintext":  "<base64 — the raw key bytes>",
  "ciphertext": "<base64 — the same key, wrapped under your KMS key>"
}
```

You encrypt your data locally with `plaintext`, then throw `plaintext` away and
store `ciphertext` beside the data it protects. To read the data back, send
`ciphertext` to `POST /v1/keys/{key_id}/decrypt` and you have the data key
again.

```mermaid theme={null}
sequenceDiagram
    participant App as Your application
    participant KMS
    participant Store as Your storage
    App->>KMS: generate-data-key
    KMS-->>App: plaintext + ciphertext
    App->>App: encrypt data with plaintext, then discard it
    App->>Store: store ciphertext beside the encrypted data
    Note over App,Store: later, on read
    Store-->>App: encrypted data + ciphertext
    App->>KMS: decrypt(ciphertext)
    KMS-->>App: plaintext data key
    App->>App: decrypt data locally
```

Your bulk data never leaves your process, one KMS call covers a whole batch,
and the KMS key stays a *key-encrypting* key — the only thing it ever wraps is
other keys.

<Warning>
  Never persist the `plaintext` data key. Storing it next to `ciphertext`
  defeats the entire arrangement: anyone who reaches your storage then has
  both the lock and the key, and revoking the KMS key no longer protects
  anything.
</Warning>

`number_of_bytes` accepts **16, 32 or 64** and nothing else — 16 for AES-128,
32 for AES-256 (the default), 64 for HMAC-SHA512. Any other value fails.

### When direct encrypt runs out

<Warning>
  An **RSA key cannot encrypt more than a few hundred bytes.** RSA-OAEP can
  only carry a message smaller than the modulus: with SHA-256 that is
  `k - 2·32 - 2` bytes, so **190 bytes** for `rsa-2048` and **446 bytes** for
  `rsa-4096` ([RFC 8017 §7.1.1](https://datatracker.ietf.org/doc/html/rfc8017#section-7.1.1)).
  There is no chunking behind the API. Past that size the operation fails and a
  data key is the only route.
</Warning>

A symmetric key has no comparable algorithmic ceiling, but the request body
still has to fit in one HTTP call and you still pay a round trip per operation.
Treat direct encrypt as a convenience for small, infrequent values, and reach
for a data key for everything else.

### Encryption context

`aad` is optional additional authenticated data. It is bound into the AES-GCM
tag, so a ciphertext will only open if the same context is presented again —
useful for pinning a blob to the thing it belongs to, so a stolen ciphertext
cannot be replayed against a different record.

```json theme={null}
{ "plaintext": "<base64>", "aad": "<base64 of e.g. tenant=42>" }
```

<Warning>
  The context must match at decrypt **exactly, including its absence**.
  Supplying `aad` to decrypt a ciphertext that was sealed without one is
  refused rather than ignored — a context that is only sometimes checked is
  not a check. Encrypting without `aad` and decrypting with it fails with
  `400 INVALID_INPUT`.
</Warning>

<Warning>
  Only a symmetric key can bind a context. Sending `aad` to encrypt under an
  RSA key is refused with `400 KMS_INVALID_KEY_SPEC` — RSA-OAEP has nowhere to
  carry one, so accepting it would drop the binding while you went on treating
  it as an integrity check. The refusal happens at the seal, where you can
  still pick a different key.
</Warning>
