# 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:

| Claim | Meaning |
|---|---|
| `iss` | the issuing environment's origin: `https://ckrets.ai` in production, a staging or lab host elsewhere; reject receipts with an `iss` you don't expect |
| `ver` | the receipt format version, `1`; check it before reading the other claims |
| `iat` | when 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 |
| `jti` | unique receipt id; we keep it for 13 months so a receipt can be checked against our record |
| `typ` | `create`, `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 |
| `tok` | `sha256:` + lowercase hex SHA-256 of the whole token string as ASCII, `ck_` included (never the token itself). For `ck_SRG73W7Q8R5T6V7W8X9Y0Z1A2B` it is `sha256:0a3af2067d501ccf12765aee0849380f2bf563350aacfcf438031ae1c4271321` |
| `dig` | the 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 |
| `pol` | `sha256:` + 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 |
| `aud` | the audience, as set at create: an object, not a JWT audience string |
| `app` | on puzzle, `policy` and `adopt` receipts: the app (`puzzlebox`, `api`); absent on the agent API's own create, reveal and deny receipts, which are `api` |
| `cre` | the 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` |
| `spo` | reserved for the sponsoring human; not issued in v1, since a v1 lease cannot name one |
| `rcp` | on 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) |
| `pur` | the `purpose` given at create |
| `exp` | the secret's expiry (Unix seconds), not the receipt's validity: a receipt stays verifiable after it |
| `siz` | the value's size in bytes |
| `ce` | on 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:

| `typ` | Claims |
|---|---|
| `create` | `tok`, `dig`, `pol`, `aud` (if set), `cre`, `pur` (if set), `exp`, `siz` |
| `reveal` | `tok`, `dig`, `aud` (if set), `cre`, `rcp`, `pur` (if set), `exp`, `siz` |
| `deny` | `tok`, `aud` (if set), `rcp`, `exp` only: no `dig`, `pol`, `cre`, `pur` or `siz` |
| `request` | `tok` (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.
