Skip to content

Delegate, Present, Verify

The Ratify Protocol is three verbs. That’s it. Every adapter, every SDK, every integration — they’re all built out of some sequence of these three.

DELEGATE PRESENT VERIFY
──────── ─────── ──────
Principal signs a Presenter (agent) Any third party
DelegationCert carries the cert runs the verifier.
naming the subject, and signs a fresh Both Ed25519 AND
the scopes, and the challenge on every ML-DSA-65 must
expiration. interaction. verify. Yes/no
in <1ms. No trust
Human → Agent OR Proves "this key is relationship with
Agent → Agent. live right now." presenter required.

The symmetry matters. A human delegating to an AI agent and one AI agent sub-delegating to another use the exact same primitive, the same verifier algorithm, and the same cryptographic guarantees.

The principal — a human or another agent — signs a DelegationCert. The cert binds the principal’s identity to the subject’s identity and specifies what the subject can do.

A DelegationCert is the following JSON shape, serialized with canonical JSON (keys sorted, no insignificant whitespace, deterministic number formatting):

{
"cert_id": "0a3b...c9d2",
"version": 1,
"issuer_id": "92cb0a15572d7a71",
"issuer_pub_key": {
"ed25519": "base64-encoded-32-bytes",
"ml_dsa_65": "base64-encoded-1952-bytes"
},
"subject_id": "b4a4c71795d676b6",
"subject_pub_key": {
"ed25519": "...",
"ml_dsa_65": "..."
},
"scope": ["meeting:attend", "meeting:speak"],
"constraints": [],
"issued_at": 1799996400,
"expires_at": 1800082800,
"signature": {
"ed25519": "base64-encoded-64-bytes",
"ml_dsa_65": "base64-encoded-3309-bytes"
}
}
delegationSignBytes = canonical_json(cert with signature field omitted)
signature.ed25519 = Ed25519.Sign(delegationSignBytes, issuer.private.ed25519)
signature.ml_dsa_65 = ML-DSA-65.Sign(delegationSignBytes, issuer.private.ml_dsa_65)

The canonical JSON is RFC 8785, with byte fields base64-standard-encoded. Both signatures are computed over the same canonical bytes and both must verify at the receiving end. The delegationSignBytes algorithm is defined precisely in SPEC.md §7.1 and the conformance fixtures pin it byte-identically across all four SDKs.

Ed25519 alone Hybrid (Ed25519 + ML-DSA-65)
───────────── ────────────────────────────
Today ✓ Forgery-resistant ✓ Forgery-resistant
After Q-day ✗ BROKEN — quantum-capable ✓ Still resistant — ML-DSA-65
(large-scale adversary can forge any is lattice-based, not
quantum Ed25519 signature ever number-theoretic. Quantum
computer) produced, retroactively offers no known speedup.
("harvest now, decrypt
later")
ML-DSA-65 alone is sufficient for Q-day defense. So why use Ed25519 too?
Defense in depth. If a flaw is found in ML-DSA-65 (newer algorithm, less
battle-tested), Ed25519 still holds the line.

The hybrid posture is what FIPS 204, CNSA 2.0, and BSI guidance all recommend for the post-quantum transition.

When the agent wants to do something, it builds a ProofBundle: the chain of delegations it holds plus a fresh signature over a verifier-supplied challenge.

{
"agent_id": "b4a4c71795d676b6",
"agent_pub_key": { "ed25519": "...", "ml_dsa_65": "..." },
"delegations": [ /* [leaf, ..., root] — leaf-first per SPEC §6.5 */ ],
"challenge": "Zx8t4vQrM2...",
"challenge_at": 1800000000,
"challenge_sig": { "ed25519": "...", "ml_dsa_65": "..." },
"session_context": "", // optional 32 bytes; SPEC §15.1
"stream_id": "", // optional 32 bytes; v1.1
"stream_seq": 0 // optional; ≥1 when stream_id is set
}

The challenge signable is raw concatenated bytes, not canonical JSON:

sign_data =
challenge // typically 32 random bytes
|| big-endian uint64(challenge_at) // 8 bytes
|| [optional] 32-byte session_context
|| [optional] 32-byte stream_id || big-endian int64(stream_seq)
challenge_sig.ed25519 = Ed25519.Sign(sign_data, agent.private.ed25519)
challenge_sig.ml_dsa_65 = ML-DSA-65.Sign(sign_data, agent.private.ml_dsa_65)

The agent’s identity isn’t in the signable — it’s established by the chain, and the verifier runs hybrid verify against bundle.agent_pub_key. The full byte-layout, sizes, and the three SDK entry points (SignChallenge, SignChallengeWithSessionContext, SignChallengeWithStream) live on the Challenges page.

Without freshness, an attacker who steals a bundle once could replay it forever. The challenge mechanism defeats this:

1. Verifier generates 32 random bytes (challenge).
2. Verifier records the timestamp (challenge_at).
3. Verifier sends both to the presenter.
4. Presenter signs (challenge, challenge_at) with the agent's hybrid private key.
5. Verifier rejects if challenge_at is older than ~5 minutes (configurable per
surface — meetings might tolerate 10 minutes; high-assurance API calls,
30 seconds).

The challenge is not stored long-term. The verifier doesn’t need to remember which challenges it issued — it only needs to check that the embedded challenge_at timestamp is recent.

Any third party with the principal’s public key can verify the entire bundle. Five deterministic checks. Sub-millisecond. Yes or no.

┌───────────────────────────────────-───────┐
bundle ─────────▶│ Verify(bundle, options) │
│ │
│ 1. Structural checks │
│ depth ∈ [1, MaxDelegationChainDepth] │
│ session_context/stream_id are 32B │
│ │
│ 2. Chain check │
│ For each link i in chain: │
│ cert[i].issuer_id == │
│ cert[i+1].subject_id │
│ Last cert's subject_id == │
│ bundle.agent_id │
│ │
│ 3. Per-cert checks │
│ For each delegation: │
│ - both hybrid sigs verify │
│ - issued_at ≤ now < expires_at │
│ - revocation provider says false │
│ - all constraints pass │
│ - intermediates carry │
│ identity:delegate │
│ │
│ 4. Liveness │
│ 0 ≤ now − challenge_at ≤ 300 s │
│ hybrid verify of challenge_sig over │
│ challenge || ts || session_ctx || │
│ stream_id || stream_seq │
│ │
│ 5. Scope │
│ effective = ⋂ link.scope (lex sort) │
│ required_scope ∈ effective │
│ │
│ 6. Policy (if provided) │
│ PolicyVerdict or PolicyProvider OK │
└────────────────┬───────────────────────-──┘
┌──────────┴───────────┐
▼ ▼
┌───────────────┐ ┌──────────────────────────┐
│ Valid=true │ │ Valid=false, with one of:│
│ identity_ │ │ │
│ status: │ │ expired │
│ authorized_ │ │ revoked │
│ agent │ │ scope_denied │
│ granted_scope │ │ constraint_denied │
│ human_id │ │ constraint_unverifiable │
│ agent_id │ │ constraint_unknown │
│ │ │ delegation_not_ │
│ │ │ authorized │
│ │ │ invalid │
└───────────────┘ └──────────────────────────┘

Fail-closed. Any check failing → Valid=false plus a specific identity_status → caller rejects the request. There is no “valid with warnings” path, no “partially valid” state. invalid is the catch-all for structural / cryptographic failures and always carries a machine-parsable error_reason prefix (e.g. stale_challenge: ..., bad_challenge_sig: ..., revocation_error: ...).

Agent-to-agent sub-delegation uses the exact same primitive — the chain just gets longer.

Alice (human)
│ signs DelegationCert {
│ issuer: Alice
│ subject: Agent-A
│ scope: [meeting:*, identity:delegate] ← identity:delegate
│ } is REQUIRED for A
│ to sub-delegate.
Agent-A
│ signs DelegationCert {
│ issuer: Agent-A
│ subject: Agent-B
│ scope: [meeting:attend, meeting:record]
│ (identity:delegate intentionally NOT passed on)
│ }
Agent-B
│ builds ProofBundle {
│ agent_id: Agent-B
│ delegations: [A→B, Alice→A] ← leaf-first per §6.5
│ challenge_sig: signed by Agent-B's private key
│ }
Verifier checks:
├ Both delegations' hybrid sigs valid (Ed25519 + ML-DSA-65)
├ A→B.issuer_id == Alice→A.subject_id (chain well-formed)
├ Alice→A scope contains identity:delegate
│ (else identity_status: delegation_not_authorized)
├ All certs within issued_at/expires_at
├ effective_scope = scope[Alice→A] ∩ scope[A→B], lex-sorted
│ = scope[meeting:*-expansion + identity:delegate] ∩
│ {meeting:attend, meeting:record}
│ = {meeting:attend, meeting:record}
└ required_scope ∈ effective_scope → authorized_agent

The verifier runs the same algorithm for chain depth 1 and chain depth MaxDelegationChainDepth — same code path, just loops more times. Runtime grows linearly with chain depth.

Effective scope is the lex-sorted intersection across the chain. An agent cannot grant more rights than it was given. The structural invariant that makes sub-delegation safe.

The verifier algorithm is normative — defined in SPEC.md §8. Every SDK implements the same algorithm; the 59 conformance fixtures verify byte-identical results.