Chio/Docs

BuildIdentitynew

Passkey-Issued Browser Capabilities

A browser agent exchanges a WebAuthn assertion for a short-lived, audience-pinned capability that the issuer signs server-side.

Browser helper

bash
$ npm install @chio-protocol/passkey
The @chio-protocol/passkey package calls navigator.credentials.get for this flow. That platform API does not return a private key to the page, and the package does not sign the capability.

The Custody Chain

The provenance chain a browser-issued capability walks before the kernel admits has four steps. The browser presents an authenticator assertion; a server-side issuer verifies it and mints a signed capability; the browser holds the capability bytes and nothing else; the kernel verifies the issuer signature at admission.

rendering…
A WebAuthn assertion is verified server-side, exchanged for an issuer-signed audience-pinned capability, and admitted by the kernel. The browser holds only the capability bytes.

The issuer and kernel fail closed at each stage. A missing issuer challenge, an assertion without a user-verifying gesture, a stale or revoked credential, a replayed nonce, or a signature that does not verify each denies with a typed urn:chio:error:custody:* code.


Zero Key Material in the Browser

The trust contract is that the page holds no signing material at any point. It does not generate a subkey, it does not receive a delegated key, and it does not sign an envelope. What it holds is an opaque capability that can be presented only to the audience the issuer pinned, only inside the issuer-set expiry window, and only once before the replay guard closes it.

Because the signature is made by a server-side key, the verifier path traces every admitted capability to a server-side root without trusting anything the browser produced. The authenticator assertion proves possession and user presence; the issuer signature is the thing the kernel actually checks.


The Capability Envelope

PasskeyCapability lives in chio-custody-hw. It is the capability record that the issuer mints and the browser stores. The envelope is canonical-JSON encoded (RFC 8785 / JCS) so the issuer signature stays bit-stable across implementations: object keys are sorted by UTF-16 code units, timestamps are RFC 3339 UTC with a Z suffix, and scope_set is a sorted array of unique strings. The serde field order is irrelevant to the on-the-wire bytes.

rust
pub struct PasskeyCapability {
    pub audience: String,          // kernel identity URI the capability is pinned to
    pub credential_id: String,     // base64url-no-pad WebAuthn credential id
    pub scope_set: ScopeSet,       // canonical sorted set of scopes
    pub iat: DateTime<Utc>,        // issued-at, verifier clock
    pub exp: DateTime<Utc>,        // iat + 5 minutes (fixed)
    pub challenge_nonce: String,   // base64url-no-pad WebAuthn challenge, keyed for replay
    pub signature: String,         // hex detached signature over the canonical envelope
}
FieldInvariant
audiencePinned to the verifier (kernel) identity. Presenting a capability minted for audience A to audience B fails closed.
expFixed at iat + 300s (CAPABILITY_LIFETIME_SECONDS), computed off the verifier clock, not the issuer clock. Issuer-clock drift is the reason for the asymmetry.
challenge_nonceThe WebAuthn challenge bound to this assertion. The issuer nonce store keys on (credential_id, challenge_nonce).
signatureDetached signature over the canonical-JSON encoding of every other field, with signature = "" as the signed message. Empty only in the pre-signing form; a capability with an empty signature never verifies.

ScopeSet is a BTreeSet<String> serialized as a sorted JSON array. Duplicates are deduplicated at construction and covers reports whether one set is a subset of another, which is how the browser helper checks that the minted scopes stay inside what it requested.


The Browser Helper

requestCapability runs the whole flow: one navigator.credentials.get and two fetches. The caller supplies the relying-party id, the audience the capability must be minted for, the requested scopes, and the issuer base URL.

typescript
import { requestCapability } from "@chio-protocol/passkey";

const capability = await requestCapability({
  rpId: "login.example.com",
  audience: "urn:chio:audience:kernel",
  scopes: ["tool:read"],
  issuerUrl: "https://issuer.example.com",
  // userVerification defaults to "required"; the issuer requires UV regardless
});

// { audience, credential_id, scope_set, iat, exp, challenge_nonce, signature }
// Attach these bytes to subsequent kernel requests. The browser signed nothing.

Internally the helper does three things. It POSTs {rp_id, audience, scope_set} to /challenge and receives a WebAuthn challenge bound to those parameters. It calls navigator.credentials.get so the platform authenticator signs the challenge under user verification. Then it POSTs the audience, scope set, challenge nonce, and the base64url-encoded assertion to /mint:

POST /mint
{
  "audience": "urn:chio:audience:kernel",
  "scope_set": ["tool:read"],
  "challenge_nonce": "Q0hBTExFTkdF",
  "assertion": {
    "credential_id": "Y3JlZA",
    "raw_id": "Y3JlZA",
    "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Iiwi...",
    "authenticator_data": "SZYN5YgO...",
    "signature": "MEUCIQ...",
    "user_handle": null
  }
}

On return the helper parses the capability through parseCapabilityToken (a structural validator, not a signature check, since verification is the kernel's job), rejects an audience it did not ask for, and rejects a scope set that is not a subset of the requested scopes. A 401 or 403 from the issuer collapses to a typed RequestCapabilityError; an unrecognized wire code fails closed to assertion-rejected rather than a treat-as-success path.


The Issuance Pipeline

The issuer is a library, not a standalone binary: chio-control-plane operators mount IssuerService behind their own /challenge and /mint routes. The control plane verifies the raw assertion through PasskeyVerifier (a webauthn-rs 0.5 wrapper) to produce a VerifiedAssertion, then hands that plus a MintRequest to mint_capability.

mint_capability applies its gates in a fixed order, placing inexpensive abuse checks before later state changes. The signing backend is a mandatory constructor argument, so the type cannot construct an issuer that emits unsigned capabilities.

OrderGateDeny error
1Audience pin match (request audience vs the constructor-pinned audience)AudienceMismatch
2User-verification bit set on the assertionUserVerificationRequired
3Credential id and challenge nonce are non-empty base64url-no-pad within MAX_NONCE_KEY_BYTESAssertionRejected
4Per-subject rate limit (sliding window, keyed on credential id)RateLimited
5Revocation cascade (credential revoked directly or through a revoked parent)CredentialRevoked
6Replay nonce store ((credential_id, challenge_nonce) already seen)ReplayDetected
7Sign the canonical-JSON envelope— (never returns unsigned)

The rate limiter runs before the oracle, the nonce store, and the signer, so a flood is shed at the cheapest gate. Revocation runs before the nonce store and the signer, so a revoked credential never advances the replay store nor consumes a signing budget. The replay check runs before signing, so a replayed assertion never produces a signed capability. The defaults admit 30 mints per credential per 60 seconds (DEFAULT_MAX_PER_WINDOW / DEFAULT_WINDOW_SECONDS), which leaves generous headroom for an interactive ceremony while capping a runaway client.

rust
// Control-plane mount: verify the assertion, then mint.
let verified = passkey_verifier.verify_assertion(&challenge_state, &assertion, now_unix)?;

let request = MintRequest {
    audience: "urn:chio:audience:kernel".into(),
    scope_set: ScopeSet::new(["tool:read"]),
    challenge_nonce: challenge_nonce.clone(),
};

let response = issuer.mint_capability(&verified, &request, now)?;
let capability = response.capability; // signed, audience-pinned, exp = now + 5 min

Audience Pinning

Each minted capability includes an explicit audience. The issuer does not derives it from caller input outside the signed request body; the audience pin is constructor configuration that the caller cannot rewrite. The issuer rejects a request whose audience does not match its pin, and the kernel rejects a capability presented to any audience other than its own. An audience-confusion property test generates capabilities for audience A and asserts that verification for audience B fails, including under bit-flips on the signed audience field.


Replay Resistance

There are two nonce stores, guarding two different boundaries. The verifier owns one keyed on (credential_id, challenge), where the challenge is recovered from the assertion's clientDataJSON; it defends the WebAuthn ceremony, so replaying a captured assertion fails closed even if the caller reused challenge state. The issuer owns the other keyed on (credential_id, challenge_nonce); it defends capability minting, so a replayed mint never yields a second signed capability.

Both stores record only after the cryptographic check passes, so only genuine assertions advance them. Retention is bounded to the capability lifetime plus a clock-skew tolerance: exp + 30s (DEFAULT_CLOCK_SKEW_SECONDS). The record_if_fresh path does not prune; the advisory gc_expired sweep drops entries, which keeps replay decisions decoupled from the wall clock. The production store is the SQLite-backed SqlitePasskeyNonceStore, whose single-use guarantee survives a process restart; the in-memory store is test and single-process only.


Revocation Cascade

When an operator revokes a WebAuthn credential, the issuer consults a CredentialRevocationOracle before signing and denies the next mint within the current oracle epoch. The oracle is keyed on the credential id (encoded as the sparse-Merkle SubjectId with a fixed epoch nonce). Revocation is one-way and, where dependency edges were registered, transitive: revoking a parent credential cascades to every dependent registered through register_dependency, computed synchronously and committed all-or-nothing so a partial failure never leaves a half-applied revocation.

Revocation is an issuance gate, not a kernel gate

The cascade denies the next mint. A capability already minted and still live keeps passing the kernel's four gates until it expires, because the kernel checks the issuer signature, audience, and liveness, not the credential's revocation state. The five-minute exp is what makes revocation operationally sufficient: an already-issued capability dies on its own within the window. For fan-out cancellation of in-flight work, the oracle exposes revoked_closure, which enumerates the root subject plus every transitively-dependent subject.

The durable SqliteCredentialRevocationOracle persists the leaf set and dependency edges and replays them in insertion order on open, so a revoked credential and its epoch root survive a restart rather than resetting to empty and silently re-admitting a previously-revoked credential. See Rotate Keys & Revoke for the operator runbook that drives revocation.


Kernel Admission

When a caller presents a capability, the kernel delegates to PasskeyCapabilityVerifier, configured with the kernel audience and issuer public key. Verification requires all four gates to pass; the first failure returns a typed error.

rust
use chio_kernel::custody::PasskeyCapabilityVerifier;

let verifier = PasskeyCapabilityVerifier::new(
    "urn:chio:audience:kernel",
    issuer_public_key,
);

// Ok(()) only if, in order:
//   1. capability.signature is non-empty
//   2. capability.audience == kernel audience
//   3. now < capability.exp
//   4. the detached signature verifies under issuer_public_key over the
//      canonical envelope with signature = "" (the exact bytes the issuer signed)
verifier.verify(&capability, now)?;

Gate one is the reason an unsigned envelope is inadmissible: the kernel refuses an empty signature even when the audience and clock would otherwise let it through. Gate four reconstructs the signed message by clearing the signature slot and re-canonicalizing, exactly as the issuer built it, so any RFC 8785 compliant implementation reproduces the same bytes.


Error Taxonomy

Every deny carries a stable urn:chio:error:custody:* code from the canonical error registry. The TypeScript SDK exposes the same codes as a typed CustodyErrorCode union so a caller can branch on revocation, replay, or expiry distinctly.

URNMeaning
custody:assertion-rejectedWebAuthn assertion failed a structural or signature check
custody:audience-mismatchCapability presented for a different audience than it was minted for
custody:replay-detectedThe (credential_id, challenge_nonce) pair was already redeemed
custody:capability-expiredexp is in the past relative to the verifier clock
custody:credential-revokedThe bound credential (or a parent that cascaded to it) is revoked
custody:user-verification-requiredAssertion verified cryptographically but did not report a UV gesture
custody:rate-limitedSubject exceeded its per-window issuance budget
custody:internal-encodingCanonicalization, encode, or decode failure; distinct from assertion-rejected so translation layers do not advise a ceremony retry for a server-side bug

user-verification-required is deliberately distinct from a generic assertion rejection: custody issuance requires user verification, so an authenticator that verified cryptographically but performed no PIN or biometric gesture is possession-only authentication, which the trust contract forbids. The precise code lets a deployment re-prompt without leaking whether the credential itself was valid.


Production Wiring

The bare issuer fails open on revocation and replay

A with_signer-only issuer wires the rate limiter by default but not the revocation oracle or the replay nonce store. It will mint for a revoked credential and accept a replayed nonce. That minimal shape is for tests and for deployments that have deliberately moved revocation/replay enforcement upstream. Build a production issuer with with_durable_stores.

with_durable_stores wires a SQLite-backed revocation oracle and replay nonce store, both persisted under a directory as sibling files (revocation.sqlite3 and nonces.sqlite3) that survive a restart. It fails closed if either store cannot be opened, so a deployment does not silently fall back to non-durable storage. enforce_revocation_replay turns an accidental fail-open build into a loud construction error: it returns an error unless both gates are wired.

rust
use std::sync::Arc;
use chio_custody_hw::IssuerService;

// Production issuer: durable revocation + replay stores under `dir`,
// then assert both gates are wired or fail at construction.
let issuer = IssuerService::with_signer("urn:chio:audience:kernel", signer)
    .with_durable_stores(dir)?        // revocation.sqlite3 + nonces.sqlite3
    .enforce_revocation_replay()?;    // Err(_) if either gate is unwired

The signing backend is any SigningBackend: Ed25519Backend (the classical default), the FIPS P-256/P-384 backends, or HybridBackend under the pq feature. Capabilities sign through the same call site regardless: with crypto_floor=allow_classical the envelope is byte-identical to the classical case, and the hybrid path follows the hybrid: prefix discipline without changing the verifier surface, so the audience pin survives a post-quantum migration.


Next Steps

  • Capabilities · the scoped, time-bounded authority primitive a passkey capability specializes
  • Rotate Keys & Revoke · the operator runbook that drives the revocation cascade
  • Browser Kernel · running kernel admission at the edge where these capabilities are presented
  • Trust Model · where server-side custody sits in the broader trust boundary
  • Schemas & Errors · the canonical registry the urn:chio:error:custody:* codes come from