Skip to main content

styrene_identity/
signer.rs

1//! IdentitySigner trait — abstract signing interface across hardware tiers.
2
3use rand_core::RngCore;
4use zeroize::Zeroize;
5
6/// Signer implementation tier — indicates trust level and key storage model.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub enum SignerTier {
9    /// Hardware HSM — non-exportable keys (YubiKey PIV/FIDO2).
10    HardwareHsm,
11    /// Device HSM — platform secure element (iOS Secure Enclave, Android StrongBox).
12    DeviceHsm,
13    /// Credential manager — software key store (Bitwarden, 1Password SSH items).
14    CredentialManager,
15    /// Encrypted file — argon2id + ChaCha20Poly1305 on disk (default).
16    EncryptedFile,
17}
18
19/// Errors from signer operations.
20#[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/// Abstract identity signer — implementations wrap hardware or software key stores.
42///
43/// The signer provides the 32-byte root secret that feeds the HKDF derivation
44/// hierarchy. Higher-tier signers (A, B) never expose the raw secret — they
45/// perform derivation internally. Lower-tier signers (C, D) yield the secret
46/// for the caller to derive keys.
47///
48/// All implementations must be `Send + Sync` for use in async daemon context.
49#[async_trait::async_trait]
50pub trait IdentitySigner: Send + Sync {
51    /// Which tier this signer implements.
52    fn tier(&self) -> SignerTier;
53
54    /// Human-readable label (e.g., "YubiKey 5C #12345", "Keychain", "~/.styrene/identity").
55    fn label(&self) -> &str;
56
57    /// Whether the signer is currently unlocked and ready to sign.
58    fn is_available(&self) -> bool;
59
60    /// Get the 32-byte root secret for HKDF derivation.
61    ///
62    /// For Tier A/B, this may require user interaction (NFC tap, biometric).
63    /// For Tier C/D, this reads from the key store.
64    ///
65    /// The returned secret is zeroized on drop.
66    async fn root_secret(&self) -> Result<RootSecret, SignerError>;
67
68    /// Sign arbitrary data with the identity's Ed25519 key.
69    ///
70    /// Implementations must derive or use the signing key appropriate to their tier.
71    /// Hardware signers (Tier A/B) use on-device signing.
72    /// Software signers (Tier C/D) derive via HKDF then sign with ed25519-dalek.
73    async fn sign(&self, data: &[u8]) -> Result<Vec<u8>, SignerError>;
74}
75
76/// A 32-byte root secret that zeroizes on drop.
77#[derive(Zeroize)]
78#[zeroize(drop)]
79pub struct RootSecret {
80    bytes: [u8; 32],
81}
82
83impl RootSecret {
84    /// Create from raw bytes.
85    pub fn new(bytes: [u8; 32]) -> Self {
86        Self { bytes }
87    }
88
89    /// Access the raw bytes.
90    pub fn as_bytes(&self) -> &[u8; 32] {
91        &self.bytes
92    }
93}
94
95impl RootSecret {
96    /// Generate an ephemeral root secret from the OS CSPRNG.
97    ///
98    /// The returned secret has **no relationship** to any persistent identity.
99    /// Use this for anonymous, pseudonymous, or one-time identities that must
100    /// not be linkable to your primary StyreneIdentity.
101    ///
102    /// The ephemeral root is never written to disk. Keys derived from it are
103    /// cryptographically independent of any file-backed or hardware-backed
104    /// identity. When the `RootSecret` is dropped, the bytes are zeroized.
105    ///
106    /// # When to use this
107    ///
108    /// - Anonymous RNS addresses for one-time communication
109    /// - Pseudonymous identities that must not be attributable
110    /// - Testing and development without touching real identity files
111    ///
112    /// # When NOT to use this
113    ///
114    /// - Any identity you need to recover later (there is no backup)
115    /// - Any identity you want attributed to you (use your persistent identity)
116    ///
117    /// ```
118    /// use styrene_identity::signer::RootSecret;
119    ///
120    /// let ephemeral = RootSecret::ephemeral();
121    /// // Derive keys, use them, drop — no trace left.
122    /// ```
123    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
136/// Ordered chain of signers — tries each in tier order (A→B→C→D) until
137/// one succeeds. This is the automatic fallback mechanism described in the spec.
138///
139/// ```text
140/// SignerChain [YubiKeySigner, FileSigner]
141///   1. Try YubiKeySigner.is_available() → false (no YubiKey plugged in)
142///   2. Try FileSigner.is_available() → true
143///   3. Use FileSigner
144/// ```
145pub struct SignerChain {
146    signers: Vec<Box<dyn IdentitySigner>>,
147}
148
149impl SignerChain {
150    /// Create a signer chain from a list of signers. They will be tried in
151    /// the given order — callers should sort by tier (highest security first).
152    pub fn new(signers: Vec<Box<dyn IdentitySigner>>) -> Self {
153        Self { signers }
154    }
155
156    /// Create a signer chain sorted by tier (A before D).
157    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    /// Find the first available signer, or None.
163    pub fn available(&self) -> Option<&dyn IdentitySigner> {
164        self.signers.iter().find(|s| s.is_available()).map(|s| s.as_ref())
165    }
166
167    /// List all signers with their availability status.
168    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    // ── SignerChain tests ───────────────────────────────────────────────────
229
230    /// A mock signer for testing the chain.
231    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    // ── Ephemeral root tests ────────────────────────────────────────────────
354
355    #[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        // Derive the same purpose from both — they must differ
378        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}