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 SDKs expose the same three with the same parameter order — the fixture corpus checks that all four 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 intentionally does NOT require this.
Why: 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
- Doesn’t actually improve security over the freshness window for the action classes the protocol targets — for sub-second replay defense, layer per-request idempotency keys at the application layer.
If your application does need per-challenge idempotency, layer it on top: include a request ID in the wrapping HTTP/RPC envelope and dedupe at the application layer. That’s where it belongs.
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