Chio/Docs
LOGIN · JOIN

Proof of possession

Bind a capability to the key that presents it, so a copied grant cannot act, and know which store refuses a reused proof.

Concept chio@5f63e6e

On this page

A grant that requires proof of possession is useless to anyone who copies it. The caller signs each call with the private key the capability names, the signature covers the exact tool and arguments, and the receiver refuses a proof it has seen before.

A capability token alone is a bearer credential: whoever holds it can present it, which suits a private transport and fails once a token leaks. Proof of possession lets the receiver ask whether the key named in the grant authorized this request. Federated grants require it at the receiving kernel's door, so a capability that crosses organizations never acts as a bearer token.

Possession at the doorChoose what arrives.
Their operatorYour operatoraK′Kdρtheir agenttheir kernelyour kerneltoolTheir operatorYour operatoraK′Kdρagenttheir kernelyour kerneltool

κgrantσpossessiongguardsτtreatyBbudget

The proof binds the grant to this call

Their agent presents the federated grant with a DPoP proof signed by the key the grant names. Your kernel verifies the grant, compares each of the proof's bindings with the request it received, then runs your guards, the treaty check, and your budget. The proof's nonce is consumed at dispatch, and your kernel signs the receipt. Proof of possession.

One federated call at your kernel's door: the grant and DPoP proof their agent presents, the proof's five bindings, and the checks your kernel runs under its own policy, in order. Scope runs inside the grant check. Output checks after dispatch and the durable replay profile are left out.

The proof binds the capability, the subject key, the tool route, a hash of the canonical arguments, and a fresh nonce with its issue time. Freshness and replay state carry as much weight as the signature: a captured signature over a valid request must never become reusable permission for new invocations.

The grant decides whether a call needs proof

The requirement lives on the grant. ToolGrant::dpop_required is an Option<bool>: Some(true) means the kernel requires a valid DPoP proof for every invocation under that grant, and None and Some(false) both mean it does not.

Delegation keeps the requirement. ToolGrant::is_subset_of, the check a delegated scope must pass against its parent, returns false whenever the parent's dpop_required is Some(true) and the child's is not, so a chain cannot narrow its way out of proof of possession.

The requirement moves responsibility to key custody. A worker that holds the subject's private key can sign proofs, and compromise of that key defeats the possession check, so scope, current policy, and use limits stay in force: proof of possession authenticates the caller's commitment, and those controls decide whether the authenticated request may proceed.

Read more in Capabilities, which documents the grant type this field lives on, and Delegation boundaries, which covers what else a child inherits.

The subject signs the exact call the receiver will check

The complete proof exchange in the Execution application supplies a separate issuer and subject, an issuer-selected pin, a proof-required grant, the argument encoder, fresh randomness, and the receiver's nonce store. The subject signs a request to read src/discount/rules.py, and both sides hash the arguments with Chio's canonical JSON encoder.

security-execution/src/proof.rsrust
let arguments = json!({"path":"src/discount/rules.py"});
let bytes = canonical_json_bytes(&arguments)?;
let mut entropy = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut entropy);
let proof = DpopProof::sign(
    DpopProofBody {
        schema: DPOP_SCHEMA.into(),
        replay_authority: None,
        capability_id: grant.id.clone(),
        agent_key: subject.public_key(),
        tool_server: "workspace".into(),
        tool_name: "read_file".into(),
        action_hash: sha256_hex(&bytes),
        nonce: sha256_hex(&entropy),
        issued_at: now,
    },
    &subject,
)?;
let wire = serde_json::to_vec(&proof)?;

Keep the signed proof with the exact arguments sent to the receiver, so JSON member order or another encoder cannot shift the action commitment. The proof names its tool server and tool name; Chio's format is built for its own wire protocol and carries no HTTP method or URI fields.

Wire type and the small signing adapter
crates/kernel/chio-kernel/src/dpop.rs84–105rust
pub struct DpopProofBody {
    /// Schema identifier. Must equal `DPOP_SCHEMA`.
    pub schema: String,
    /// Exact durable replay domain for v2. Absent in the legacy v1 preimage.
    /// Data supplied by a proof is never the verifier's authority selection.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub replay_authority: Option<authority::DpopReplayAuthorityV1>,
    /// ID of the capability token being used for this invocation.
    pub capability_id: String,
    /// `server_id` of the tool server being called.
    pub tool_server: String,
    /// Name of the tool being invoked.
    pub tool_name: String,
    /// SHA-256 hex of the serialized tool arguments (action binding).
    pub action_hash: String,
    /// Caller-chosen random string; must be unique within the TTL window.
    pub nonce: String,
    /// Unix seconds when this proof was created.
    pub issued_at: u64,
    /// Hex-encoded Ed25519 public key of the signer (must equal capability.subject).
    pub agent_key: PublicKey,
}

Serialization omits replay_authority when it is absent, so the signed preimage is the original eight-field chio.dpop_proof.v1 format. request_proof.rs is a small signing adapter for a host that already owns canonical argument bytes, its subject key, a fresh nonce, and trusted time; the complete program above supplies those inputs and the receiving verifier.

The receiver compares the proof with the request it received

The receiver decodes the proof and checks the separately presented grant against its own issuer pin. It computes the expected action hash from the received arguments; the proof's own action_hash is a claim to compare.

security-execution/src/proof.rsrust
// Receiver: decode the wire proof, verify the separately presented grant,
// and hash the actual received arguments with the same canonical encoder.
let received: DpopProof = serde_json::from_slice(&wire)?;
verify_capability(&grant, &[expected_issuer], &FixedClock::new(now))
    .map_err(|error| anyhow::anyhow!("grant verification: {error:?}"))?;
let expected_action = sha256_hex(&canonical_json_bytes(&arguments)?);
let config = DpopConfig::default();
let nonces = DpopNonceStore::new(
    config.nonce_store_capacity,
    Duration::from_secs(config.proof_ttl_secs),
);

verify_dpop_proof(
    &received,
    &grant,
    "workspace",
    "read_file",
    &expected_action,
    &nonces,
    &config,
)?;
Signed fieldChecked againstSubstitution it prevents
capability_idThe accepted grantMoving the signature to another grant
agent_keyGrant subject and proof signaturePresenting a copied token with another key
tool_server, tool_nameThe invoked routeRedirecting a read proof to another tool
action_hashCanonical received argumentsChanging the path under the same proof
nonce, issued_atVerifier clock and replay storeReusing an accepted proof inside its window

On a tool call, the kernel checks a chio.dpop_proof.v1 proof before admission through verify_dpop_proof_stateless, stopping at the first failure: identity byte bounds, schema, sender-key match, binding fields, freshness window, and signature (Ed25519 by default). It refuses a spent nonce later, when it reserves the proof's credentials for dispatch after the scope, guard, and budget checks. The direct verify_dpop_proof helper runs the same checks and consumes the nonce in one call, so a proof consumed with the helper is spent for any kernel that shares its store.

The prepared comparison refuses a changed path, a reused proof, and a foreign key

After the example setup, run the program from the built security-execution/ directory. It creates disposable keys inside the process, prints public proof material and the verifier's results, and invokes no tool.

Terminalbash
proof
Output
Original request         Accepted
Changed arguments        Rejected
Repeated proof           Rejected
Different signing key    Rejected
Fresh proof              Accepted

Tool invocations: 0

You should see two accepted proofs, three rejected ones, and zero tool invocations. The changed path fails its binding before the valid nonce is consumed, so the original request still verifies afterward; presenting that accepted proof again fails the nonce check. A different signer cannot become the capability's subject by editing agent_key and re-signing, and a fresh nonce signed by the subject passes the proof check, after which the operation still needs current admission before any effect.

The signed proof and the exact refusal messages

JSON mode includes the original arguments, the signed proof, the subject, and the full verification errors. The second block holds the outcome fields from the recorded comparison.

Terminalbash
proof --json > proof.json
jq '{canonical_arguments, grant_subject, proof}' proof.json
proof.json · outcome fieldsjson
{
  "first_proof": "accepted",
  "changed_arguments": "DPoP proof verification failed: binding fields do not match: capability_id, tool_server, tool_name, or action_hash mismatch",
  "repeated_proof": "DPoP proof verification failed: nonce replayed: this nonce has already been used during the proof validity window",
  "different_key": "DPoP proof verification failed: agent_key does not match capability subject (sender constraint violated)",
  "fresh_proof": "accepted",
  "tool_invocations": 0
}

Replay refusal lives in the store the verifier selects

The example's DpopNonceStore is an in-process LRU cache keyed by (nonce, capability_id) that refuses a nonce already recorded for that capability inside its validity window. The default policy accepts a proof for 300 seconds after issued_at and tolerates 30 seconds of future clock skew, and a host may configure both. Reconstructing the store loses its markers, and separate hosts with separate stores share no replay knowledge.

A long-lived service therefore selects where its replay authority resides, and the durable profile carries refusal across restarts. Schema chio.dpop_proof.v2 adds a signed replay_authority field, described by DpopReplayAuthorityV1: the destination store, the independently configured authority name, an expectation identifier hashed from the destination authority, the DPoP authority name, the source instance, and the source inventory together, and the proof's own TTL and clock-skew bounds. The verifier selects its expected domain from its own authenticated configuration and the durable store, never from the proof under evaluation, and the complete domain in the proof must match that selection exactly.

Durable replay authority wire fields
crates/kernel/chio-kernel/src/dpop/authority.rs22–28rust
pub struct DpopReplayAuthorityV1 {
    destination_store_uuid: AdmissionIdentifier,
    dpop_authority_id: AdmissionIdentifier,
    expectation_id: AdmissionDigest,
    proof_ttl_secs: u64,
    max_clock_skew_secs: u64,
}

A host that embeds the kernel selects the domain with set_operation_owned_dpop_authority, which verifies that the domain is already activated and rejects any later attempt to replace it on that kernel. claim_prepared_dpop then binds one invocation: it matches the retained request and the selected grant against the prepared credential and records a durable claim under the operation's lease before budget authorization runs, so dispatch requires that reserved claim and a still-valid credential. The SQLite-backed store holding this history refuses a replayed v2 proof after a restart, and history imported from a prior source stays inactive until an explicit, domain-separated activation commits it. Retained operation identity is a separate mechanism: Replay protection distinguishes refusing a consumed proof from returning an original completed result.

Every proof refusal reaches a tool call as one prefixed deny reason

All three refusals share one source, KernelError::DpopVerificationFailed(String). A tool call receives the message as a ToolCallResponse deny reason prefixed DPoP proof verification failed: .

Deny reason after the prefixConditionChecked by
agent_key does not match capability subject (sender constraint violated)
The proof was signed by a key other than the capability's subject
Sender-key match
nonce replayed: this nonce has already been used during the proof validity window
The store already holds this nonce for this capability inside its window
In-process nonce store
durable DPoP profile: proof does not match the independently pinned durable domain
The proof's schema or signed domain differs from the host-selected domain
Durable replay domain

By default, the nonce store is what refuses a replay; Replay protection shows how other single-use grants and completed operations refuse the same kind of retry. When a refusal arrives, check the capability subject, the host-selected durable domain, and the proof you signed. A fresh proof belongs to a new request and clears nothing for an operation that already captured an effect; for an uncertain original call, keep its operation binding and follow recovery.

Next: refuse retries of completed work