Skip to main content

chio_kernel/
custody.rs

1//! Kernel-side passkey-capability verification.
2//!
3//! The kernel sits behind the issuer surface ([`chio_custody_hw::IssuerService`]).
4//! When a caller presents a [`PasskeyCapability`] to the kernel, the kernel
5//! delegates to [`PasskeyCapabilityVerifier`] which:
6//!
7//! 1. Refuses any capability with an empty signature (custody contract:
8//!    empty signatures NEVER verify).
9//! 2. Checks the audience pin matches the kernel's configured identity.
10//! 3. Checks the capability is live (`now < exp`).
11//! 4. Reconstructs the signing message via
12//!    [`chio_custody_hw::mint::signing_message`] and verifies the
13//!    detached signature against the configured issuer
14//!    [`chio_core_types::crypto::PublicKey`].
15//!
16//! All four gates are fail-closed: any path that does not strictly
17//! satisfy its precondition returns a typed [`CustodyError`] with a
18//! stable `urn:chio:error:custody:*` code.
19//!
20//! # Async discipline
21//!
22//! The verifier surface takes no `&mut self`.
23//! `PasskeyCapabilityVerifier::verify` takes `&self` so it can be shared
24//! behind an `Arc<>` and consumed concurrently from the async kernel.
25
26use chio_core_types::crypto::{PublicKey, Signature};
27use chio_custody_hw::capability::PasskeyCapability;
28use chio_custody_hw::error::CustodyError;
29use chio_custody_hw::mint::signing_message;
30use chrono::{DateTime, Utc};
31
32/// Kernel-side verifier for [`PasskeyCapability`] envelopes.
33///
34/// The verifier is configured with the audience the kernel pins itself
35/// to, and the issuer's public key (the public half of the
36/// [`chio_core_types::crypto::SigningBackend`] the issuer used to sign
37/// the capability). The verifier is `Send + Sync` and stateless;
38/// production deployments hold one in an `Arc<>` and re-use it across
39/// every kernel call.
40pub struct PasskeyCapabilityVerifier {
41    audience: String,
42    issuer_public_key: PublicKey,
43}
44
45impl PasskeyCapabilityVerifier {
46    /// Build a verifier pinned to a kernel audience and the issuer
47    /// public key.
48    #[must_use]
49    pub fn new(audience: impl Into<String>, issuer_public_key: PublicKey) -> Self {
50        Self {
51            audience: audience.into(),
52            issuer_public_key,
53        }
54    }
55
56    /// Configured kernel audience pin.
57    #[must_use]
58    pub fn audience(&self) -> &str {
59        &self.audience
60    }
61
62    /// Configured issuer public key.
63    #[must_use]
64    pub fn issuer_public_key(&self) -> &PublicKey {
65        &self.issuer_public_key
66    }
67
68    /// Verify a [`PasskeyCapability`] presented to this kernel.
69    ///
70    /// Returns `Ok(())` only when ALL four gates pass:
71    ///
72    /// 1. `capability.signature` is non-empty.
73    /// 2. `capability.audience == self.audience`.
74    /// 3. `now < capability.exp`.
75    /// 4. The detached hex-encoded `capability.signature` verifies
76    ///    under [`Self::issuer_public_key`] over the canonical-JSON
77    ///    bytes of the envelope WITH `signature = ""` (the same
78    ///    message the issuer signed).
79    ///
80    /// Fail-closed: any failing gate returns a typed [`CustodyError`].
81    /// The first failing gate short-circuits; deployments that need
82    /// distinct telemetry per gate read the [`CustodyError::urn`].
83    pub fn verify(
84        &self,
85        capability: &PasskeyCapability,
86        now: DateTime<Utc>,
87    ) -> Result<(), CustodyError> {
88        // Gate 1: empty signatures NEVER verify. The kernel
89        // refuses to admit an unsigned envelope even if the audience and clock
90        // would otherwise let it through.
91        if capability.signature.is_empty() {
92            return Err(CustodyError::AssertionRejected(
93                "PasskeyCapability presented with empty signature".into(),
94            ));
95        }
96
97        // Gate 2: audience pin.
98        capability.require_audience(&self.audience)?;
99
100        // Gate 3: liveness.
101        capability.require_live(now)?;
102
103        // Gate 4: cryptographic verification.
104        let message = signing_message(capability)?;
105        let signature = Signature::from_hex(&capability.signature).map_err(|err| {
106            CustodyError::AssertionRejected(format!("signature hex decode failed: {err}"))
107        })?;
108        if !self.issuer_public_key.verify(&message, &signature) {
109            return Err(CustodyError::AssertionRejected(
110                "PasskeyCapability signature did not verify against issuer public key".into(),
111            ));
112        }
113
114        Ok(())
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::sync::Arc;
122
123    use chio_core_types::crypto::{Ed25519Backend, Keypair, SigningBackend};
124    use chio_custody_hw::capability::ScopeSet;
125    use chio_custody_hw::issuer::{IssuerService, MintRequest};
126    use chio_custody_hw::verifier::VerifiedAssertion;
127    use chrono::TimeZone;
128
129    const AUDIENCE: &str = "urn:chio:audience:kernel";
130
131    fn fixed_now() -> chrono::DateTime<Utc> {
132        match Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0) {
133            chrono::LocalResult::Single(t) => t,
134            _ => panic!("fixed_now fixture must construct"),
135        }
136    }
137
138    fn signed_cap_for_audience(audience: &str) -> (PasskeyCapability, PublicKey) {
139        let backend = Ed25519Backend::new(Keypair::from_seed(&[41u8; 32]));
140        let public = backend.public_key();
141        let backend_arc: Arc<dyn SigningBackend> = Arc::new(backend);
142        let svc = IssuerService::with_signer(audience, backend_arc);
143        let req = MintRequest {
144            audience: audience.into(),
145            scope_set: ScopeSet::new(["tool:read"]),
146            challenge_nonce: "n".into(),
147        };
148        let assertion = VerifiedAssertion {
149            credential_id_b64: "AAAA".into(),
150            user_verified: true,
151        };
152        let resp = match svc.mint_capability(&assertion, &req, fixed_now()) {
153            Ok(r) => r,
154            Err(e) => panic!("mint must succeed: {e}"),
155        };
156        (resp.capability, public)
157    }
158
159    #[test]
160    fn verifies_signed_capability_for_pinned_audience() {
161        let (cap, public) = signed_cap_for_audience(AUDIENCE);
162        let verifier = PasskeyCapabilityVerifier::new(AUDIENCE, public);
163        if let Err(e) = verifier.verify(&cap, fixed_now()) {
164            panic!("matching capability must verify: {e}");
165        }
166    }
167
168    #[test]
169    fn rejects_audience_mismatch() {
170        let (cap, public) = signed_cap_for_audience("urn:chio:audience:other");
171        let verifier = PasskeyCapabilityVerifier::new(AUDIENCE, public);
172        let res = verifier.verify(&cap, fixed_now());
173        assert!(matches!(res, Err(CustodyError::AudienceMismatch { .. })));
174    }
175
176    #[test]
177    fn rejects_empty_signature() {
178        let (mut cap, public) = signed_cap_for_audience(AUDIENCE);
179        cap.signature.clear();
180        let verifier = PasskeyCapabilityVerifier::new(AUDIENCE, public);
181        let res = verifier.verify(&cap, fixed_now());
182        assert!(matches!(res, Err(CustodyError::AssertionRejected(_))));
183    }
184
185    #[test]
186    fn rejects_expired_capability() {
187        let (cap, public) = signed_cap_for_audience(AUDIENCE);
188        let verifier = PasskeyCapabilityVerifier::new(AUDIENCE, public);
189        let later = cap.exp + chrono::Duration::seconds(1);
190        let res = verifier.verify(&cap, later);
191        assert!(matches!(res, Err(CustodyError::CapabilityExpired)));
192    }
193
194    #[test]
195    fn rejects_signature_from_wrong_issuer_key() {
196        let (cap, _real_public) = signed_cap_for_audience(AUDIENCE);
197        // Same audience, different issuer key. The kernel pins the
198        // public key, so a foreign issuer never produces a verifying
199        // capability.
200        let other_backend = Ed25519Backend::new(Keypair::from_seed(&[99u8; 32]));
201        let other_public = other_backend.public_key();
202        let verifier = PasskeyCapabilityVerifier::new(AUDIENCE, other_public);
203        let res = verifier.verify(&cap, fixed_now());
204        assert!(matches!(res, Err(CustodyError::AssertionRejected(_))));
205    }
206}