BuildIdentity
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
@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. It declares @chio-protocol/browser as an optional peer, and it parses the minted envelope with its own fail-closed parseCapabilityToken on every path: no module under src/ imports the peer.Prerequisites
- A WebAuthn relying party. A registered credential, a relying-party id, and an origin the authenticator will sign for. The issuer requires a user-verifying gesture, so the authenticator must support PIN or biometric verification.
- A server-side issuer.
chio-custody-hwis a library, not a binary. Your service holds the signing backend and exposes the two routes the browser helper calls. - A writable directory for the durable stores.
with_durable_storesopensrevocation.sqlite3andnonces.sqlite3as siblings under it, and fails construction if either cannot be opened. - The kernel audience URI and the issuer public key. The kernel verifier pins both. A capability minted for one audience never admits at another.
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.
sdks/typescript/packages/passkey/src/request.ts:163-292crates/trust/chio-custody-hw/src/issuer.rs:387-475crates/kernel/chio-kernel/src/custody.rs:83-115at fe56570The 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 and only inside the five-minute window between iat and exp. The replay guard is a mint-side gate rather than a presentation counter: it stops a second capability being minted from the same assertion, not a live capability being presented twice.
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.
pub struct PasskeyCapability {
/// Logical audience pin (kernel identity URI). Mismatch is fail-closed.
pub audience: String,
/// Base64url-no-pad WebAuthn credential id the assertion was bound to.
pub credential_id: String,
/// Capability scope set (canonical sorted).
pub scope_set: ScopeSet,
/// Issued-at timestamp, verifier clock.
pub iat: DateTime<Utc>,
/// Expiry timestamp, fixed at `iat + 5 minutes`.
pub exp: DateTime<Utc>,
/// Base64url-no-pad WebAuthn challenge nonce. Keyed for replay detection.
pub challenge_nonce: String,
/// Hex-encoded detached signature over the canonical-JSON encoding of
/// every other field. Empty only in the pre-signing canonical form
/// (the signed message); the issuer always populates it via its
/// signing backend before returning the capability.
pub signature: String,
}| Field | Invariant |
|---|---|
audience | Pinned to the verifier (kernel) identity. Presenting a capability minted for audience A to audience B fails closed. |
exp | Fixed at iat + 300s (CAPABILITY_LIFETIME_SECONDS, capability.rs:40). iat is the instant the caller passes into mint_capability, an injected clock rather than the system clock, so a test can pin the window. |
challenge_nonce | The WebAuthn challenge bound to this assertion. The issuer nonce store keys on (credential_id, challenge_nonce). |
signature | Detached 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> under serde(transparent), so it serializes as a sorted JSON array. Duplicates are deduplicated at construction, and covers reports whether one set contains another. The browser helper does its own subset check in TypeScript rather than calling covers: it builds a Set of the requested scopes and rejects a minted scope that is not in it.
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.
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 resolves as opts ?? challenge.userVerification ?? "required",
// so an issuer-advertised value wins over the default. The issuer requires a
// UV gesture at mint time regardless of what the ceremony asked for.
});
// { 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:
{
"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, and chio-custody-hw ships no HTTP router. You mount IssuerService behind your own /challenge and /mint routes; the request and response types are HTTP-shaped so the call site does not change when you do. Your handler verifies the raw assertion through PasskeyVerifier (a wrapper over webauthn-rs 0.5.2, pinned in the workspace manifest) to produce a VerifiedAssertion, then hands that plus a MintRequest to mint_capability. Note the split: MintRequest carries no assertion field, so the wire body the browser helper posts is your handler's shape to deserialize, not the issuer's.
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.
//! # Issuance pipeline (fail-closed, ordered)
//!
//! `mint_capability` applies its gates in this order so the cheapest /
//! most abuse-resistant checks run first and an early deny never advances
//! later state:
//!
//! 1. Audience pin match.
//! 2. User-verification bit.
//! 3. Verified credential id and request challenge nonce canonicality.
//! 4. Per-subject rate limit ([`IssuanceRateLimiter`]). A flood is denied
//! before the oracle, nonce store, or signer are touched.
//! 5. Revocation cascade ([`CredentialRevocationOracle`]). A revoked
//! credential (or one revoked transitively through its parent) is
//! denied before recording the nonce or signing.
//! 6. Replay nonce store ([`PasskeyNonceStore`]). A replayed
//! `(credential_id, challenge_nonce)` is denied before signing.
//! 7. Signature over the canonical-JSON envelope.Read as a deny table, with the error each gate returns:
| Order | Gate | Deny error |
|---|---|---|
| 1 | Audience pin match (request audience vs the constructor-pinned audience) | AudienceMismatch |
| 2 | User-verification bit set on the assertion | UserVerificationRequired |
| 3 | Credential id and challenge nonce are non-empty base64url-no-pad within MAX_NONCE_KEY_BYTES (512) | AssertionRejected |
| 4 | Per-subject rate limit (sliding window, keyed on credential id) | RateLimited |
| 5 | Revocation cascade (credential revoked directly or through a revoked parent) | CredentialRevoked |
| 6 | Replay nonce store ((credential_id, challenge_nonce) already seen) | ReplayDetected |
| 7 | Sign the canonical-JSON envelope | none (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.
// 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 minAudience Pinning
Each minted capability includes an explicit audience. The issuer does not derive 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 test suite mints for audience A and asserts that both layers refuse audience B, including under bit-flips at an arbitrary index of the signed audience field (crates/trust/chio-custody-hw/tests/audience_confusion.rs:67,104,133).
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, DEFAULT_CLOCK_SKEW_SECONDS = 30. The issuer store retains to exp + 30s; the verifier store adds the same tolerance to its own five-minute ceremony window before the store adds it again, so its effective retention is 60 seconds past the window. 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
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.
use chio_kernel::custody::PasskeyCapabilityVerifier;
let verifier = PasskeyCapabilityVerifier::new(
"urn:chio:audience:kernel",
issuer_public_key,
);
verifier.verify(&capability, now)?;pub fn verify(
&self,
capability: &PasskeyCapability,
now: DateTime<Utc>,
) -> Result<(), CustodyError> {
// Gate 1: empty signatures NEVER verify. The kernel
// refuses to admit an unsigned envelope even if the audience and clock
// would otherwise let it through.
if capability.signature.is_empty() {
return Err(CustodyError::AssertionRejected(
"PasskeyCapability presented with empty signature".into(),
));
}
// Gate 2: audience pin.
capability.require_audience(&self.audience)?;
// Gate 3: liveness.
capability.require_live(now)?;
// Gate 4: cryptographic verification.
let message = signing_message(capability)?;
let signature = Signature::from_hex(&capability.signature).map_err(|err| {
CustodyError::AssertionRejected(format!("signature hex decode failed: {err}"))
})?;
if !self.issuer_public_key.verify(&message, &signature) {
return Err(CustodyError::AssertionRejected(
"PasskeyCapability signature did not verify against issuer public key".into(),
));
}
Ok(())
}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. The verifier is stateless and holds no nonce store, so gate four is a signature check and not a single-use counter.
Verify the Result
The transcript below is a program that wires a production issuer against durable stores, mints one capability, and presents it to a PasskeyCapabilityVerifier pinned to the same audience. Three things in the output are worth reading against the sections above: the envelope keys come back in RFC 8785 order rather than declaration order, exp - iat is the fixed 300 seconds, and the signature is bare lowercase hex with no algorithm prefix.
$ cargo run -- mint ./issuer-storesminted capability, canonical JSON
{"audience":"urn:chio:audience:kernel","challenge_nonce":"Q0hBTExFTkdF","credential_id":"AAAA","exp":"2026-04-29T12:05:00Z","iat":"2026-04-29T12:00:00Z","scope_set":["tool:read"],"signature":"e41eb7a40b3d4fa213e5b296fdde709ca2f4377f83a891cae7362cc7d0b5e41f4f5d1259418a127ed78ca00461b14d72b52e6b540b1176836e30c8beb7c6d409"}
exp - iat 300 seconds
signature 128 hex characters
kernel admission: four gates passed, audience urn:chio:audience:kernelCheck three things on your own run. The signature slot is non-empty, which is the only state in which gate one passes. The audience in the envelope is the one your kernel pins, character for character. And exp is 300 seconds after iat, which tells you the envelope came from new_stub_unsigned and not from a hand-built literal.
Failures and Recovery
Every deny carries a stable urn:chio:error:custody:* code. These eight are the ones this flow raises, in gate order; the summaries and the recovery column are the registry's own words, read from spec/errors/registry.yaml. The custody domain holds 21 codes in total: the rest cover Apple App Attest, Google Play Integrity, and the TEE hardware-key path, none of which this flow touches.
| URN | Meaning | Recovery |
|---|---|---|
custody:audience-mismatch | Capability presented to a different audience than it was minted for. | Issue or request a capability whose audience pin matches the verifier identity. |
custody:user-verification-required | WebAuthn assertion verified cryptographically but did not report user verification. | Re-attempt the ceremony with a user-verifying gesture (PIN, biometric); custody issuance requires UV. |
custody:assertion-rejected | WebAuthn assertion failed structural or signature verification. | Treat the assertion as untrusted; do not retry without a fresh challenge from the issuer. |
custody:rate-limited | Capability issuance was denied because the subject exceeded its rate budget. | Retry after the rate window elapses; the mint is denied fail-closed before the revocation oracle, nonce store, or signing backend are consulted. |
custody:credential-revoked | WebAuthn credential bound to this capability has been revoked. | Re-enroll the user's authenticator and request a freshly-issued capability. |
custody:replay-detected | WebAuthn challenge nonce was previously redeemed for this credential. | Obtain a fresh challenge from the issuer; replayed assertions are denied fail-closed. |
custody:capability-expired | Passkey capability expired before the verifier evaluated the request. | Mint a fresh capability; passkey capabilities are pinned to a five-minute lifetime. |
custody:internal-encoding | Custody surface failed to canonicalize, encode, or decode an envelope. | Treat as an internal error; do not retry until the encoding bug is resolved. No fresh challenge will help. |
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.
The TypeScript union is not this list
CHIO_CUSTODY_ERROR_CODES in @chio-protocol/passkey also has eight entries, but a different eight: it omits rate-limited and adds hardware-key-unavailable. A 401 or 403 carrying rate-limited fails isCustodyErrorCode and collapses to assertion-rejected, so a browser caller cannot currently branch on a throttled mint. Branch on the HTTP status, or read the raw code off the response body, until the union is corrected.Every one of these is reachable from a test harness. The transcript below drives the same wiring as the mint above and walks four issuance gates and three admission gates, printing the URN each one returns.
$ cargo run -- deny ./deny-storesissuance gates
assertion reported no user-verifying gesture
urn urn:chio:error:custody:user-verification-required
message custody: user verification required (UV bit not set on assertion)
requested audience is not the issuer pin
urn urn:chio:error:custody:audience-mismatch
message custody: audience mismatch (expected urn:chio:audience:kernel, found urn:chio:audience:other)
revoked closure of PARENT is ["PARENT", "CHILD"]
credential revoked through its parent
urn urn:chio:error:custody:credential-revoked
message custody: credential revoked
challenge nonce already redeemed for this credential
urn urn:chio:error:custody:replay-detected
message custody: replay detected for credential AAAA
admission gates
signature slot cleared
urn urn:chio:error:custody:assertion-rejected
message custody: WebAuthn assertion rejected: PasskeyCapability presented with empty signature
presented to an audience it was not minted for
urn urn:chio:error:custody:audience-mismatch
message custody: audience mismatch (expected urn:chio:audience:other, found urn:chio:audience:kernel)
presented one second after exp
urn urn:chio:error:custody:capability-expired
message custody: capability expiredTwo lines carry more than they look. revoked closure of PARENT enumerates the root plus every transitively dependent subject, which is what an operator fans a cancellation across. And the replayed nonce is refused on the same store the first mint wrote, which is the behavior a with_signer-only issuer would not have had.
Production Wiring
The bare issuer fails open on revocation and replay
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.
use std::path::Path;
use std::sync::Arc;
use chio_core_types::crypto::SigningBackend;
use chio_custody_hw::IssuerService;
let signer: Arc<dyn SigningBackend> = Arc::new(backend);
// 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(Path::new(dir))? // revocation.sqlite3 + nonces.sqlite3
.enforce_revocation_replay()?; // Err(_) if either gate is unwiredwith_durable_stores is behind the sqlite-store feature, which is on by default. A build with --no-default-features has neither it nor the WebAuthn verifier, and can wire only the in-memory stores. If your service also needs to drive revocations, take the shared oracle handle from with_durable_stores_handle rather than opening a second oracle over the same file: a second oracle keeps its own in-RAM cache, and this issuer would not observe your revocations until it was rebuilt.
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
- Assurance Model · where server-side custody sits in the broader trust boundary
- Schemas & Errors · the canonical registry the
urn:chio:error:custody:*codes come from