# Client-side encryption (no-view)

With `clientEncrypted: true`, you send ciphertext and a wrapped key; ckrets stores and returns them without being able
to read the value. This page is the format, so anything can do it. Format version 1, draft 2026-10-24.

**Release status.** The API is live in beta; the SDKs (`@ckrets/sdk` on npm, `ckrets` on PyPI) and the helper are not
published yet. When they create, they encrypt by default in this format (`fragment.v1`, or `hpke.v1` to a `publicKey`
audience with an `encJwk`), and `plaintext: true` opts out ([the guide, section 3](/for-agents.md)). Until they are, and on hosts where you would rather not install them, use the recipes
below: fragment mode needs only AES-256-GCM, which every standard library or `cryptography` package has.

## The value

1. Generate a 32-byte random data key `K` and a 12-byte random nonce `N`.
2. Encrypt the UTF-8 plaintext with AES-256-GCM under `K` and `N`, with additional data the ASCII string `ckrets:v1`.
3. Send `value` = `v1.` + base64url(`N`) + `.` + base64url(ciphertext with its 16-byte tag appended), no padding.

`digest` in the response is then the keyed digest of that `value` string (HMAC-SHA256 under the secret's `digestKey`), not of the plaintext.

## The wrapped key: three ways to get `K` to the recipient

`wrappedKey` is required with `clientEncrypted`, and says which way was used. ckrets does not parse `value` or
`wrappedKey`: both are opaque strings it stores and returns. The `value` string is held to the tier's size (1,024
bytes anonymous, 65,536 with a key; over it is `413 value-too-large`), so the plaintext must fit after encryption: at
most 737 bytes anonymously and 49,121 with a key in the format above. `wrappedKey` is held to 4,096
characters (`400 invalid-request` beyond, never cut; it does not count toward the value's size). A malformed
`wrappedKey` or `value` is stored and returned as sent (`201`): checking them is the recipient's job. Without `clientEncrypted: true` a `wrappedKey` is ignored and
not stored. `clientEncrypted` is a JSON boolean: `"true"` or `1` is `400 invalid-request`.

| `wrappedKey` | How the recipient gets `K` | Use it when |
|---|---|---|
| `hpke.v1.<enc>.<ct>` | HPKE (RFC 9180) base mode: KEM DHKEM(X25519, HKDF-SHA256), KDF HKDF-SHA256, AEAD AES-256-GCM, `info` = `ckrets wrap v1`, empty AAD, plaintext `K`; `<enc>` and `<ct>` are base64url | the audience is a `publicKey` with an `encJwk` (the recipient's X25519 public key; how to make one: `/docs/signing.md#making-the-keys`) |
| `fragment.v1` | `K` is not sent to ckrets at all; you append `#k=` + base64url(`K`) to the share URL or pass it beside the token, and HTTP clients never send a fragment to the server | the recipient gets the full URL or the `k` value through a channel you trust; works with any audience, including none |
| `pass.v1.<salt>.<m>.<t>.<p>` | `K` = Argon2id(passphrase, salt, 32 bytes) with memory `m` KiB, `t` passes, `p` lanes (default 65536, 3, 1); salt is 16 random bytes, base64url. ckrets does not check `m`, `t` or `p`, so a recipient checks them before deriving and refuses values over its own limits (for example `m` over 262144, `t` over 10, `p` over 4): a sender could otherwise name parameters that exhaust it | you share a passphrase out of band; never reuse it as the server-checked `passphrase` field: that one reaches ckrets in the reveal's body, so ckrets would see it and could derive `K`, and the value would no longer be no-view |
| `answer.v1` | riddle boxes (puzzle template): `K` is wrapped once per accepted answer; the wraps go in `puzzle.wraps`, in the same order as `puzzle.commitments`, and a reveal returns only the wrap whose commitment matched. Format below | the box should open on an answer the platform never learns |

On reveal, ckrets returns `value`, `clientEncrypted: true` and `wrappedKey` exactly as stored; the recipient
recovers `K` and decrypts. Audience binding still applies: with an audience set, only the named party gets the
ciphertext at all.

## Fragment mode without the SDK (Python)

```python
import base64, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def unb64u(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

# sender
K, N = os.urandom(32), os.urandom(12)
ct = AESGCM(K).encrypt(N, plaintext.encode(), b"ckrets:v1")          # ciphertext with the tag appended
body = {"value": f"v1.{b64u(N)}.{b64u(ct)}", "clientEncrypted": True, "wrappedKey": "fragment.v1", "ttl": "15m"}
k_part = b64u(K)                                                       # hand over with the token; never send it to ckrets

# recipient, after revealing
_, n, c = revealed["value"].split(".")
plaintext = AESGCM(unb64u(k_part)).decrypt(unb64u(n), unb64u(c), b"ckrets:v1").decode()
```

## HPKE mode without the SDK

Any RFC 9180 implementation with the suite above works, for example `@hpke/core` with `@hpke/dhkem-x25519` on npm or
`pyhpke` on PyPI. Seal `K` to the recipient's `encJwk` with `info` = `ckrets wrap v1`, then send
`wrappedKey` = `hpke.v1.` + base64url(enc) + `.` + base64url(sealed). The recipient opens it with its X25519 private key.

## Riddle boxes (`answer.v1`), exactly

As the puzzlebox builds it. Everything happens
in the browser; the API stores the salt-free record: ciphertext, commitments and wraps.

1. Make a random 16-byte `salt` and a random 16-byte link `token`; both go in the share link's fragment, base64url.
2. Make a random 32-byte data key `K` and seal the secret with it as in "The value" above.
3. For each accepted answer, reduced to its canonical string `c` (at most 12):
   - `a` = Argon2id(`c`, `salt`), 64 MiB, 3 passes, 1 lane, 32 bytes;
   - `kek` = HKDF-SHA-256(input `a` ‖ `token`, salt `salt`, info `ckrets puzzlebox v2 riddle key`), 32 bytes;
   - commitment = HKDF-SHA-256(the same input and salt, info `ckrets puzzlebox v2 riddle commitment`), 32 bytes, hex;
   - wrap = base64url(12-byte IV) + `.` + base64url(AES-256-GCM(`kek`, IV, `K`) with its 16-byte tag, no additional
     data): 16 and 64 characters.
4. Send `puzzle.commitments` and `puzzle.wraps` in the same order, with `wrappedKey: "answer.v1"`.

A solver's browser derives the commitment and `kek` the same way from its own canonical answer, reveals with the
commitment (or a ticket and the commitment), unwraps the returned `wrap` with `kek`, and opens the value with `K`.

## What no-view does and does not cover

ckrets cannot read the value, before or after reveal. It still sees and keeps the metadata in `/limits.md` ("What we
keep"): who created, sizes, times, who revealed.

In fragment mode, a person who opens the full share URL decrypts in the reveal page: the page reads `k` from the
fragment, which the browser never sends to us, and decrypts in the browser. That page is still code ckrets serves. A
recipient that trusts no code from us reveals through the API and decrypts with its own code (the recipe above). Losing the `k` value or the passphrase means the value is
unrecoverable; ckrets cannot help.
