Docs menu and this page

On this page

For agents: /docs/receipts.md is this page as Markdown.

Receipts

Every create, reveal and denial (a wrong passphrase or a wrong audience proof) returns a receipt, and so do an adoption and a policy change: a compact JWS signed by ckrets. A denial's receipt is in the problem body's receipt field (on the 401 or the 403). A revocation answers 204 with no body; status shows it. Webhook deliveries are signed the same way. A receipt lets the sender prove what was handed over, to whom and when, and you can check it without ckrets being up. The API is live in beta (0.9.0).

Format

Header: {"alg":"ES256","kid":"<key id>","typ":"ckrets-receipt+jwt"}. Payload claims:

Scroll the table sideways.

ClaimMeaning
issthe issuing environment's origin: https://ckrets.ai in production, a staging or lab host elsewhere; reject receipts with an iss you don't expect
verthe receipt format version, 1; check it before reading the other claims
iatwhen the event happened (Unix seconds); a reveal receipt is signed just before the reveal is counted, so its iat can be a second earlier than the reveal's time in status
jtiunique receipt id; we keep it for 13 months so a receipt can be checked against our record
typcreate, reveal, deny, batch, request, webhook, adopt or policy (revoke is reserved: a revoke is 204 with no body today); accept values you don't know
toksha256: + lowercase hex SHA-256 of the whole token string as ASCII, ck_ included (never the token itself). For ck_SRG73W7Q8R5T6V7W8X9Y0Z1A2B it is sha256:0a3af2067d501ccf12765aee0849380f2bf563350aacfcf438031ae1c4271321
digthe keyed digest: hmac-sha256: + base64url (no padding) HMAC-SHA256 with the HMAC key = the 32 bytes base64url-decoded from digestKey, over the UTF-8 bytes of value exactly as sent, with no normalisation; for a client-encrypted secret that is the ciphertext string. digestKey goes only to the creator at create and to the recipient with each reveal: a secret with several reveals returns the same digestKey, and its own receipt, every time
polsha256: + hex SHA-256 of {"audience":A,"passphrase":P,"reveals":R,"ttl":T} serialized with no spaces, keys in that order: A is the audience as status returns it, itself serialized to a JSON string with no spaces (or null), P whether a passphrase is set, R the reveals, T the TTL in seconds. On a policy receipt (puzzle boxes) it is the SHA-256 of the new policy as returned
audthe audience, as set at create: an object, not a JWT audience string
appon puzzle, policy and adopt receipts: the app (puzzlebox, api); absent on the agent API's own create, reveal and deny receipts, which are api
crethe creator: a key id (key_…), a CI job as its issuer and subject joined by \| (https://token.actions.githubusercontent.com\|repo:acme/api:ref:refs/heads/main), a signing key's thumbprint, or anonymous
sporeserved for the sponsoring human; not issued in v1, since a v1 lease cannot name one
rcpon reveal: who revealed (a key id, a CI job as <iss>\|<sub>, a signing key's thumbprint, or bearer); on a denial, who was refused (anonymous when no proof was sent)
purthe purpose given at create
expthe secret's expiry (Unix seconds), not the receipt's validity: a receipt stays verifiable after it
sizthe value's size in bytes
ceon every receipt: true when the value arrived encrypted on the sender's side (clientEncrypted), so ckrets stored ciphertext it holds no key for; false when ckrets sealed it, and could read it. On a request receipt it says how the person's answer will be stored: true when the request carried encJwk, so the page encrypts it in the browser; false when ckrets seals it. It is the same as the create's Ckrets-Encryption header (client or server)

Which claims each receipt carries

Every receipt has iss, ver, iat, jti, typ and ce. Then, for the agent API:

Scroll the table sideways.

typClaims
createtok, dig, pol, aud (if set), cre, pur (if set), exp, siz
revealtok, dig, aud (if set), cre, rcp, pur (if set), exp, siz
denytok, aud (if set), rcp, exp only: no dig, pol, cre, pur or siz
requesttok (here the SHA-256 of the requestId, ckq_ included), cre (the asking key), exp (when the page closes). The answer's secret then gets its own create receipt (cre the asking key, pur "request <requestId>"); GET /v1/requests/{requestId} gives its jti as receiptId

Puzzle, adopt and policy receipts add app. A receipt is readable by whoever holds it: cre, rcp, aud and pur name the parties and say why, so keep purpose free of anything you would not show the recipient or a third party.

Worked vectors

dig: with digestKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8 (the bytes 0 to 31) and value = LAB_7f3a0c9d2e, dig = hmac-sha256:rF0rV51ShGDAmgesKvcAWTcrJyiuz-huLCoCoq7NF1Y.

pol: a create with ttl 15m, reveals 1, no passphrase and audience {"type":"key","id":"key_7TQM4C8R2D5F6G7H8J9K0M1N2P"} hashes this string (keys sorted, no spaces, the audience as a JSON string):

text
{"audience":"{\"type\":\"key\",\"id\":\"key_7TQM4C8R2D5F6G7H8J9K0M1N2P\"}","passphrase":false,"reveals":1,"ttl":900}

so pol = sha256:30b0d205177533d6a8ba38728e5ca64360f92935fdf6ddf66144369952fe3a2f. With no audience, a passphrase and ttl 1h: {"audience":null,"passphrase":true,"reveals":1,"ttl":3600}, pol = sha256:9f06debd1b664a492876d0cab8640896c7f4877e6447639cd2defa4555600d57.

Verifying one

  1. Fetch <origin>/.well-known/jwks.json once and keep it, where <origin> is the receipt's expected iss (https://ckrets.ai in production; a staging or lab deployment serves its own keys). Pick the key by the receipt's kid, never by its place in the list. In v1 each deployment signs with one key and no rotation is scheduled; if a key is ever retired, it stays listed for 13 months, so an old receipt still finds its kid.
  2. Find the key whose kid matches the receipt header, verify the ES256 signature, check iss.
  3. Check tok against the token you hold. If you hold the value and its digestKey (the creator gets it at create, the recipient with each reveal), recompute dig.
ts
import { compactVerify, createLocalJWKSet } from "jose";
const jwks = createLocalJWKSet(await (await fetch("https://ckrets.ai/.well-known/jwks.json")).json());
const { payload } = await compactVerify(receipt, jwks);
const claims = JSON.parse(new TextDecoder().decode(payload));
python
import json, jwt                      # pip install "pyjwt[crypto]"
jwks = jwt.PyJWKSet.from_json(open("ckrets-jwks.json").read())
kid = jwt.get_unverified_header(receipt)["kid"]
claims = jwt.decode(receipt, key=jwks[kid].key, algorithms=["ES256"], options={"verify_aud": False, "verify_exp": False})

aud is an object and exp is the secret's expiry, so turn off a JWT library's audience and expiry checks (as the Python lines do; compactVerify checks no claims). POST /v1/receipts/verify does the same check server-side as a convenience and always answers 200: {valid: true, claims}, or {valid: false} for a tampered, malformed or missing receipt. Prefer verifying yourself.

Why the digest is keyed

An unkeyed hash of a short value (a six-digit code, a common password, "hello") is reversed by lookup, so dig is an HMAC under a per-secret digestKey that only the creator and the recipient receive. A receipt shown to anyone else says which secret and when, not what it held.

What a receipt proves, and what it does not

It proves ckrets recorded that event, at that time, for that token digest and policy. It does not prove what the recipient did with the value afterwards. A reveal receipt with an audience proves the named party collected; a reveal receipt without one proves only that someone holding the URL did.