UNPKG

noa-receipt

Version:

NOA Agent Action Receipt — open, offline-verifiable provenance for AI-agent actions. The governance/receipt organ only; the NOA brain is separate and proprietary.

374 lines (308 loc) 24 kB
# NOA Receipt — Specification v0.1 ### An open, offline-verifiable format for AI-agent action provenance > **Status:** v0.1 (2026-06-20). Apache-2.0. This is the normative spec for the OPEN > governance/receipt **organ**. The NOA agent-cognition brain is separate and proprietary. > > **What changed from the early draft (after adversarial security review):** signatures > are now **MANDATORY**, the signing-key identity is **bound into the hash** (key-swap > defense), **genesis** and **tail-truncation** are defined explicitly, and the > canonicalization rules are frozen and integer-only. An unsigned hash chain is just a > checksum — it proves nothing against a party that can write the log — so v0.1 requires > signatures from the start. --- ## 1. What a NOA Receipt is A **NOA Receipt** is a signed, hash-chained, append-only record asserting: > *Agent **A**, acting for principal **P**, attempted action **X** (params hashed to **H**), > under governance mode **M**; the verdict was **V**; reversible via **R**; and this record > is link-hashed to the previous one (**prevHash**) and signed by key **kid**.* One action lifecycle emits one or more receipts (proposed → verdict → executed/blocked/ deferred → approved/rejected → rolled_back). Receipts for one `scope.chain` form a **hash-chain**: altering any past receipt breaks every later hash. **What it proves / does not prove (be precise):** - ✅ Proves: *this exact record was produced under these rules and signed by this key, and the sequence has not been edited in the middle.* - ❌ Does NOT prove: that the real-world action actually succeeded, that it was *wise*, that the agent didn't hallucinate the intent, or (without a checkpoint, §6) that recent records weren't **deleted from the tail**. See [THREAT-MODEL.md](../THREAT-MODEL.md). --- ## 2. Receipt object Canonicalized per **RFC 8785 (JCS)** — with NOA hardening (§4) — before hashing/signing. ```json { "spec": "noa.receipt/0.1", "id": "rcpt_01J...", "ts": "2026-06-20T07:30:54.123Z", "scope": { "tenant": "store_or_org", "chain": "chain-partition-key" }, "agent": { "id": "agent-handle", "model": "vendor/model|null", "principal": "HUMAN|SERVICE|POLICY|SANDBOX_SIM" }, "action": { "id": "payment.refund", "canonical": "payment.refund", "riskClass": "LOW|MEDIUM|HIGH|CRITICAL|IRREVERSIBLE", "paramsHash": "sha256:…|hmac-sha256:…", "reversible": false, "rollbackRef": "snap_…|null" }, "governance": { "mode": "off|shadow|approvals_on|on", "verdict": "ALLOWED|BLOCKED|DEFERRED|EXECUTED|FAILED|ROLLED_BACK|SIMULATED", "ruleId": "…|null", "approval": { "by": "…", "at": "…" } , "sandboxed": false }, "chain": { "seq": 42, "prevHash": "sha256:…|null", "hash": "sha256:…" }, "sig": { "alg": "ed25519", "kid": "noa-key-2026", "value": "base64…" } } ``` ### Field rules - **Mandatory:** `spec, id, ts, scope.chain, agent.id, agent.principal, action.{id,canonical,riskClass,paramsHash,reversible}, governance.{mode,verdict,sandboxed}, chain.{seq,prevHash,hash}, sig.{alg,kid,value}`. - **Unknown fields are REJECTED** at every level (`additionalProperties:false`). This is a security control: it closes the "smuggle PII / data in an unrecognized field" channel and keeps the hashed surface exactly the documented surface. - **PII-free producer obligation** (the format cannot enforce this — see THREAT-MODEL T9): producers MUST NOT embed raw params, customer data, secrets, or free text — only `paramsHash` and enum/id fields. Unknown fields are rejected, but PII placed in a known opaque field is not. - **Integer-only:** all numbers are JSON integers in the safe range. Floats/exponents are rejected (removes number-serialization ambiguity entirely). - **`paramsHash`** is `sha256:<hex>` or, recommended for low-entropy params, `hmac-sha256:<hex>` with a tenant-scoped key (plain SHA-256 of an amount/id/bool is brute-forceable and identical across tenants → correlation; see THREAT-MODEL §params). - **`ts` and `approval.at` MUST denote a real instant**, not merely match the RFC 3339 shape. Month `01`-`12`; day within the real length of that month in that year (leap years included); hour ≤ `23`; minute ≤ `59`; second ≤ `60`; offset ≤ `23:59`. **Second `60` is ACCEPTED** — a leap second is a real instant, and refusing one would refuse a truthful receipt; which UTC days carry one is an IERS table, not a property of the string, so the range is what a verifier enforces. `2026-13-45T99:99:99.000Z` matches the pattern and is **`MALFORMED`**. ### Coherence rules (cross-field) Every rule above reads ONE field. A receipt can satisfy all of them and still be a statement that argues both ways — `agent.principal: "SANDBOX_SIM"` (the actor was the sandbox simulator) beside `governance.sandboxed: false` (this really happened), on a `CRITICAL` transfer, signed and chain-valid. A verifier MUST reject these as **`MALFORMED`**: | | Rule | |---|---| | **R1** | `agent.principal == "SANDBOX_SIM"` REQUIRES `governance.sandboxed == true` | | **R2** | `governance.verdict == "SIMULATED"` REQUIRES `governance.sandboxed == true` | | **R3** | `action.reversible == false` REQUIRES `action.rollbackRef` absent or `null` | | **R4** | `governance.verdict == "ROLLED_BACK"` REQUIRES `action.reversible == true` | Each is **one-directional**, and the converse is legitimate: a `SERVICE` agent may run inside a sandbox (`sandboxed: true` with any principal is valid), and a reversible action need not carry a `rollbackRef`. These are decidable **with no key material at all** — a reader holding only the bytes can see the contradiction — which is why they belong beside the unknown-field rule rather than in the signature path. The same placement means a conformant *producer* cannot sign one either. R1-R4 are encoded in [`schema/noa-receipt-0.1.schema.json`](../schema/noa-receipt-0.1.schema.json) `allOf`; the real-instant rule above is **not** expressible in JSON Schema and is enforced only by the normative validators. Pinned by `conformance/vectors/attack/coherence-*.json` (reject) and `conformance/vectors/coherence-*.json` + `conformance/vectors/ts-leap-second.json` (must stay valid). ### Hashing rule (frozen) ``` hash = sha256( JCS( receipt WITHOUT chain.hash AND WITHOUT sig.value ) ) ``` `sig.alg` and `sig.kid` **are inside** the hashed bytes. Therefore an attacker cannot strip the signature, swap to another key, and re-sign: changing `sig.kid` changes the hash, which breaks linkage. `chain.hash = "sha256:" + hash`. The **signature** is Ed25519 over a **domain-separated preimage**, not the bare digest: ``` sig.value = Ed25519_sign( privkey, "NOA-Receipt-v0.1-sig:" ++ sha256(JCS(receipt \ chain.hash \ sig.value)) ) ``` The domain tag (`NOA-Checkpoint-v0.1-sig:` for checkpoints) prevents cross-protocol signature reuse — a signature over a 32-byte value in another context can never be replayed as a receipt signature. Well-formed Unicode is required: **unpaired UTF-16 surrogates are rejected** (they would collapse to U+FFFD at the UTF-8 hashing step, a forgery channel). --- ## 3. Hash-chain & key-pinning For each `scope.chain`, receipts form an append-only chain: ``` R0(seq=0, prevHash=null) -> R1(prevHash=H(R0)) -> R2(prevHash=H(R1)) -> … ``` - **Genesis** is `seq == 0` with `prevHash == null`. A non-null genesis prevHash is rejected. The genesis receipt is signed; obtain its key out-of-band (the keyring is the trust root). - **Key-pinning (continuity):** the first receipt for a given `agent.id` pins its `sig.kid`. A later receipt for the same `agent.id` under a **different** `kid` is rejected — a mid-chain key swap cannot pass even if the attacker holds a valid keypair. (Pinning is per `agent.id`; one `kid` MAY be shared across multiple `agent.id`s by design. This is key *continuity* — identity authenticity comes from the out-of-band keyring, not from first-sight.) - Editing any `Ri` changes `H(Ri)` → mismatches `R(i+1).prevHash`. **Tamper-evident.** --- ## 4. Canonicalization (NOA-hardened JCS) RFC 8785 with these frozen, test-pinned rules (see `conformance/`): 1. Object keys sorted by UTF-16 code units. 2. No whitespace. 3. Strings: escape `" \ \b \f \n \r \t` and control chars `< U+0020` as `\u00XX`; **all other code points emitted literally as UTF-8** (no `\u` escaping of non-control chars, **no Unicode normalization** — inputs MUST already be NFC). 4. Numbers: **integers only**, safe range; `-0` serializes as `0`; floats/NaN/Infinity/bigint rejected. 5. On parse (verifier side): **duplicate object keys are rejected** (no silent last-wins); `__proto__`/`constructor`/`prototype` keys rejected; depth and size bounded. Conformance test vectors pin the exact bytes so a Rust producer and a TypeScript verifier cannot disagree. --- ## 5. Verification (the open verifier) Anyone — operator, auditor, receiving service, regulator — verifies a chain **offline**, no NOA service, via `noa verify` (or the library `verifyChain`): ``` verify(receipts, { keyring?, checkpoint?, identityManifest? }): 1. structural validate each receipt (strict; reject unknown fields; -> else MALFORMED enforce the §2 coherence rules R1-R4 and the real-instant `ts` rule — all decidable with no key material) 2. single chain partition; seqs contiguous 0..n-1, unique -> else TAMPERED for each receipt in seq order: 3. recompute hash = sha256(JCS(receipt \ chain.hash \ sig.value)); assert == chain.hash 4. pin sig.kid per agent.id (reject mid-chain key swap) 5. signatures, by keyring state: - keyring supplied AND kid known -> ed25519_verify over the domain-separated preimage (curve-PINNED: a non-Ed25519 key is rejected, no alg-confusion) - keyring supplied AND kid UNKNOWN -> TAMPERED (no silent TOFU on attacker input) - no keyring at all -> UNVERIFIED (cannot authenticate; never VALID) 5b. identity binding (only if identityManifest supplied): - (agent.id, sig.kid) authorized in the manifest -> ok - else -> UNTRUSTED (cross-agent impersonation: authenticated key, but NOT authorized for this agent.id). No manifest => kid-level attribution + an explicit warning. 6. linkage: seq 0 => prevHash null; else prevHash == prev.hash && seq == prev.seq+1 7. if checkpoint: assert head matches checkpoint (tail-truncation); and (if identityManifest supplied) the checkpoint sig.kid MUST be authorized for the GENESIS agent.id (the chain OPENER, seq 0) — NOT the mutable head — else UNTRUSTED (closes the re-heading attack); warn when the chain has >1 agent.id (opener-scoped completeness) -> VALID | UNVERIFIED | UNTRUSTED | TAMPERED | MALFORMED ``` CLI exit codes: `0` VALID · `1` UNVERIFIED (no keyring supplied) · `2` TAMPERED · `3` MALFORMED · `4` usage · `5` UNTRUSTED (identity binding failed). **CI rule: treat any non-zero exit as failure** (do not special-case `==2`). Honest by design: without a keyring, signatures are reported UNVERIFIED, never VALID; without a checkpoint, the verifier emits an explicit tail-truncation warning; and it always emits a fork/equivocation caveat (an offline verifier sees only the branch it was given) plus a non-monotonic-timestamp warning if `ts` goes backwards. **Public-key strictness (interop-normative).** A conformant verifier MUST decode the Ed25519 public key `A` strictly: it **MUST reject** the 8 canonical small-order point encodings (the torsion subgroup of order dividing 8) and **MUST reject** any non-canonical encoding with `y ≥ q`. A verifier whose library admits low-order keys (e.g. OpenSSL) otherwise accepts low-order keys that a strict RFC-8032 verifier rejects, splitting the verdict on identical signed bytes (a legitimate signing key is never a low-order point, so this rejects no genuine key). This is the minimal pin for cross-impl agreement on `A`**not** full ZIP-215 semantics; the signature's `R` point needs no separate blocklist because it is bound by the verification equation, which both implementations enforce. The cross-impl conformance suite pins these vectors. --- ## 6. Tail-truncation & checkpoints `prevHash` catches mid-chain edits but **not** deletion of the most-recent receipts (an attacker drops the tail; the prefix still validates). A signed **checkpoint** closes this: ```json { "spec": "noa.checkpoint/0.1", "chain": "…", "highestSeq": 42, "headHash": "sha256:…", "ts": "…", "sig": { "alg": "ed25519", "kid": "…", "value": "base64…" } } ``` A verifier given a checkpoint asserts the chain head equals `{highestSeq, headHash}` → truncation/extension is detected. **The checkpoint signature is held to the same trust root as receipts**: with a keyring supplied, a checkpoint signed by an unknown `kid` (or with a bad signature) is `TAMPERED`, and `tailChecked` is set true **only** for an authenticated checkpoint — otherwise an attacker could drop the tail and forge a checkpoint over the truncated head with their own key. **Identity-bound (when an `identityManifest` is supplied):** the checkpoint is authorized by the chain **OPENER** — its `sig.kid` MUST be authorized for the **GENESIS** receipt's `agent.id` (the `seq == 0` receipt that opened the chain), else `UNTRUSTED`. **The authority is the opener, NOT the mutable head** — this is deliberate: a `scope.chain` is a *shared* partition with no opener/ownership binding, so any co-trusted key holder can APPEND its own receipt onto a victim's prefix, become the head, drop the victim's tail, and forge a checkpoint over its OWN head (the **re-heading** attack). Binding to the head would then check the attacker's checkpoint against the attacker's OWN authorized `agent.id``VALID` while the victim's tail is erased. The opener cannot be re-written by an appended tail, so genesis-binding rejects the re-heading checkpoint as `UNTRUSTED` and strictly subsumes the legitimate opener-checkpoint case (when the opener also heads, genesis == head). **Multi-agent caveat:** the checkpoint completeness it proves is *opener-scoped*. When a chain holds more than one distinct `agent.id`, the verifier **warns** that a co-agent's tail is not separately certified by the opener's checkpoint. **Residual (needs the v1.0 external anchor):** the opener itself dropping a co-agent's tail, and the no-`identityManifest` case (kid-level only — any keyring-trusted key can forge a checkpoint over any head). Without a checkpoint the verifier **warns** that tail-truncation is undetectable offline. True tamper-*proof* (vs evident) needs an external anchor — transparency log / receiver-attestation — which is **v1.0** (§7). --- ## 7. Roadmap & non-goals - **v0.1 (now):** format + hardened JCS + mandatory Ed25519 + key-pinning + offline verifier + checkpoints + JSON-Schema + conformance suite. - **v0.2:** key-rotation policy; MCP-proxy reference emitter; HMAC params profile. - **v1.0:** external anchor (transparency log / receiver-attestation) → tamper-evident, independently witnessed; neutral-foundation governance. - **Non-goals (v0.x):** does NOT detect hallucination; does NOT undo irreversible real-world effects (it *gates before* them); does NOT replace your model or your framework. --- *Reference implementation + conformance vectors: this repository (`src/`, `conformance/`). Companion: [README](../README.md) · [THREAT-MODEL](../THREAT-MODEL.md) · [SECURITY](../SECURITY.md).* --- ## 8. The universal envelope — COSE_Sign1 / SCITT profile A NOA Receipt is also expressible as a **COSE_Sign1** (RFC 9052) so it verifies in **any** conforming COSE implementation — every language, hardware (TPM/FIDO), cloud KMS, RATS/EAT — **without NOA's code**. That is what makes it universal rather than bespoke. - **Algorithm:** Ed25519 (COSE alg `-19`, RFC 9864). The protected header alg (label `1`) MUST be `-19`; a verifier MUST reject any other `alg` to prevent algorithm confusion. We use the curve-specific `-19` rather than the generic EdDSA (`-8`, RFC 9053, deprecated Oct-2025): `-8` also admits Ed448, so pinning `-19` closes the Ed448 algorithm-confusion surface at the alg-id layer (complementing the node:crypto curve-type key pin). NOA *issues* the two-member protected header `{1: -19, 4: kid}` — CBOR leading bytes `a2 01 32 04 …`, the `…` being the kid byte string, so the header's length follows the kid's. On the *verify* side, per RFC 9052 §3.1, additional registered/non-critical protected headers (e.g. `kid`, `content type`, `CWT_Claims`) are accepted and any unknown header NOT listed in `crit` (label `2`) is ignored — so a draft-conformant peer that carries `kid`/`crit`/extra headers in the protected bucket still verifies; only the `alg` pin and `crit`-processability are enforced (a critical header the verifier cannot process is rejected, fail-closed). - **kid:** NOA *emits* `kid` (label `4`) in the **protected** (signed) header, so the signature covers the emitter's identifier and it cannot be swapped for a same-length victim kid; the unprotected bucket is emitted empty. A verifier resolves the kid against its keyring from EITHER bucket, preferring the protected (signed) copy when present. An outer kid taken from the *unprotected* bucket MAY resolve a key and MUST NOT be reported as an identity. - **TWO SIGNATURES, TWO CLAIMS (normative MUST — draft §6):** an enveloped receipt carries two identifiers and they answer different questions. The **native** `sig.kid` inside the payload attributes the **agent**: it is the key that signed the receipt into its chain and the identifier `agent.id` makes a claim about. The **outer** COSE kid attributes the party that **emitted the envelope** — an issuer submitting its own receipt, or a relay presenting someone else's. A verifier supplied an identity manifest MUST check it against the **native** `sig.kid`; MUST NOT let an authorized outer kid satisfy the agent check (an envelope signed by a key that happens to be authorized for `agent.id` says nothing about who signed the receipt inside it); MUST NOT reject a receipt solely because its outer signer is not one of the agent's keys (a relay is a legitimate presentation); and MUST report the two results separately. This profile defines no authorization list for the emitter: the manifest binds agents to keys, not envelopes to emitters. `receiptFromCose` therefore verifies **both** signatures — `ok:true` never means one of the two — and returns `nativeKid`/`agentClaim` beside `envelopeKid`/`envelopeClaim`. Vectors for both directions: `conformance/cose-attribution/vectors.json`. - **Payload:** the JCS-canonical NOA receipt bytes (§2/§4). So a standard COSE verify authenticates the receipt; a NOA-native consumer then parses the payload and runs the hash-chain / policy checks (§3–§6). - **Sig_structure:** the RFC 9052 `["Signature1", protected, external_aad(empty), payload]`, Ed25519-signed. - **CBOR:** core-deterministic (RFC 8949 §4.2) — shortest-form heads, map keys sorted by encoded bytes. **SCITT:** the same COSE_Sign1 is a **SCITT Signed Statement**. Registering it in a SCITT transparency log yields a registration **receipt** + an append-only, witness-cosigned anchor — which supplies the external non-equivocation / tail-truncation defense the self-signed hash-chain (§6) cannot give alone. **Conformance:** NOA ships its own zero-dependency COSE_Sign1 producer/verifier, and its output is verified by an **independent** implementation (an off-the-shelf CBOR library + a separate Ed25519 path) in the conformance suite — universality is proven, not asserted. --- ## 9. L2 policy-compliance (optional, on-receipt) A receipt MAY commit an `governance.compliance` block binding the decision to the exact policy + the exact recorded inputs WITHOUT carrying raw inputs (which may be PII) — only their hashes: ``` "governance": { …, "compliance": { "policyHash": "sha256:…", // JCS-canonical policy identity "readSetHash": "sha256:…", // the policy's closed input read-set "inputsHash": "sha256:…", // JCS-canonical recorded decision inputs (hash only — no raw PII) "verdict": "ALLOW|DENY" // OPTIONAL: the recorded decision (re-run at commit time) }} ``` `verifyReceiptCompliance(receipt, policy, inputs)` is the OFFLINE L2 proof: given the policy + the recorded inputs out-of-band, it confirms the three committed hashes authenticate exactly that policy + those inputs, then **re-runs the deterministic evaluator** to reproduce the verdict. When the commitment also records a `verdict`, the verifier **REQUIRES the re-run verdict to equal the recorded one** — so a receipt that commits inputs evaluating to DENY while recording ALLOW is rejected (`ok:false`). It is fail-closed (any hash mismatch / verdict mismatch / non-canonicalizable input ⇒ `ok:false`) and never throws. A substituted policy (policyHash mismatch — anti policy-swap) or substituted inputs (inputsHash mismatch) is rejected. The `verdict` field is OPTIONAL and additive: a commitment without it stays backward-compatible (no reconciliation; the verifier just returns the re-run verdict). **CARRIER AUTHENTICITY (normative MUST):** the L2 check operates on the receipt's `governance.compliance` block, which is attacker-mutable on a NON-authentic receipt — so by itself it does NOT establish that the receipt is genuine. A verifier MUST authenticate the carrier before trusting an L2 `ok:true`, by EITHER passing the keyring to `verifyReceiptCompliance(receipt, policy, inputs, { keyring })` (it then verifies the carrier's own `chain.hash` + Ed25519 signature first; a non-authentic carrier ⇒ `ok:false`) OR calling `verifyChain([...], { keyring })` and requiring `VALID` first. Reporting "compliant" off an un-authenticated carrier is a conformance violation. **ATTRIBUTION — KID-LEVEL vs AGENT-LEVEL:** `{ keyring }` carrier-auth is *kid-level* — it proves "a keyring-trusted key signed this carrier", NOT "THIS `agent.id` signed it". In a multi-key keyring a co-trusted key can sign a receipt claiming `agent.id=victim` and still pass carrier-auth, which is exactly the cross-agent impersonation `verifyChain` rejects as `UNTRUSTED` *only* when also given an identityManifest. To bind WHICH agent, pass `verifyReceiptCompliance(receipt, policy, inputs, { keyring, identityManifest })`: after carrier-auth, an unauthorized `(agent.id, sig.kid)` pairing ⇒ `ok:false` (mirroring verify.ts §5 / the `UNTRUSTED` verdict). Without an identityManifest, L2 attribution stays kid-level. **Honesty razor (normative):** this proves *"policy P, re-run over the RECORDED inputs I, yields verdict V, and V equals the decision the receipt recorded, on an authenticated carrier"* — it is substitution-resistant (a receipt cannot commit DENY-inputs while claiming ALLOW), but it is NOT proof the policy was in force at decision time, nor that I is true or complete, nor that P is a *good* rule, nor that the recorded inputs reflect external ground truth (the oracle/input-authenticity limit — a lying agent can emit a fully-valid receipt over inputs it fabricated). Carrier-auth via `{ keyring }` alone is *kid-level*: it does NOT prove THIS `agent.id` signed — pass `{ keyring, identityManifest }` to bind the signer to the agent (see "ATTRIBUTION" above). The reference policy DSL is integer-only / pure-logic; policies using non-deterministic elements are out of scope for replay.