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 private key │ │ │ │ │ │ │ │ 4. ProofBundle { │ │ │ │ delegations: [cert], │ │ │ │ challenge, │ │ │ │ challenge_at, │ │ │ │ challenge_sig │ │ │ │ } │ │ │ │ ──────────────────────────────────────────────▶│ │ │ │ │ │ │ │ 5. verify_bundle: │ │ │ │ - cert sig valid? │ │ │ │ - challenge_sig │ │ │ │ valid against │ │ │ │ agent pub key? │ │ │ │ - challenge_at │ │ │ │ fresh (<5m old)? │ │ │ │ │ │ │ │ ◀──── 6. ✓ Authorized OR ✗ Rejected ───────────│ │ │ │ │ │ │ ┌──────┴──────┐ ┌──────┴──────┐ │ │ └─────────────┘ └─────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────┘What gets signed
Section titled “What gets signed”challengeSignBytes = canonical_json({ agent_id: bundle.agent_id, challenge: bundle.challenge, // 32 random bytes, base64-encoded challenge_at: bundle.challenge_at, // unix seconds})
challenge_sig.ed25519 = Ed25519.Sign(challengeSignBytes, agent.private.ed25519)challenge_sig.ml_dsa_65 = ML-DSA-65.Sign(challengeSignBytes, agent.private.ml_dsa_65)The agent_id is included so an attacker can’t reuse a challenge signature with a different
identity. The challenge_at is included so a stale challenge signature can be rejected by
timestamp without needing to remember which challenges were issued.
Freshness window
Section titled “Freshness window”The verifier rejects bundles whose challenge_at is older than a configurable freshness window.
Default: 300 seconds (5 minutes).
const result = await verifyBundle(bundle, { required_scope: "meeting:attend", freshness_max_seconds: 300, // default});The window is a tradeoff:
| Window | Use case | Tradeoff |
|---|---|---|
| 30 s | High-assurance API calls, financial actions | Tight replay window; clients must reissue often |
| 300 s (default) | Meeting joins, voice gateway, normal API | Balanced — enough slack for slow networks |
| 600 s | Long-running session attestation | Wider replay window; only use if the action is observable enough that replay would be caught downstream |
You can set tighter windows per surface. Meeting-join uses the default 5 minutes; physical-AI movement commands typically use 30–60 seconds.
Clock skew
Section titled “Clock skew”Verifier and presenter clocks can disagree. The default tolerance is ±60 seconds:
challenge_at = 1800000000 now (verifier) = 1800000050 → ok (50s past, within freshness) now (verifier) = 1800000400 → REJECTED (400s past, outside 300s window) now (verifier) = 1799999940 → ok (60s in the future, within skew tolerance) now (verifier) = 1799999800 → REJECTED (200s in the future, beyond skew)Both ends use NTP-synced clocks in normal operation. The skew tolerance covers the realistic range — a clock that’s more than a minute off either way is broken and the bundle should fail.
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 → freshness check fails. → REJECTED with identity_status: replayThe freshness check is a single subtraction. It’s the cheapest possible defense, and it completely eliminates the replay class of attack.
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 check (replay within 5 minutes is acceptable for the action classes the protocol targets; for sub-second replays you need per-request idempotency keys at the application layer, not the protocol 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.
Where to next
Section titled “Where to next”- Delegate, Present, Verify — the full verifier algorithm
- Revocation — defending against compromised keys (not replays)
- Hybrid post-quantum crypto — why the challenge sig is hybrid