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

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:

WindowUse caseTradeoff
30 sHigh-assurance API calls, financial actionsTight replay window; clients must reissue often
300 s (default)Meeting joins, voice gateway, normal APIBalanced — enough slack for slow networks
600 sLong-running session attestationWider 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.

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.

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: replay

The freshness check is a single subtraction. It’s the cheapest possible defense, and it completely eliminates the replay class of attack.

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.