Skip to content

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.”

┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 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 ───────────│ │
│ │ │ │
│ └──────┴──────┘ └──────┴──────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

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.gochallengeSignBytes):

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:

BindingsSignable length (for a 32-byte challenge)
Base (challenge + ts)40 bytes
session_context only72 bytes
stream_id + stream_seq only80 bytes
Both bindings112 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.

Each reference SDK exposes three entry points so callers can pick the binding level they need without ever building the byte string themselves:

FunctionBinds 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.

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.go
challengeAge := now.Unix() - bundle.ChallengeAt
if challengeAge < 0 || challengeAge > ChallengeWindowSeconds {
return invalid("stale_challenge", fmt.Sprintf(
"challenge is %d seconds old (max %d)", challengeAge, ChallengeWindowSeconds))
}

On failure:

  • identity_status: invalid
  • error_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.

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.

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.

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.

crypto.gochallengeSignBytes (canonical byte layout), SignChallenge family (SDK entry points), verify.go → freshness window and ChallengeWindowSeconds constant.