pub struct Receipt {
pub body: ReceiptBody,
pub signature_b64: String,
}Expand description
A complete receipt: body + detached signature over the version’s
signing message (see signing_message — bare JCS(body) for v1,
domain-tagged and length-framed JCS(body) for v2).
The wire shape is intentionally flat (13 body fields + signature_b64
at the top level) — the schemas at schemas/receipt-v1.schema.json
and schemas/receipt-v2.schema.json commit to it (they differ only
in the pinned receipt_version) and downstream verifiers
(avctl receipt-verify, external tools) consume it that way.
#[serde(flatten)] normally silently disables deny_unknown_fields
on the inner struct, which would let a hostile issuer add extra
top-level fields (“claim_extra”: “grant admin”) that the human
reviewer sees but verify() accepts (the field never survives the
round-trip to ReceiptBody used inside canonicalize). To close
that gap without breaking the wire shape, the Deserialize impl
is written by hand and rejects any top-level key outside the
declared whitelist. See the
allowed_receipt_top_level_keys_cover_body_fields_exactly
test for the compile-time-ish drift guard.
Fields§
§body: ReceiptBodySigned body.
signature_b64: StringEd25519 signature, base64. Covers the version’s signing message:
bare JCS(body) for v1, RECEIPT_DOMAIN_TAG_V2 + u64-BE
length + JCS(body) for v2.
Implementations§
Source§impl Receipt
impl Receipt
Sourcepub fn from_json_slice(bytes: &[u8]) -> Result<Self, ReceiptError>
pub fn from_json_slice(bytes: &[u8]) -> Result<Self, ReceiptError>
Deserialize a receipt from wire bytes, rejecting any duplicate key at ANY nesting level.
The custom Deserialize impl for Receipt (below) already
rejects duplicate top-level keys, but nested structs
(ai_agent, tool_calls, cost, subject) went through
serde’s default derive which relies on serde_json::Map /
IndexMap — both silently collapse duplicate keys with
last-wins semantics. A hostile issuer could then sign a
receipt whose ai_agent.instance_uid appeared twice
("a" then "b"); JCS canonicalisation saw only "b" so
the signature verified, but a first-wins auditor tool (jq’s
default, some Python configs) displayed "a" — the exact
split-brain the top-level guard was written to prevent, one
level deeper.
Pre-scans the JSON with a strict duplicate-key
checker (check_no_duplicate_keys) before deserialising into
Receipt. Callers verifying a receipt off the wire should
prefer this over serde_json::from_slice::<Receipt>.
Sourcepub fn from_json_str(s: &str) -> Result<Self, ReceiptError>
pub fn from_json_str(s: &str) -> Result<Self, ReceiptError>
Same as Receipt::from_json_slice for owned/borrowed strings.
Sourcepub fn issue(
body: ReceiptBody,
signer: &dyn Signer,
) -> Result<Self, ReceiptError>
pub fn issue( body: ReceiptBody, signer: &dyn Signer, ) -> Result<Self, ReceiptError>
Issue (sign) a receipt over body with signer.
The key_id and public_key_b64 fields of the body are overwritten
from the signer — a caller can never claim someone else’s key identity.
receipt_version is likewise overwritten to RECEIPT_VERSION:
every freshly issued receipt uses the current (domain-separated)
signing format, so no issuance path can produce a legacy v1
signature.
Sourcepub fn verify(&self, ring: &Keyring) -> Result<(), ReceiptError>
pub fn verify(&self, ring: &Keyring) -> Result<(), ReceiptError>
Verify offline against a keyring. Checks, in execution order:
AtifTrajectory.retroactive == true(retroactive: falseon an ATIF-promoted receipt is semantically nonsense; the schema pins it toconst: truebut the Rust type still acceptsfalse; refuse it here so the two agree);- the embedded public key matches the ring’s key for
key_id(anti-substitution); - the signature verifies over the version’s signing message
(bare
JCS(body)for v1; domain-tagged, length-framedJCS(body)for v2 — seesigning_message).
Sourcepub fn verify_embedded(&self) -> Result<(), ReceiptError>
pub fn verify_embedded(&self) -> Result<(), ReceiptError>
SECURITY WARNING — proves internal consistency, NOT authenticity.
Verifies the signature under the receipt’s OWN embedded
public_key_b64. An attacker who generates a fresh Ed25519 keypair
and signs an arbitrary body with it will pass this check. The
result is equivalent to “this JSON blob is syntactically a
well-formed receipt whose signature is consistent with the key it
embeds”; it says nothing about whether the KEY is one the verifier
should trust.
This method is safe ONLY when:
- The receipt was obtained over an authenticated channel that binds it to a trusted origin (e.g. a mutually-authenticated TLS connection to a known signer), and/or
- The caller separately pins
receipt.body.key_idagainst a known-good allowlist and rejects any mismatch BEFORE reading any receipt content.
For general receipt verification, use Receipt::verify with a
Keyring pre-populated with trusted keys. The browser /verify
page enforces the same discipline via TRUSTED_RECEIPT_KEYS
(docs/verify/verify.js) and shows an “internally consistent — trust
anchor NOT verified” warning without a pinned key; downstream Rust
callers must maintain the same distinction.