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). 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
- Generate a 32-byte random data key
Kand a 12-byte random nonceN. - Encrypt the UTF-8 plaintext with AES-256-GCM under
KandN, with additional data the ASCII stringckrets:v1. - 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.
Scroll the table sideways.
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)
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.
- Make a random 16-byte
saltand a random 16-byte linktoken; both go in the share link's fragment, base64url. - Make a random 32-byte data key
Kand seal the secret with it as in "The value" above. - 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(inputa‖token, saltsalt, infockrets 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.
- Send
puzzle.commitmentsandpuzzle.wrapsin the same order, withwrappedKey: "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.