Challenges & freshness
A DelegationCert proves Alice authorized this agent. But on its own, that cert is just a
file — anyone who steals it once could replay it forever. The challenge-response is what
turns “I have a cert” into “I have a cert and I am the live holder of the agent’s private key
right now.”
The mechanism
Section titled “The mechanism” ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ Agent │ │ Verifier │ │ │ │ │ │ │ │ │ │ has: │ │ │ │ │ │ - cert │ │ │ │ │ │ - own │ │ │ │ │ │ priv │ │ │ │ │ │ keys │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ 1. "Hi, I want to do X." │ │ │ │ ──────────────────────────────────────────────▶│ │ │ │ │ │ │ │ 2. challenge = 32 random bytes │ │ │ │ challenge_at = now() │ │ │ │ ◀──────────────────────────────────────────────│ │ │ │ │ │ │ │ 3. Sign (challenge || challenge_at || …) │ │ │ │ with agent's hybrid (Ed25519 + ML-DSA-65) │ │ │ │ private key │ │ │ │ │ │ │ │ 4. ProofBundle { │ │ │ │ delegations: [...], │ │ │ │ challenge, │ │ │ │ challenge_at, │ │ │ │ challenge_sig │ │ │ │ } │ │ │ │ ──────────────────────────────────────────────▶│ │ │ │ │ │ │ │ 5. Verify: │ │ │ │ - chain sigs OK? │ │ │ │ - challenge_sig │ │ │ │ valid against │ │ │ │ agent pub key? │ │ │ │ - challenge_at │ │ │ │ within 5 minutes?│ │ │ │ │ │ │ │ ◀──── 6. ✓ Authorized OR ✗ Rejected ───────────│ │ │ │ │ │ │ └──────┴──────┘ └──────┴──────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘What gets signed (raw bytes, not JSON)
Section titled “What gets signed (raw bytes, not JSON)”The challenge signable is raw binary concatenation — not canonical JSON. The challenge
is already opaque random bytes, so JSON-wrapping would add weight without adding security
(crypto.go → challengeSignBytes):
sign_data = challenge // typically 32 random bytes || big-endian uint64(challenge_at) // 8 bytes || [optional] 32-byte session_context // §15.1 SessionContext binding || [optional] 32-byte stream_id || [optional] big-endian int64(stream_seq) // 8 bytes (only when stream_id is set)
challenge_sig.ed25519 = Ed25519.Sign(sign_data, agent.priv.ed25519)challenge_sig.ml_dsa_65 = ML-DSA-65.Sign(sign_data, agent.priv.ml_dsa_65)Three signable sizes are unambiguously distinct on the wire:
| Bindings | Signable length (for a 32-byte challenge) |
|---|---|
| Base (challenge + ts) | 40 bytes |
session_context only | 72 bytes |
stream_id + stream_seq only | 80 bytes |
| Both bindings | 112 bytes |
The verifier reconstructs the same byte sequence from bundle.Challenge, bundle.ChallengeAt,
bundle.SessionContext, bundle.StreamID, bundle.StreamSeq and runs the hybrid verify
against bundle.AgentPubKey. There is no agent_id in the signable — the binding to identity
comes from the hybrid verify against agent_pub_key, whose ID is established by the chain.
Three sign-paths in the SDKs
Section titled “Three sign-paths in the SDKs”Each reference SDK exposes three entry points so callers can pick the binding level they need without ever building the byte string themselves:
| Function | Binds to |
|---|---|
SignChallenge(challenge, ts, priv) | challenge + timestamp only (the common case) |
SignChallengeWithSessionContext(challenge, ts, sessionContext, priv) | + verifier/session (§15.1) |
SignChallengeWithStream(challenge, ts, sessionContext, streamID, streamSeq, priv) | + ordered stream (v1.1) |
The Python, TypeScript, Rust, and C/C++ SDKs expose the same challenge-signing semantics through their idiomatic APIs. The fixture corpus checks that all five produce byte-identical signables.
Freshness window
Section titled “Freshness window”The verifier rejects bundles whose challenge_at is older than ChallengeWindowSeconds (a
constant in the protocol, currently 300 — 5 minutes). It also rejects negative
ages — a challenge timestamped in the future.
// from verify.gochallengeAge := now.Unix() - bundle.ChallengeAtif challengeAge < 0 || challengeAge > ChallengeWindowSeconds { return invalid("stale_challenge", fmt.Sprintf( "challenge is %d seconds old (max %d)", challengeAge, ChallengeWindowSeconds))}On failure:
identity_status: invaliderror_reason: "stale_challenge: challenge is 312 seconds old (max 300)"
The window is a protocol-level constant, not a per-Verify option. If you need a tighter
window for a high-stakes surface, apply the check yourself before calling Verify — the spec
does not currently expose freshness_max_seconds on VerifyOptions. If you’d benefit from
configurability per surface, file an issue; that’s a v1.1 candidate.
Clock skew
Section titled “Clock skew”There is no built-in clock-skew tolerance. challengeAge < 0 means any timestamp in
the future fails. Both ends are expected to be NTP-synchronized. If you need to tolerate a
small forward skew, override VerifyOptions.Now with a slightly-back-dated clock — the
mechanism is intentionally explicit so misaligned clocks fail loudly instead of silently.
Why this defeats replay
Section titled “Why this defeats replay”Without freshness:
Day 1, 09:00: Eve records a bundle Alice's agent sent to a verifier.Day 8, 09:00: Eve replays the same bundle to the same verifier. ✗ The cert is still within its 7-day expiry — looks valid. ✗ The challenge_sig still verifies — still authored by the agent. ✗ Verifier accepts. ATTACK SUCCESSFUL.With freshness:
Day 1, 09:00: Eve records a bundle (challenge_at = day-1 09:00).Day 8, 09:00: Eve replays the same bundle. ✓ Cert still valid. ✓ challenge_sig still verifies. ✗ challenge_at is 7 days old → stale_challenge. → identity_status: invalid, error_reason: stale_challenge ...The freshness check is a single subtraction. It’s the cheapest possible defense, and it completely eliminates the long-window replay class of attack.
SessionContext: defeating cross-verifier forwarding (SPEC §15.1)
Section titled “SessionContext: defeating cross-verifier forwarding (SPEC §15.1)”The base signable (challenge + timestamp) doesn’t bind a bundle to which verifier the agent
intended to talk to. A malicious proxy that knows the agent will hit verifier X can forward
the agent’s bundle to verifier Y instead — both have the same (challenge, challenge_at)
the proxy passed through.
SessionContext closes the loop. The verifier sets a per-session 32-byte hash; the agent
signs challenge || ts || session_context; the verifier compares
bundle.SessionContext == opts.SessionContext byte-for-byte and re-verifies the signature.
A bundle signed for verifier X is now unforgeable for verifier Y.
opts := ratify.VerifyOptions{ RequiredScope: "payment:execute", SessionContext: mySessionHash, // 32 bytes}result := ratify.Verify(&bundle, opts)Length is enforced: anything other than 0 or 32 bytes is invalid_session_context.
StreamID + StreamSeq: defeating reorder/replay within a stream
Section titled “StreamID + StreamSeq: defeating reorder/replay within a stream”For v1.1 stream-bound flows (a sequence of bundles sharing one logical session), the agent
binds stream_id (32 bytes) and stream_seq (≥1, monotonic) into the same signable. The
verifier’s StreamContext.LastSeenSeq tracks the highest seq seen so far; a bundle with
stream_seq <= LastSeenSeq is rejected (proxy can’t replay or reorder within the stream).
Both bindings are optional — the base signable remains the v1.0 path and conformant verifiers handle both without flags.
What about per-challenge nonce tracking?
Section titled “What about per-challenge nonce tracking?”You could store every issued challenge and check that no challenge is presented twice. The protocol does not currently require this, because it would force every verifier to maintain durable state, which:
- Breaks offline verifiers (drones, vehicles, edge-inference appliances)
- Forces clustered verifiers to share challenge-tracking state
- Adds DB load on every Verify call
Be clear-eyed about what that trade-off means: the freshness check bounds replay to the
challenge window (300 seconds); it does not make presentations single-use. A captured bundle
can be re-presented to the same verifier within that window unless the verifier tracks the
challenges it issued and consumes each one exactly once. session_context narrows what a
captured bundle is good for, but it does not prove a challenge is still outstanding.
If your deployment needs single-use presentations — task handoffs, payments, anything where accepting the same authorization twice is itself the harm — track issued challenges with one-time consumption on the verifying side, or dedupe by request ID at the application layer for idempotency-shaped cases.
Source
Section titled “Source”crypto.go → challengeSignBytes (canonical byte layout), SignChallenge family
(SDK entry points), verify.go → freshness window and ChallengeWindowSeconds constant.
Where to next
Section titled “Where to next”- Delegate → Present → Verify — the full verifier algorithm
- Revocation — defending against compromised keys (different threat)
- Hybrid post-quantum crypto — why the challenge sig is hybrid