1use rand_core::RngCore;
4use zeroize::Zeroize;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub enum SignerTier {
9 HardwareHsm,
11 DeviceHsm,
13 CredentialManager,
15 EncryptedFile,
17}
18
19#[derive(Debug, thiserror::Error)]
21pub enum SignerError {
22 #[error("signer not available: {0}")]
23 Unavailable(String),
24
25 #[error("authentication required: {0}")]
26 AuthRequired(String),
27
28 #[error("key not found: {0}")]
29 KeyNotFound(String),
30
31 #[error("signing failed: {0}")]
32 SigningFailed(String),
33
34 #[error("decryption failed: {0}")]
35 DecryptionFailed(String),
36
37 #[error("IO error: {0}")]
38 Io(#[from] std::io::Error),
39}
40
41#[async_trait::async_trait]
50pub trait IdentitySigner: Send + Sync {
51 fn tier(&self) -> SignerTier;
53
54 fn label(&self) -> &str;
56
57 fn is_available(&self) -> bool;
59
60 async fn root_secret(&self) -> Result<RootSecret, SignerError>;
67
68 async fn sign(&self, data: &[u8]) -> Result<Vec<u8>, SignerError>;
74}
75
76#[derive(Zeroize)]
78#[zeroize(drop)]
79pub struct RootSecret {
80 bytes: [u8; 32],
81}
82
83impl RootSecret {
84 pub fn new(bytes: [u8; 32]) -> Self {
86 Self { bytes }
87 }
88
89 pub fn as_bytes(&self) -> &[u8; 32] {
91 &self.bytes
92 }
93}
94
95impl RootSecret {
96 pub fn ephemeral() -> Self {
124 let mut bytes = [0u8; 32];
125 rand_core::OsRng.fill_bytes(&mut bytes);
126 Self { bytes }
127 }
128}
129
130impl std::fmt::Debug for RootSecret {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.write_str("RootSecret([REDACTED])")
133 }
134}
135
136pub struct SignerChain {
146 signers: Vec<Box<dyn IdentitySigner>>,
147}
148
149impl SignerChain {
150 pub fn new(signers: Vec<Box<dyn IdentitySigner>>) -> Self {
153 Self { signers }
154 }
155
156 pub fn new_sorted(mut signers: Vec<Box<dyn IdentitySigner>>) -> Self {
158 signers.sort_by_key(|s| s.tier());
159 Self { signers }
160 }
161
162 pub fn available(&self) -> Option<&dyn IdentitySigner> {
164 self.signers.iter().find(|s| s.is_available()).map(|s| s.as_ref())
165 }
166
167 pub fn status(&self) -> Vec<(&str, SignerTier, bool)> {
169 self.signers
170 .iter()
171 .map(|s| (s.label(), s.tier(), s.is_available()))
172 .collect()
173 }
174}
175
176#[async_trait::async_trait]
177impl IdentitySigner for SignerChain {
178 fn tier(&self) -> SignerTier {
179 self.available().map(|s| s.tier()).unwrap_or(SignerTier::EncryptedFile)
180 }
181
182 fn label(&self) -> &str {
183 self.available().map(|s| s.label()).unwrap_or("(no signer available)")
184 }
185
186 fn is_available(&self) -> bool {
187 self.signers.iter().any(|s| s.is_available())
188 }
189
190 async fn root_secret(&self) -> Result<RootSecret, SignerError> {
191 for signer in &self.signers {
192 if signer.is_available() {
193 return signer.root_secret().await;
194 }
195 }
196 Err(SignerError::Unavailable("no signer available in chain".into()))
197 }
198
199 async fn sign(&self, data: &[u8]) -> Result<Vec<u8>, SignerError> {
200 for signer in &self.signers {
201 if signer.is_available() {
202 return signer.sign(data).await;
203 }
204 }
205 Err(SignerError::Unavailable("no signer available in chain".into()))
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn root_secret_zeroizes_debug() {
215 let secret = RootSecret::new([42u8; 32]);
216 let debug = format!("{:?}", secret);
217 assert_eq!(debug, "RootSecret([REDACTED])");
218 assert_eq!(secret.as_bytes(), &[42u8; 32]);
219 }
220
221 #[test]
222 fn signer_tier_ordering() {
223 assert!(SignerTier::HardwareHsm < SignerTier::DeviceHsm);
224 assert!(SignerTier::DeviceHsm < SignerTier::CredentialManager);
225 assert!(SignerTier::CredentialManager < SignerTier::EncryptedFile);
226 }
227
228 struct MockSigner {
232 tier: SignerTier,
233 name: &'static str,
234 available: bool,
235 }
236
237 #[async_trait::async_trait]
238 impl IdentitySigner for MockSigner {
239 fn tier(&self) -> SignerTier {
240 self.tier
241 }
242 fn label(&self) -> &str {
243 self.name
244 }
245 fn is_available(&self) -> bool {
246 self.available
247 }
248 async fn root_secret(&self) -> Result<RootSecret, SignerError> {
249 if self.available {
250 Ok(RootSecret::new([self.tier as u8; 32]))
251 } else {
252 Err(SignerError::Unavailable(self.name.into()))
253 }
254 }
255 async fn sign(&self, _data: &[u8]) -> Result<Vec<u8>, SignerError> {
256 if self.available {
257 Ok(vec![self.tier as u8; 64])
258 } else {
259 Err(SignerError::Unavailable(self.name.into()))
260 }
261 }
262 }
263
264 #[test]
265 fn chain_selects_first_available() {
266 let chain = SignerChain::new(vec![
267 Box::new(MockSigner {
268 tier: SignerTier::HardwareHsm,
269 name: "yubikey",
270 available: false,
271 }),
272 Box::new(MockSigner {
273 tier: SignerTier::EncryptedFile,
274 name: "file",
275 available: true,
276 }),
277 ]);
278 assert!(chain.is_available());
279 assert_eq!(chain.label(), "file");
280 assert_eq!(chain.tier(), SignerTier::EncryptedFile);
281 }
282
283 #[test]
284 fn chain_prefers_higher_tier() {
285 let chain = SignerChain::new(vec![
286 Box::new(MockSigner {
287 tier: SignerTier::HardwareHsm,
288 name: "yubikey",
289 available: true,
290 }),
291 Box::new(MockSigner {
292 tier: SignerTier::EncryptedFile,
293 name: "file",
294 available: true,
295 }),
296 ]);
297 assert_eq!(chain.label(), "yubikey");
298 assert_eq!(chain.tier(), SignerTier::HardwareHsm);
299 }
300
301 #[test]
302 fn chain_empty_is_unavailable() {
303 let chain = SignerChain::new(vec![]);
304 assert!(!chain.is_available());
305 }
306
307 #[test]
308 fn chain_all_unavailable() {
309 let chain = SignerChain::new(vec![
310 Box::new(MockSigner {
311 tier: SignerTier::HardwareHsm,
312 name: "yubikey",
313 available: false,
314 }),
315 Box::new(MockSigner {
316 tier: SignerTier::EncryptedFile,
317 name: "file",
318 available: false,
319 }),
320 ]);
321 assert!(!chain.is_available());
322 assert_eq!(chain.label(), "(no signer available)");
323 }
324
325 #[tokio::test]
326 async fn chain_sign_uses_first_available() {
327 let chain = SignerChain::new(vec![
328 Box::new(MockSigner {
329 tier: SignerTier::HardwareHsm,
330 name: "yubikey",
331 available: false,
332 }),
333 Box::new(MockSigner {
334 tier: SignerTier::EncryptedFile,
335 name: "file",
336 available: true,
337 }),
338 ]);
339 let sig = chain.sign(b"test").await.unwrap();
340 assert_eq!(sig[0], SignerTier::EncryptedFile as u8);
341 }
342
343 #[tokio::test]
344 async fn chain_sign_fails_when_none_available() {
345 let chain = SignerChain::new(vec![Box::new(MockSigner {
346 tier: SignerTier::HardwareHsm,
347 name: "yubikey",
348 available: false,
349 })]);
350 assert!(chain.sign(b"test").await.is_err());
351 }
352
353 #[test]
356 fn ephemeral_is_non_zero() {
357 let root = RootSecret::ephemeral();
358 assert_ne!(root.as_bytes(), &[0u8; 32], "CSPRNG should not produce all zeros");
359 }
360
361 #[test]
362 fn ephemeral_produces_unique_roots() {
363 let a = RootSecret::ephemeral();
364 let b = RootSecret::ephemeral();
365 assert_ne!(
366 a.as_bytes(),
367 b.as_bytes(),
368 "two ephemeral roots must be independent"
369 );
370 }
371
372 #[test]
373 fn ephemeral_is_unlinkable_to_fixed_root() {
374 let fixed = RootSecret::new([0x42u8; 32]);
375 let ephemeral = RootSecret::ephemeral();
376
377 let d_fixed = crate::derive::KeyDeriver::new(fixed.as_bytes());
379 let d_ephemeral = crate::derive::KeyDeriver::new(ephemeral.as_bytes());
380
381 let k_fixed = d_fixed.derive(crate::derive::KeyPurpose::Signing);
382 let k_ephemeral = d_ephemeral.derive(crate::derive::KeyPurpose::Signing);
383
384 assert_ne!(
385 k_fixed, k_ephemeral,
386 "ephemeral keys must not match any fixed identity"
387 );
388 }
389
390 #[test]
391 fn chain_status_reports_all() {
392 let chain = SignerChain::new(vec![
393 Box::new(MockSigner {
394 tier: SignerTier::HardwareHsm,
395 name: "yubikey",
396 available: false,
397 }),
398 Box::new(MockSigner {
399 tier: SignerTier::EncryptedFile,
400 name: "file",
401 available: true,
402 }),
403 ]);
404 let status = chain.status();
405 assert_eq!(status.len(), 2);
406 assert_eq!(status[0], ("yubikey", SignerTier::HardwareHsm, false));
407 assert_eq!(status[1], ("file", SignerTier::EncryptedFile, true));
408 }
409}