Skip to main content
KeyOS API Reference

security/
lib.rs

1// SPDX-FileCopyrightText: 2023 Foundation Devices, Inc. <hello@foundation.xyz>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4pub mod messages;
5use {
6    bip39::{Error as Bip39Error, Language, Mnemonic},
7    crypto::error::CryptoError,
8    messages::*,
9    std::{fmt, num::ParseIntError, str::Utf8Error},
10    zeroize::ZeroizeOnDrop,
11};
12
13pub const MAX_LOGIN_ATTEMPTS: u32 = 10;
14pub const MIN_PIN_LENGTH: usize = 6;
15
16/// FIDO attestation private key for software signing.
17/// Corresponding pubkey for testing:
18/// 044c0fef3ee1ac94a1cb113e87db62ba64ac3666cce5690c333c7f801d7d4254f1dcc700b76d2ce311170bf543967f4e6b8204cb9ba99f44d3039ee76d1d527560
19pub const DEV_FIDO_ATTESTATION_PRIVATE_KEY: [u8; 32] = [
20    0xbc, 0x2a, 0x1b, 0xfb, 0xce, 0xf4, 0xf7, 0x53, 0xb8, 0x6e, 0xbe, 0x13, 0x02, 0x13, 0x33, 0xc9, 0xbe,
21    0x7e, 0x4c, 0xd0, 0x7b, 0x2a, 0xb9, 0x94, 0xb4, 0xcf, 0x23, 0x36, 0x4b, 0x6f, 0x3c, 0x33,
22];
23
24#[derive(Default)]
25pub struct Security<P: server::CheckedPermissions> {
26    conn: server::CheckedConn<P>,
27}
28
29#[derive(Clone, ZeroizeOnDrop, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
30pub struct Pin(pub [u8; 32]);
31
32impl fmt::Debug for Pin {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_tuple("Pin").field(&"<redacted>").finish()
35    }
36}
37
38#[derive(Clone, ZeroizeOnDrop, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
39pub enum Seed {
40    /// Twelve word seed.
41    Twelve([u8; 16]),
42    /// Twenty-four word seed.
43    TwentyFour([u8; 32]),
44}
45
46impl fmt::Debug for Seed {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Twelve(_) => f.debug_tuple("Twelve").field(&"<redacted>").finish(),
50            Self::TwentyFour(_) => f.debug_tuple("TwentyFour").field(&"<redacted>").finish(),
51        }
52    }
53}
54
55impl Seed {
56    /// Creates a new `Seed` from a byte slice. The slice must be either 16 bytes (for a 12-word seed) or 32
57    /// bytes (for a 24-word seed).
58    ///
59    /// # Panics
60    ///
61    /// Panics if the length of the slice is not 16 or 32 bytes.
62    pub fn from_bytes(seed: &[u8]) -> Self {
63        match seed.len() {
64            16 => Seed::Twelve(seed.try_into().unwrap()),
65            32 => Seed::TwentyFour(seed.try_into().unwrap()),
66            _ => panic!("Invalid seed length: expected 16 or 32 bytes, got {}", seed.len()),
67        }
68    }
69
70    pub fn bytes(&self) -> &[u8] {
71        match self {
72            Seed::Twelve(bytes) => bytes,
73            Seed::TwentyFour(bytes) => bytes,
74        }
75    }
76
77    pub fn to_vec(&self) -> Vec<u8> { self.bytes().to_vec() }
78
79    pub fn from_mnemonic(mnemonic: &Mnemonic) -> Self {
80        let entropy = mnemonic.to_entropy();
81        Self::from_bytes(&entropy)
82    }
83
84    pub fn to_mnemonic(&self) -> Result<Mnemonic, Bip39Error> { Mnemonic::from_entropy(self.bytes()) }
85
86    pub fn to_mnemonic_words(&self) -> Result<Vec<String>, Bip39Error> {
87        let mnemonic = self.to_mnemonic()?;
88        Ok(mnemonic.words().map(str::to_string).collect())
89    }
90
91    pub fn to_standard_seed_qr_data(&self) -> Result<Vec<u8>, Bip39Error> {
92        let mnemonic = self.to_mnemonic()?;
93        let indices: String = mnemonic.word_indices().map(|idx| format!("{idx:04}")).collect();
94        Ok(indices.into_bytes())
95    }
96
97    pub fn to_compact_seed_qr_data(&self) -> Result<Vec<u8>, Bip39Error> {
98        let mnemonic = self.to_mnemonic()?;
99        Ok(mnemonic.to_entropy())
100    }
101}
102
103impl Default for Seed {
104    fn default() -> Self { Seed::TwentyFour([0; 32]) }
105}
106
107/// The 32-byte per-app seed the security element derives for a calling app.
108///
109/// A distinct type (annotated like [`Seed`]) so the zeroize-on-drop treatment follows the
110/// bytes wherever an `AppSeed` flows, and a call site can't quietly pass an unrelated array
111/// where a seed is expected. Raw `[u8; 32]` copies a caller makes from `as_bytes` still escape
112/// scrubbing (Rust move semantics), so this narrows the exposure window rather than closing it.
113#[derive(Clone, ZeroizeOnDrop, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
114pub struct AppSeed([u8; 32]);
115
116impl AppSeed {
117    pub fn new(bytes: [u8; 32]) -> Self { Self(bytes) }
118
119    pub fn as_bytes(&self) -> &[u8; 32] { &self.0 }
120}
121
122#[derive(Clone, Debug, thiserror::Error)]
123pub enum ParseSeedQrError {
124    #[error("Invalid UTF-8 in word index: {0}")]
125    InvalidUtf8(#[from] Utf8Error),
126
127    #[error("Failed to parse word index: {0}")]
128    InvalidWordIndex(#[from] ParseIntError),
129
130    #[error("Word index {0} out of range")]
131    WordIndexOutOfRange(usize),
132
133    #[error("Invalid mnemonic: {0}")]
134    InvalidMnemonic(#[from] Bip39Error),
135}
136
137/// Parse standard, compact, or plaintext mnemonic SeedQR format.
138/// <https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md>
139pub fn parse_seedqr(qr_data: &[u8]) -> Result<Mnemonic, ParseSeedQrError> {
140    // 12 or 24 word standard qr
141    if qr_data.len() == 48 || qr_data.len() == 96 {
142        let words = qr_data
143            .chunks(4)
144            .map(|index| -> Result<&'static str, ParseSeedQrError> {
145                let index_str = std::str::from_utf8(index)?;
146                let index: usize = index_str.parse()?;
147                let word = Language::English
148                    .word_list()
149                    .get(index)
150                    .copied()
151                    .ok_or(ParseSeedQrError::WordIndexOutOfRange(index))?;
152                Ok(word)
153            })
154            .collect::<Result<Vec<&'static str>, _>>()?
155            .join(" ");
156
157        return Mnemonic::parse(words.as_str()).map_err(Into::into);
158    }
159
160    if let Ok(text) = std::str::from_utf8(qr_data) {
161        if let Ok(mnemonic) = Mnemonic::parse_normalized(text) {
162            return Ok(mnemonic);
163        }
164    }
165
166    Mnemonic::from_entropy(qr_data).map_err(Into::into)
167}
168
169#[derive(Debug, Default, Copy, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, PartialEq, Eq)]
170pub enum PinEntryMode {
171    #[default]
172    Pin = 0,
173    Passphrase = 1,
174}
175
176impl From<u8> for PinEntryMode {
177    fn from(value: u8) -> Self {
178        match value {
179            0 => PinEntryMode::Pin,
180            1 => PinEntryMode::Passphrase,
181            _ => PinEntryMode::Pin,
182        }
183    }
184}
185
186impl From<PinEntryMode> for u8 {
187    fn from(mode: PinEntryMode) -> u8 { mode as u8 }
188}
189
190/// Determines what data apart from the seed the lockout will erase.
191/// The seed is always erased.
192#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, PartialEq, Eq)]
193pub struct LockoutOptions {
194    pub seed_fingerprint: bool,
195    pub aes_keys: bool,
196}
197
198impl LockoutOptions {
199    pub const fn erase_seed_only() -> Self { Self { seed_fingerprint: false, aes_keys: false } }
200
201    pub const fn erase_all() -> Self { Self { seed_fingerprint: true, aes_keys: true } }
202
203    pub const fn erase_aes_keys() -> Self { Self { seed_fingerprint: false, aes_keys: true } }
204}
205
206#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, PartialEq, Eq)]
207pub struct FirmwareTimestamp(pub [u8; 4]);
208
209impl From<FirmwareTimestamp> for u32 {
210    fn from(ts: FirmwareTimestamp) -> u32 { u32::from_le_bytes(ts.0) }
211}
212
213impl From<u32> for FirmwareTimestamp {
214    fn from(ts: u32) -> FirmwareTimestamp { FirmwareTimestamp(ts.to_le_bytes()) }
215}
216
217impl Default for FirmwareTimestamp {
218    fn default() -> Self { 0u32.into() }
219}
220
221pub struct LastSuccess {
222    pub num_fails: u32,
223    pub attempts_left: u32,
224}
225
226#[macro_export]
227macro_rules! use_api {
228    () => {
229        mod security_permissions {
230            use security::messages::*;
231            #[derive(Clone, Default, server::Permissions)]
232            #[server_name = "os/security"]
233            pub struct SecurityPermissions;
234        }
235        type Security = security::Security<security_permissions::SecurityPermissions>;
236    };
237}
238
239impl<P: server::CheckedPermissions> Security<P> {
240    /// User does not need to be logged in. Use this when setting the seed and PIN for the first
241    /// time.
242    pub fn set_seed_and_pin(&self, seed: Seed, pin: String, pin_entry: PinEntryMode) -> Result<(), PinError>
243    where
244        P: server::MessageAllowed<SetSeedAndPin>,
245    {
246        self.conn.send_blocking_archive(SetSeedAndPin { seed, pin: RawPin(pin), pin_entry })
247    }
248
249    /// User must be [logged in](Login) to set a new pin.
250    pub fn change_pin(
251        &self,
252        raw_pin: String,
253        seed: Option<Seed>,
254        pin_entry: PinEntryMode,
255    ) -> Result<(), PinError>
256    where
257        P: server::MessageAllowed<ChangePin>,
258    {
259        self.conn.send_blocking_archive(ChangePin { pin: RawPin(raw_pin), seed, pin_entry })
260    }
261
262    pub fn is_pin_set(&self) -> Result<bool, AccessDenied>
263    where
264        P: server::MessageAllowed<IsPinSet>,
265    {
266        self.conn.send_blocking_archive(IsPinSet)
267    }
268
269    pub fn get_pin_entry_mode(&self) -> PinEntryMode
270    where
271        P: server::MessageAllowed<GetPinEntryMode>,
272    {
273        self.conn.send_blocking_archive(GetPinEntryMode)
274    }
275
276    pub fn log_in(&self, pin: String) -> Result<(), LoginFailed>
277    where
278        P: server::MessageAllowed<Login>,
279    {
280        self.conn.send_blocking_archive(Login { pin: RawPin(pin) })
281    }
282
283    pub fn log_out(&self)
284    where
285        P: server::MessageAllowed<Logout>,
286    {
287        self.conn.send_blocking_scalar(Logout)
288    }
289
290    pub fn logged_in(&self) -> bool
291    where
292        P: server::MessageAllowed<LoggedIn>,
293    {
294        self.conn.send_blocking_scalar(LoggedIn)
295    }
296
297    pub fn attempts_remaining(&self) -> Result<u32, AccessDenied>
298    where
299        P: server::MessageAllowed<GetAttemptsRemaining>,
300    {
301        self.conn.send_blocking_archive(GetAttemptsRemaining)
302    }
303
304    pub fn factory_reset_counter(&self) -> Result<u32, AccessDenied>
305    where
306        P: server::MessageAllowed<GetFactoryResetCounter>,
307    {
308        self.conn.send_blocking_archive(GetFactoryResetCounter)
309    }
310
311    /// Fetches the [Seed] from SE.
312    ///
313    /// # Returns
314    ///
315    /// - `None` if `otp_key` field of SECURAM is set to all zeros.
316    /// - `Some(seed)` otherwise.
317    pub fn seed(&self) -> Result<Option<Seed>, AccessDenied>
318    where
319        P: server::MessageAllowed<GetSeed>,
320    {
321        self.conn.send_blocking_archive(GetSeed)
322    }
323
324    /// User must be [logged in](Login) to change the seed. This is because a XOR operation will
325    /// be performed between the seed and the PIN hash before storing it in the SE.
326    ///
327    /// In case the user is setting the seed for the first time, use [`SetSeedAndPin`] instead.
328    pub fn set_seed(&self, seed: Seed) -> Result<(), AccessDenied>
329    where
330        P: server::MessageAllowed<SetSeed>,
331    {
332        self.conn.send_blocking_archive(SetSeed(seed))
333    }
334
335    pub fn app_seed(&self) -> Result<AppSeed, AccessDenied>
336    where
337        P: server::MessageAllowed<GetAppSeed>,
338    {
339        self.conn.send_blocking_archive(GetAppSeed)
340    }
341
342    pub fn lockout(&self, lockout_options: LockoutOptions) -> Result<(), AccessDenied>
343    where
344        P: server::MessageAllowed<Lockout>,
345    {
346        self.conn.send_blocking_archive(Lockout { lockout_options, reboot: true })
347    }
348
349    pub fn sign_with_security_check_key(&self, data: [u8; 32]) -> Result<[u8; 64], AccessDenied>
350    where
351        P: server::MessageAllowed<SignWithSecurityCheckKey>,
352    {
353        self.conn.send_blocking_archive(SignWithSecurityCheckKey(data))
354    }
355
356    pub fn sign_with_fido_key(&self, data: [u8; 32]) -> Result<[u8; 64], AccessDenied>
357    where
358        P: server::MessageAllowed<SignWithFidoKey>,
359    {
360        self.conn.send_blocking_archive(SignWithFidoKey(data))
361    }
362
363    pub fn get_fido_pubkey(&self) -> Result<[u8; 64], AccessDenied>
364    where
365        P: server::MessageAllowed<GetFidoPubkey>,
366    {
367        self.conn.send_blocking_archive(GetFidoPubkey)
368    }
369
370    pub fn security_words(&self, pin_prefix: &str) -> Result<[SecurityWord; 2], AccessDenied>
371    where
372        P: server::MessageAllowed<GetSecurityWords>,
373    {
374        self.conn.send_blocking_archive(GetSecurityWords { pin_prefix: pin_prefix.as_bytes().to_vec() })
375    }
376
377    pub fn firmware_timestamp(&self) -> Result<FirmwareTimestamp, AccessDenied>
378    where
379        P: server::MessageAllowed<GetFirmwareTimestamp>,
380    {
381        self.conn.send_blocking_archive(GetFirmwareTimestamp)
382    }
383
384    pub fn set_firmware_timestamp(&self, timestamp: FirmwareTimestamp) -> Result<(), AccessDenied>
385    where
386        P: server::MessageAllowed<SetFirmwareTimestamp>,
387    {
388        self.conn.send_blocking_archive(SetFirmwareTimestamp(timestamp))
389    }
390
391    pub fn seed_fingerprint(&self) -> Result<[u8; 32], AccessDenied>
392    where
393        P: server::MessageAllowed<GetSeedFingerprint>,
394    {
395        self.conn.send_blocking_archive(GetSeedFingerprint)
396    }
397
398    pub fn fingerprint(&self, seed: &Seed) -> Result<[u8; 32], AccessDenied>
399    where
400        P: server::MessageAllowed<ComputeSeedFingerprint>,
401    {
402        self.conn.send_blocking_archive(ComputeSeedFingerprint(seed.clone()))
403    }
404
405    pub fn os_version_info(&self) -> Result<Option<OsVersionInfo>, AccessDenied>
406    where
407        P: server::MessageAllowed<GetOsVersionInfo>,
408    {
409        self.conn.send_blocking_archive(GetOsVersionInfo)
410    }
411
412    pub fn bootloader_build_date(&self) -> Result<Option<u64>, AccessDenied>
413    where
414        P: server::MessageAllowed<GetBootloaderBuildDate>,
415    {
416        self.conn.send_blocking_archive(GetBootloaderBuildDate)
417    }
418
419    pub fn sc_challenge(&self, challenge: [u8; ScChallenge::SIZE]) -> Result<ScProof, ScChallengeError>
420    where
421        P: server::MessageAllowed<ScChallenge>,
422    {
423        self.conn.send_blocking_archive(ScChallenge(challenge))
424    }
425
426    pub fn device_id(&self) -> Result<DeviceId, GetDeviceIdError>
427    where
428        P: server::MessageAllowed<GetDeviceId>,
429    {
430        self.conn.send_blocking_archive(GetDeviceId)
431    }
432
433    pub fn get_random(&self) -> Result<[u8; 32], AccessDenied>
434    where
435        P: server::MessageAllowed<GetRandom>,
436    {
437        self.conn.send_blocking_archive(GetRandom)
438    }
439
440    pub fn keycard_authenticity_mac(&self, msg: [u8; 32]) -> Result<[u8; 32], AccessDenied>
441    where
442        P: server::MessageAllowed<KeycardAuthenticityMac>,
443    {
444        self.conn.send_blocking_archive(KeycardAuthenticityMac(msg))
445    }
446
447    #[cfg(not(keyos))]
448    pub fn get_pin(&self) -> String
449    where
450        P: server::MessageAllowed<GetPin>,
451    {
452        self.conn.send_blocking_archive(GetPin)
453    }
454
455    #[cfg(not(keyos))]
456    pub fn set_attempts_remaining(&self, attempts: u32) -> Result<(), SecurityError>
457    where
458        P: server::MessageAllowed<SetAttempts>,
459    {
460        if attempts > MAX_LOGIN_ATTEMPTS {
461            return Err(SecurityError::AttemptsOutOfBounds(attempts));
462        }
463
464        self.conn.send_blocking_archive(SetAttempts(MAX_LOGIN_ATTEMPTS - attempts));
465        Ok(())
466    }
467
468    /// Get the bluetooth HMAC challenge secret and whether it was shared with the BT chip already.
469    pub fn bluetooth_challenge_secret(&self) -> BluetoothChallengeSecret
470    where
471        P: server::MessageAllowed<GetBluetoothChallengeSecret>,
472    {
473        self.conn.send_blocking_archive(GetBluetoothChallengeSecret)
474    }
475
476    pub fn set_bluetooth_challenge_secret_sent(&self)
477    where
478        P: server::MessageAllowed<SetBluetoothCheckSecretSent>,
479    {
480        self.conn.send_blocking_scalar(SetBluetoothCheckSecretSent)
481    }
482
483    pub fn set_bluetooth_device_id(&self, device_id: [u8; 8])
484    where
485        P: server::MessageAllowed<SetBluetoothDeviceId>,
486    {
487        self.conn.send_blocking_archive(SetBluetoothDeviceId(device_id))
488    }
489
490    pub fn master_key_state(&self) -> MasterKeyState
491    where
492        P: server::MessageAllowed<GetMasterKeyState>,
493    {
494        self.conn.send_blocking_scalar(GetMasterKeyState)
495    }
496
497    /// Subscribe to the `DiskEncryptionKeysReady` event. The event fires once, when the security server
498    /// has written disk encryption keys into SECURAM. Subscribers that arrive after the event has already
499    /// fired receive it immediately on subscription.
500    pub fn subscribe_disk_encryption_keys_ready<SR>(&self, context: &mut server::ServerContext<SR>)
501    where
502        P: server::MessageAllowed<SubscribeDiskEncryptionKeysReady>,
503        SR: server::ScalarEventHandler<DiskEncryptionKeysReady>,
504    {
505        self.conn.subscribe_scalar_infallible(SubscribeDiskEncryptionKeysReady, context)
506    }
507}
508
509/// The state of the master key determined by the combination of the secrets available to the security server.
510#[derive(Debug, Copy, Clone)]
511pub enum MasterKeyState {
512    Onboarding,
513    Erased,
514    Normal,
515    Unknown,
516}
517
518#[cfg(not(keyos))]
519#[derive(Debug, thiserror::Error)]
520pub enum SecurityError {
521    #[error("Attempts remaining must not be greater than max attempts of {}: {0:?}", MAX_LOGIN_ATTEMPTS)]
522    AttemptsOutOfBounds(u32),
523}
524
525#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
526pub struct SecurityWord(pub usize);
527
528impl std::fmt::Display for SecurityWord {
529    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530        bip39::Language::English.word_list()[self.0].fmt(f)
531    }
532}
533
534#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, thiserror::Error)]
535pub struct AccessDenied;
536
537impl std::fmt::Display for AccessDenied {
538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Access denied") }
539}
540
541#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, thiserror::Error)]
542pub enum PinError {
543    #[error("Access denied")]
544    AccessDenied,
545    #[error("PIN too short")]
546    TooShort,
547}
548
549impl From<AccessDenied> for PinError {
550    fn from(_: AccessDenied) -> Self { PinError::AccessDenied }
551}
552
553#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
554pub struct LoginFailed {
555    pub attempts_left: u32,
556}
557
558impl std::fmt::Display for LoginFailed {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Login failed") }
560}
561
562#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
563pub struct OsVersionInfo {
564    pub bootloader_version: [u8; 8],
565    pub keyos_version: [u8; 20],
566}
567
568/// A message sent from the device to the server, serving to prove that the device knows the private key
569/// corresponding to the public key it claims to own. The message has the following binary format:
570/// ```text
571/// ----------------------------------------------------------------------------------------
572/// | challenge | deadline | device pubkey | device nonce | bootloader version | signature |
573/// | 32 bytes  | 8 bytes  | 33 bytes      | 32 bytes     | 20 bytes           | 64 bytes  |
574/// ----------------------------------------------------------------------------------------
575/// ```
576#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
577pub struct ScProof(pub [u8; Self::SIZE]);
578
579impl ScProof {
580    pub const SIZE: usize = 189;
581}
582
583#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
584#[repr(u8)]
585pub enum ScError {
586    Ok = 0,
587    InvalidMessageLength = 1,
588    InvalidSignature = 3,
589    DeadlineExpired = 4,
590    UnknownChallenge = 6,
591    InvalidBootloaderVersion = 7,
592}
593
594#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
595pub enum ScChallengeError {
596    Sc(ScError),
597    CryptoAuthLib(i32),
598    Crypto(CryptoError),
599    AccessDenied,
600    Internal(String),
601}
602
603#[derive(Debug, thiserror::Error, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
604pub enum GetDeviceIdError {
605    #[error("crypto auth lib error: {0}")]
606    CryptoAuthLib(i32),
607    #[error(transparent)]
608    Crypto(CryptoError),
609    #[error("no bluetooth serial yet")]
610    NoBluetoothSerialYet,
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
614pub struct DeviceId(pub [u8; 32]);
615
616impl std::fmt::Display for DeviceId {
617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        write!(
619            f,
620            "{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}",
621            self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5], self.0[6], self.0[7]
622        )
623    }
624}
625
626#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
627pub struct BluetoothChallengeSecret {
628    pub secret: [u8; 32],
629    pub sent: bool,
630}
631
632impl fmt::Debug for BluetoothChallengeSecret {
633    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
634        f.debug_struct("BluetoothChallengeSecret")
635            .field("secret", &"<redacted>")
636            .field("sent", &self.sent)
637            .finish()
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn test_seed_mnemonic_roundtrip() {
647        let seed = Seed::Twelve([0x7Au8; 16]);
648        let mnemonic = seed.to_mnemonic().unwrap();
649        let recovered_seed = Seed::from_mnemonic(&mnemonic).to_vec();
650
651        assert_eq!(
652            &seed.bytes()[..mnemonic.to_entropy().len()],
653            &recovered_seed[..mnemonic.to_entropy().len()]
654        );
655    }
656
657    #[test]
658    fn test_parse_seedqr_standard_12_word() {
659        // Standard SeedQR format: 48 bytes (12 words * 4)
660        let qr_data = b"192402220235174306311124037817700641198012901210";
661
662        let result = parse_seedqr(qr_data).unwrap().word_indices().collect::<Vec<_>>();
663
664        let expected = vec![1924, 222, 235, 1743, 631, 1124, 378, 1770, 641, 1980, 1290, 1210];
665        assert_eq!(result, expected, "Word indices should match expected values");
666    }
667
668    #[test]
669    fn test_parse_seedqr_standard_24_word() {
670        let entropy = [0x35u8; 32];
671        let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
672
673        let indices: String = mnemonic.word_indices().map(|idx| format!("{idx:04}")).collect();
674        let qr_data = indices.as_bytes();
675        let result = parse_seedqr(qr_data).unwrap();
676
677        assert_eq!(result, mnemonic);
678        assert_eq!(result.word_count(), 24);
679    }
680
681    #[test]
682    fn test_parse_seedqr_compact() {
683        fn test(entropy: &[u8]) {
684            let mnemonic = Mnemonic::from_entropy(entropy).unwrap();
685            let result = parse_seedqr(entropy).unwrap();
686            assert_eq!(result, mnemonic);
687        }
688
689        test(&[0x11u8; 16]);
690        test(&[0x22u8; 32]);
691    }
692
693    #[test]
694    fn test_parse_seedqr_plaintext() {
695        let mnemonic = Mnemonic::from_entropy(&[0x5Au8; 16]).unwrap();
696        let qr_data = mnemonic.to_string();
697        let result = parse_seedqr(qr_data.as_bytes()).unwrap();
698
699        assert_eq!(result, mnemonic);
700    }
701
702    #[test]
703    fn test_parse_seedqr_plaintext_with_extra_whitespace() {
704        let mnemonic = Mnemonic::from_entropy(&[0xA5u8; 32]).unwrap();
705        let words = mnemonic.words().collect::<Vec<_>>();
706        let qr_data = format!("  {}\n{}\n  ", words[..12].join("  "), words[12..].join("\n"));
707
708        let result = parse_seedqr(qr_data.as_bytes()).unwrap();
709        assert_eq!(result, mnemonic);
710    }
711
712    #[test]
713    fn test_seedqr_generation_roundtrip() {
714        let seed = Seed::Twelve([0x6Cu8; 16]);
715
716        let standard_data = seed.to_standard_seed_qr_data().unwrap();
717        let parsed_standard = parse_seedqr(&standard_data).unwrap();
718        let recovered_seed = Seed::from_mnemonic(&parsed_standard).to_vec();
719        assert_eq!(
720            &seed.bytes()[..parsed_standard.to_entropy().len()],
721            &recovered_seed[..parsed_standard.to_entropy().len()]
722        );
723
724        let compact_data = seed.to_compact_seed_qr_data().unwrap();
725        let parsed_compact = parse_seedqr(&compact_data).unwrap();
726        let recovered_seed = Seed::from_mnemonic(&parsed_compact).to_vec();
727        assert_eq!(
728            &seed.bytes()[..parsed_compact.to_entropy().len()],
729            &recovered_seed[..parsed_compact.to_entropy().len()]
730        );
731    }
732
733    #[test]
734    fn test_parse_seedqr_errors() {
735        // Invalid UTF-8 in standard format (48 bytes)
736        let invalid_utf8 = vec![0xFF; 48];
737        assert!(matches!(parse_seedqr(&invalid_utf8), Err(ParseSeedQrError::InvalidUtf8(_))));
738
739        // Invalid number format in standard format
740        let invalid_number = b"abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"; // 48 bytes
741        assert!(matches!(parse_seedqr(invalid_number), Err(ParseSeedQrError::InvalidWordIndex(_))));
742
743        // Out of range index
744        let out_of_range = b"999999999999999999999999999999999999999999999999"; // 48 bytes
745        assert!(matches!(parse_seedqr(out_of_range), Err(ParseSeedQrError::WordIndexOutOfRange(9999))));
746
747        // Invalid compact format (not a valid entropy length)
748        let invalid_compact = b"invalid"; // 7 bytes
749        assert!(matches!(parse_seedqr(invalid_compact), Err(ParseSeedQrError::InvalidMnemonic(_))));
750    }
751}