1use 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
32pub struct PasskeyCapabilityVerifier {
41 audience: String,
42 issuer_public_key: PublicKey,
43}
44
45impl PasskeyCapabilityVerifier {
46 #[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 #[must_use]
58 pub fn audience(&self) -> &str {
59 &self.audience
60 }
61
62 #[must_use]
64 pub fn issuer_public_key(&self) -> &PublicKey {
65 &self.issuer_public_key
66 }
67
68 pub fn verify(
84 &self,
85 capability: &PasskeyCapability,
86 now: DateTime<Utc>,
87 ) -> Result<(), CustodyError> {
88 if capability.signature.is_empty() {
92 return Err(CustodyError::AssertionRejected(
93 "PasskeyCapability presented with empty signature".into(),
94 ));
95 }
96
97 capability.require_audience(&self.audience)?;
99
100 capability.require_live(now)?;
102
103 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 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}