Yanez Agent Authorization

Receipts

A receipt is the signed artifact a Yanez approval produces. It is what the relying party verifies before acting. What the human approved is on the terms page.

A receipt carries two signatures: Yanez’s, over the receipt, and the approver’s own, over the decision they made. Verifying both is the subject of user-signed approvals.

The receipt (artifact)

A compact EdDSA JWS, signed by Yanez, readable by its holder (signed, not encrypted).

Claim Required Meaning
iss yes The issuer string of your Yanez deployment, published by its operator. It is deployment configuration, not necessarily the API origin; configure your verifier with exactly this value
sub yes YID whose owner approved. Bind it: pass expected_sub, or resolve it against your own records before acting. Terms that mention an account do not by themselves prove the approver owns it
jti yes Request id; the single-use replay identifier
iat yes NumericDate; equals decision time
yanez_agent_key_id yes Public id of the agent key that asked: yak_ plus 12 hex characters, which is the prefix of the full credential. It is not the credential: the secret half is never stored in plain form and never appears in a receipt or any response. Safe to log, and pinnable with expected_agent_key_id
yanez_decision yes Always approved — only approvals produce artifacts
yanez_decided_at yes NumericDate of approval
yanez_match_overlap yes Non-negative integer; issuer evidence about the accepted biometric match. Verifiers must not assume a maximum or threshold
yanez_terms yes Verbatim approved terms
yanez_consent_not_after no Bound on when the receipt may be acted on. The agent declares it as intent_expires_at at create time, from the user’s instruction; the app does not ask the approver to confirm it separately
yanez_assurance_tier yes low, medium, or high — the tier the approver’s biometric scan reached. Gate on it against your own floor for the value at risk
yanez_user_public_key yes The approver’s verifying key: 0x + 48-byte compressed G1 point, hex
yanez_user_signature yes The approver’s signature: 0x + 96-byte compressed G2 point, hex
yanez_signed_message yes base64url of the exact bytes the approver signed. Verify against these bytes, never a re-encode
yanez_user_sig_alg yes BLS12-381-G2-basic

The five proof claims are required. A receipt minted before user-signed approvals lacks them and fails verification rather than being reported as an approval nobody signed. Those receipts record real approvals; they simply do not meet this contract.

There is deliberately no exp. The receipt is durable evidence of a past approval — a dispute or audit arrives years later, and the record must still verify. Three separate questions, three separate mechanisms:

  1. Genuine? Both signatures + issuer + claim profile. Never changes with time.
  2. Recent enough for me to act? Your policy, applied to yanez_decided_at.
  3. Still within the declared consent bound, and unspent? yanez_consent_not_after plus introspection with consume: true.

Yanez stores the parsed JSON value of the terms, not the bytes, so whitespace and key order are not preserved. That is why verification compares terms structurally, never by byte equality. yanez_signed_message is the one place byte fidelity exists, and only as the thing the user signed. The YID app shows the approver the stored terms and never resubmits them, so an agent cannot change the terms while approval is pending.

How the receipt is signed

Twice, by two different parties, and the two say different things.

Yanez Pulse signs the receipt with its own Ed25519 private key, identified by kid in the protected header. That signature is Pulse’s assertion that a fresh biometric scan matching the YID approved these terms.

The approver signs the decision with their own BLS key, derived on their device from their biometric during the approval ceremony. That signature covers the request id, the terms verbatim, the tier the scan reached, the consent bound, and the decision itself. It is the half that does not rest on trusting Yanez.

The decision arrives from the YID app on a platform-attested device (App Attest on iOS, Play Integrity on Android), signed per request by that device’s key and carrying a request-time biometric sample and the user’s own signature. Pulse matches the sample against the YID’s enrolled template, which produces yanez_match_overlap, verifies the user’s signature against the keys registered for that YID at the signed tier, and only then moves the request out of pending with a single conditional update, so two devices racing to decide cannot both win. Only when the result is approved does Pulse build the claims from the stored request, sign them, and write the receipt, all inside that same transaction. An approval is never stored without both proofs, and a rejected or expired request has no artifact.

A rejection is signed too, and stored. A decision to refuse is attributable for the same reasons a decision to permit is — it just produces no artifact.

Together the two signatures prove that Pulse recorded an approval of exactly these terms, by the YID in sub, at yanez_decided_at, in response to the agent key in yanez_agent_key_id, after a biometric match that met the server’s policy at the time — and that the holder of yanez_user_public_key signed that decision themselves. They prove nothing about whether the action is still appropriate now. Freshness, the consent bound, the assurance floor, exact-terms matching, and single use are the relying party’s job: action enforcement.

Key publication and rotation

GET /api/authz/public-keys returns a flat JWK set and needs no credentials, so any verifier can check a receipt without holding a Yanez key. Each key has this shape:

{"kty": "OKP", "crv": "Ed25519", "x": "<base64url raw public key>", "kid": "...", "alg": "EdDSA"}

The key-distribution origin is a trust anchor in its own right. Fetch the set from a base URL you configure, over HTTPS (plain HTTP only for loopback development), without following redirects, and skip any entry whose kty, crv, alg, kid, or x is missing or wrong. Checking that a receipt’s iss matches your expected string does not make keys from an untrusted origin trustworthy. The SDK verifiers do all of this.

The set holds the current signing key and every key that has signed a receipt. Because receipts never expire, a retired key stays published: operators rotate the signing key freely, and old receipts keep verifying under their original kid. Cache the set, and on an unknown kid refresh it once before rejecting, because a rotation is the normal reason a genuine receipt carries a kid you haven’t seen. The issuer string is fixed for the life of a deployment, so configure it once.

The one exception is key compromise. If a signing key is ever exposed, the operator removes its kid and asks every verifier to denylist it, because a cached or offline key set does not learn about the removal by itself. The SDKs cache the key set for up to ten minutes.