Skip to main content

homecore_hap/
pairing.rs

1//! Durable HAP accessory identity, setup verifier, and controller pairings.
2#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
3
4use std::collections::BTreeMap;
5use std::fs::{self, File};
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
9use std::sync::RwLock;
10
11use ed25519_dalek::{SigningKey, VerifyingKey};
12use serde::{Deserialize, Serialize};
13use sha2_11::Sha512;
14use srp::ClientG3072;
15use tempfile::Builder;
16use tokio::sync::watch;
17use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
18
19use crate::error::HapError;
20
21const STORE_VERSION: u32 = 2;
22const MAX_STORE_BYTES: u64 = 1024 * 1024;
23const MAX_CONTROLLER_ID_BYTES: usize = 64;
24const MAX_CONTROLLERS: usize = 16;
25const MAX_PAIR_SETUP_FAILURES: u16 = 100;
26const SRP_USERNAME: &[u8] = b"Pair-Setup";
27
28/// A controller authorized by a completed HAP pairing ceremony.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ControllerPairing {
32    /// Case-sensitive HAP controller pairing identifier.
33    pub controller_id: String,
34    /// Controller Ed25519 long-term public key.
35    pub public_key: [u8; 32],
36    /// Whether this controller may manage other pairings.
37    pub admin: bool,
38}
39
40impl ControllerPairing {
41    /// Validate bounded identifiers and the encoded Ed25519 point.
42    pub fn validate(&self) -> Result<(), HapError> {
43        let len = self.controller_id.len();
44        if len == 0
45            || len > MAX_CONTROLLER_ID_BYTES
46            || self.controller_id.chars().any(char::is_control)
47        {
48            return Err(HapError::InvalidPairingRecord(
49                "controller_id must contain 1..=64 bytes and no control characters".into(),
50            ));
51        }
52        VerifyingKey::from_bytes(&self.public_key).map_err(|_| {
53            HapError::InvalidPairingRecord("controller public key is not valid Ed25519".into())
54        })?;
55        Ok(())
56    }
57}
58
59/// A newly provisioned HAP setup code.
60///
61/// The code is returned only when a store is created. The persisted file holds
62/// an SRP verifier instead of the raw code. Call [`SetupCode::expose`] only at
63/// the operator-facing provisioning boundary.
64#[derive(Zeroize, ZeroizeOnDrop)]
65pub struct SetupCode {
66    bytes: [u8; 10],
67}
68
69impl SetupCode {
70    pub fn parse(value: &str) -> Result<Self, HapError> {
71        let bytes: [u8; 10] = value.as_bytes().try_into().map_err(|_| {
72            HapError::InvalidPairingRecord("setup code must use the XXX-XX-XXX format".into())
73        })?;
74        if bytes[3] != b'-'
75            || bytes[6] != b'-'
76            || bytes
77                .iter()
78                .enumerate()
79                .any(|(index, byte)| index != 3 && index != 6 && !byte.is_ascii_digit())
80        {
81            return Err(HapError::InvalidPairingRecord(
82                "setup code must use the XXX-XX-XXX format".into(),
83            ));
84        }
85        let digits: Vec<u8> = bytes.iter().copied().filter(u8::is_ascii_digit).collect();
86        if is_trivial_setup_code(&digits) {
87            return Err(HapError::InvalidPairingRecord(
88                "setup code is prohibited because it is trivial".into(),
89            ));
90        }
91        Ok(Self { bytes })
92    }
93
94    /// Explicitly expose the code for a display, label, or provisioning UI.
95    pub fn expose(&self) -> &str {
96        std::str::from_utf8(&self.bytes).expect("validated setup code is ASCII")
97    }
98
99    pub(crate) fn as_bytes(&self) -> &[u8] {
100        &self.bytes
101    }
102
103    fn generate() -> Result<Self, HapError> {
104        const RANGE: u32 = 100_000_000;
105        const ACCEPT_BELOW: u32 = (u32::MAX / RANGE) * RANGE;
106        loop {
107            let mut random = [0u8; 4];
108            getrandom::getrandom(&mut random)
109                .map_err(|error| HapError::PairingStore(format!("generate setup code: {error}")))?;
110            let sample = u32::from_le_bytes(random);
111            if sample >= ACCEPT_BELOW {
112                continue;
113            }
114            let digits = format!("{:08}", sample % RANGE);
115            if is_trivial_setup_code(digits.as_bytes()) {
116                continue;
117            }
118            return Self::parse(&format!(
119                "{}-{}-{}",
120                &digits[..3],
121                &digits[3..5],
122                &digits[5..]
123            ));
124        }
125    }
126}
127
128impl std::fmt::Debug for SetupCode {
129    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        formatter.write_str("SetupCode([REDACTED])")
131    }
132}
133
134fn is_trivial_setup_code(digits: &[u8]) -> bool {
135    digits == b"12345678"
136        || digits == b"87654321"
137        || (digits.len() == 8 && digits.iter().all(|digit| *digit == digits[0]))
138}
139
140/// Result of opening an existing store or provisioning a new one.
141pub struct PairingStoreProvisioning {
142    pub store: PairingStore,
143    /// Present only on first creation. It is never recoverable from the store.
144    pub setup_code: Option<SetupCode>,
145}
146
147#[derive(Clone, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149struct StoredAccessory {
150    device_id: String,
151    signing_seed: [u8; 32],
152}
153
154// Manual, redacted impl: `signing_seed` is the accessory's permanent Ed25519
155// identity key, used to sign every Pair-Setup/Pair-Verify transcript for the
156// device's whole lifetime with no rotation mechanism. A derived `Debug` would
157// print it in plaintext the first time anything formats this struct (a log
158// line, a panic message) — same rationale as `SetupCode`'s manual impl below.
159impl std::fmt::Debug for StoredAccessory {
160    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        formatter
162            .debug_struct("StoredAccessory")
163            .field("device_id", &self.device_id)
164            .field("signing_seed", &"[REDACTED]")
165            .finish()
166    }
167}
168
169#[derive(Clone, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171struct StoredSetup {
172    salt: [u8; 16],
173    verifier: Vec<u8>,
174}
175
176// Manual, redacted impl: `salt`/`verifier` are the SRP-6a material derived
177// from the setup code. Printing them would hand an attacker exactly what an
178// offline dictionary attack against the (8-digit) setup code needs.
179impl std::fmt::Debug for StoredSetup {
180    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        formatter.write_str("StoredSetup([REDACTED])")
182    }
183}
184
185impl Drop for StoredAccessory {
186    fn drop(&mut self) {
187        self.signing_seed.zeroize();
188    }
189}
190
191impl Drop for StoredSetup {
192    fn drop(&mut self) {
193        self.salt.zeroize();
194        self.verifier.zeroize();
195    }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200struct PairingFile {
201    version: u32,
202    accessory: StoredAccessory,
203    setup: StoredSetup,
204    controllers: Vec<ControllerPairing>,
205}
206
207#[derive(Debug, Clone)]
208struct StoreState {
209    accessory: StoredAccessory,
210    setup: StoredSetup,
211    controllers: BTreeMap<String, ControllerPairing>,
212}
213
214/// Thread-safe, atomically persisted HAP security store.
215#[derive(Debug)]
216pub struct PairingStore {
217    path: PathBuf,
218    state: RwLock<StoreState>,
219    pair_setup_active: AtomicBool,
220    pair_setup_failures: AtomicU16,
221    changes: watch::Sender<u64>,
222}
223
224impl PairingStore {
225    /// Open an existing v2 store.
226    pub fn open(path: impl Into<PathBuf>) -> Result<Self, HapError> {
227        let path = path.into();
228        if !path.exists() {
229            return Err(HapError::PairingStore(format!(
230                "{} does not exist; use PairingStore::load_or_create for first provisioning",
231                path.display()
232            )));
233        }
234        Self::from_state(path.clone(), load_file(&path)?)
235    }
236
237    /// Open a store, or securely create identity/setup material and return the
238    /// one-time setup code.
239    pub fn load_or_create(path: impl Into<PathBuf>) -> Result<PairingStoreProvisioning, HapError> {
240        let path = path.into();
241        if path.exists() {
242            return Ok(PairingStoreProvisioning {
243                store: Self::open(path)?,
244                setup_code: None,
245            });
246        }
247        let setup_code = SetupCode::generate()?;
248        let state = new_state(&setup_code, None)?;
249        persist_file(&path, &state, false)?;
250        Ok(PairingStoreProvisioning {
251            store: Self::from_state(path, state)?,
252            setup_code: Some(setup_code),
253        })
254    }
255
256    /// Deterministic provisioning entry point for an externally generated
257    /// per-accessory setup code and optional device ID.
258    pub fn create(
259        path: impl Into<PathBuf>,
260        setup_code: SetupCode,
261        device_id: Option<String>,
262    ) -> Result<Self, HapError> {
263        let path = path.into();
264        if path.exists() {
265            return Err(HapError::PairingStore(format!(
266                "{} already exists",
267                path.display()
268            )));
269        }
270        let state = new_state(&setup_code, device_id)?;
271        persist_file(&path, &state, false)?;
272        Self::from_state(path, state)
273    }
274
275    fn from_state(path: PathBuf, state: StoreState) -> Result<Self, HapError> {
276        validate_state(&state)?;
277        let (changes, _) = watch::channel(0);
278        Ok(Self {
279            path,
280            state: RwLock::new(state),
281            pair_setup_active: AtomicBool::new(false),
282            pair_setup_failures: AtomicU16::new(0),
283            changes,
284        })
285    }
286
287    pub fn path(&self) -> &Path {
288        &self.path
289    }
290
291    pub fn accessory_id(&self) -> Result<String, HapError> {
292        Ok(self.read_state()?.accessory.device_id.clone())
293    }
294
295    pub fn accessory_public_key(&self) -> Result<[u8; 32], HapError> {
296        Ok(self.signing_key()?.verifying_key().to_bytes())
297    }
298
299    pub fn list(&self) -> Result<Vec<ControllerPairing>, HapError> {
300        Ok(self.read_state()?.controllers.values().cloned().collect())
301    }
302
303    pub fn get(&self, controller_id: &str) -> Result<Option<ControllerPairing>, HapError> {
304        Ok(self.read_state()?.controllers.get(controller_id).cloned())
305    }
306
307    pub fn is_paired(&self) -> Result<bool, HapError> {
308        Ok(!self.read_state()?.controllers.is_empty())
309    }
310
311    /// Add the first administrator after a fully authenticated Pair-Setup M5.
312    pub(crate) fn add_initial(&self, pairing: ControllerPairing) -> Result<(), HapError> {
313        pairing.validate()?;
314        if !pairing.admin {
315            return Err(HapError::InvalidPairingRecord(
316                "the first controller must be an administrator".into(),
317            ));
318        }
319        self.update(|state| {
320            if !state.controllers.is_empty() {
321                return Err(HapError::PairingAlreadyExists(
322                    pairing.controller_id.clone(),
323                ));
324            }
325            state
326                .controllers
327                .insert(pairing.controller_id.clone(), pairing);
328            Ok(())
329        })?;
330        self.pair_setup_failures.store(0, Ordering::Release);
331        Ok(())
332    }
333
334    /// Add or update a pairing according to HAP Add Pairing semantics.
335    pub(crate) fn upsert(&self, pairing: ControllerPairing) -> Result<(), HapError> {
336        pairing.validate()?;
337        self.update(|state| {
338            if let Some(existing) = state.controllers.get(&pairing.controller_id) {
339                if existing.public_key != pairing.public_key {
340                    return Err(HapError::InvalidPairingRecord(
341                        "existing pairing public key cannot be replaced".into(),
342                    ));
343                }
344            } else if state.controllers.len() >= MAX_CONTROLLERS {
345                return Err(HapError::PairingCapacity);
346            }
347            state
348                .controllers
349                .insert(pairing.controller_id.clone(), pairing);
350            Ok(())
351        })
352    }
353
354    /// Remove a pairing idempotently. Removing the final administrator clears
355    /// every pairing as required by HAP.
356    pub(crate) fn remove_hap(&self, controller_id: &str) -> Result<bool, HapError> {
357        let mut removed = false;
358        self.update(|state| {
359            removed = state.controllers.remove(controller_id).is_some();
360            if removed
361                && !state.controllers.is_empty()
362                && !state.controllers.values().any(|pairing| pairing.admin)
363            {
364                state.controllers.clear();
365            }
366            Ok(())
367        })?;
368        Ok(removed)
369    }
370
371    pub(crate) fn signing_key(&self) -> Result<SigningKey, HapError> {
372        Ok(SigningKey::from_bytes(
373            &self.read_state()?.accessory.signing_seed,
374        ))
375    }
376
377    pub(crate) fn setup_record(&self) -> Result<([u8; 16], Vec<u8>), HapError> {
378        let state = self.read_state()?;
379        Ok((state.setup.salt, state.setup.verifier.clone()))
380    }
381
382    pub(crate) fn try_begin_pair_setup(&self) -> bool {
383        self.pair_setup_active
384            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
385            .is_ok()
386    }
387
388    pub(crate) fn end_pair_setup(&self) {
389        self.pair_setup_active.store(false, Ordering::Release);
390    }
391
392    pub(crate) fn pair_setup_locked_out(&self) -> bool {
393        self.pair_setup_failures.load(Ordering::Acquire) >= MAX_PAIR_SETUP_FAILURES
394    }
395
396    pub(crate) fn record_pair_setup_failure(&self) {
397        let _ = self.pair_setup_failures.fetch_update(
398            Ordering::AcqRel,
399            Ordering::Acquire,
400            |attempts| Some(attempts.saturating_add(1)),
401        );
402    }
403
404    pub(crate) fn subscribe_changes(&self) -> watch::Receiver<u64> {
405        self.changes.subscribe()
406    }
407
408    fn read_state(&self) -> Result<std::sync::RwLockReadGuard<'_, StoreState>, HapError> {
409        self.state
410            .read()
411            .map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))
412    }
413
414    fn update(
415        &self,
416        mutate: impl FnOnce(&mut StoreState) -> Result<(), HapError>,
417    ) -> Result<(), HapError> {
418        let mut guard = self
419            .state
420            .write()
421            .map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
422        let mut next = guard.clone();
423        mutate(&mut next)?;
424        validate_state(&next)?;
425        persist_file(&self.path, &next, true)?;
426        *guard = next;
427        self.changes.send_modify(|revision| *revision += 1);
428        Ok(())
429    }
430}
431
432fn new_state(setup_code: &SetupCode, device_id: Option<String>) -> Result<StoreState, HapError> {
433    let mut signing_seed = [0u8; 32];
434    let mut salt = [0u8; 16];
435    getrandom::getrandom(&mut signing_seed)
436        .and_then(|_| getrandom::getrandom(&mut salt))
437        .map_err(|error| HapError::PairingStore(format!("generate accessory identity: {error}")))?;
438    let device_id = match device_id {
439        Some(device_id) => device_id,
440        None => generate_device_id()?,
441    };
442    let verifier =
443        ClientG3072::<Sha512>::new().compute_verifier(SRP_USERNAME, setup_code.as_bytes(), &salt);
444    let state = StoreState {
445        accessory: StoredAccessory {
446            device_id,
447            signing_seed,
448        },
449        setup: StoredSetup { salt, verifier },
450        controllers: BTreeMap::new(),
451    };
452    validate_state(&state)?;
453    Ok(state)
454}
455
456fn generate_device_id() -> Result<String, HapError> {
457    let mut bytes = [0u8; 6];
458    getrandom::getrandom(&mut bytes)
459        .map_err(|error| HapError::PairingStore(format!("generate device ID: {error}")))?;
460    Ok(bytes
461        .iter()
462        .map(|byte| format!("{byte:02X}"))
463        .collect::<Vec<_>>()
464        .join(":"))
465}
466
467fn validate_device_id(device_id: &str) -> Result<(), HapError> {
468    let parts: Vec<&str> = device_id.split(':').collect();
469    if parts.len() != 6
470        || parts
471            .iter()
472            .any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
473    {
474        return Err(HapError::InvalidPairingRecord(
475            "accessory device ID must be six colon-separated hexadecimal octets".into(),
476        ));
477    }
478    Ok(())
479}
480
481fn validate_state(state: &StoreState) -> Result<(), HapError> {
482    validate_device_id(&state.accessory.device_id)?;
483    SigningKey::from_bytes(&state.accessory.signing_seed);
484    if state.setup.verifier.len() != 384 {
485        return Err(HapError::InvalidPairingRecord(
486            "SRP verifier must contain exactly 384 bytes".into(),
487        ));
488    }
489    if state.controllers.len() > MAX_CONTROLLERS {
490        return Err(HapError::InvalidPairingRecord(
491            "too many persisted controller pairings".into(),
492        ));
493    }
494    for (id, pairing) in &state.controllers {
495        pairing.validate()?;
496        if id != &pairing.controller_id {
497            return Err(HapError::InvalidPairingRecord(
498                "controller map key does not match identifier".into(),
499            ));
500        }
501    }
502    if !state.controllers.is_empty() && !state.controllers.values().any(|pairing| pairing.admin) {
503        return Err(HapError::InvalidPairingRecord(
504            "persisted pairings have no administrator".into(),
505        ));
506    }
507    Ok(())
508}
509
510fn load_file(path: &Path) -> Result<StoreState, HapError> {
511    let metadata = fs::symlink_metadata(path)
512        .map_err(|error| HapError::PairingStore(format!("metadata {}: {error}", path.display())))?;
513    if metadata.file_type().is_symlink() || !metadata.is_file() {
514        return Err(HapError::PairingStore(format!(
515            "{} must be a regular, non-symlink file",
516            path.display()
517        )));
518    }
519    if metadata.len() > MAX_STORE_BYTES {
520        return Err(HapError::PairingStore(format!(
521            "{} exceeds the {MAX_STORE_BYTES}-byte limit",
522            path.display()
523        )));
524    }
525    validate_permissions(path, &metadata)?;
526    let mut bytes = Zeroizing::new(Vec::with_capacity(metadata.len() as usize));
527    File::open(path)
528        .and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(bytes.as_mut()))
529        .map_err(|error| HapError::PairingStore(format!("read {}: {error}", path.display())))?;
530    if bytes.len() as u64 > MAX_STORE_BYTES {
531        return Err(HapError::PairingStore(
532            "pairing store exceeds size limit".into(),
533        ));
534    }
535    let file: PairingFile = serde_json::from_slice(bytes.as_slice())
536        .map_err(|error| HapError::PairingStore(format!("parse {}: {error}", path.display())))?;
537    if file.version != STORE_VERSION {
538        return Err(HapError::PairingStore(format!(
539            "unsupported pairing store version {}; v1 controller-only stores cannot be used because they lack accessory identity and setup material",
540            file.version
541        )));
542    }
543    let mut controllers = BTreeMap::new();
544    for pairing in file.controllers {
545        pairing.validate()?;
546        let id = pairing.controller_id.clone();
547        if controllers.insert(id.clone(), pairing).is_some() {
548            return Err(HapError::InvalidPairingRecord(format!(
549                "duplicate controller_id {id}"
550            )));
551        }
552    }
553    let state = StoreState {
554        accessory: file.accessory,
555        setup: file.setup,
556        controllers,
557    };
558    validate_state(&state)?;
559    Ok(state)
560}
561
562fn persist_file(path: &Path, state: &StoreState, overwrite: bool) -> Result<(), HapError> {
563    let parent = path
564        .parent()
565        .filter(|parent| !parent.as_os_str().is_empty())
566        .unwrap_or(Path::new("."));
567    create_private_dir(parent)?;
568    let payload = Zeroizing::new(
569        serde_json::to_vec_pretty(&PairingFile {
570            version: STORE_VERSION,
571            accessory: state.accessory.clone(),
572            setup: state.setup.clone(),
573            controllers: state.controllers.values().cloned().collect(),
574        })
575        .map_err(|error| HapError::PairingStore(format!("serialize pairings: {error}")))?,
576    );
577    let mut temp = Builder::new()
578        .prefix(".homecore-hap-security-")
579        .tempfile_in(parent)
580        .map_err(|error| HapError::PairingStore(format!("create temporary store: {error}")))?;
581    set_private_file_permissions(temp.as_file())?;
582    temp.write_all(&payload)
583        .and_then(|_| temp.flush())
584        .and_then(|_| temp.as_file().sync_all())
585        .map_err(|error| HapError::PairingStore(format!("write temporary store: {error}")))?;
586    if overwrite {
587        temp.persist(path).map_err(|error| {
588            HapError::PairingStore(format!("replace {}: {}", path.display(), error.error))
589        })?;
590    } else {
591        temp.persist_noclobber(path).map_err(|error| {
592            HapError::PairingStore(format!("create {}: {}", path.display(), error.error))
593        })?;
594    }
595    #[cfg(unix)]
596    File::open(parent)
597        .and_then(|directory| directory.sync_all())
598        .map_err(|error| HapError::PairingStore(format!("sync {}: {error}", parent.display())))?;
599    Ok(())
600}
601
602fn create_private_dir(path: &Path) -> Result<(), HapError> {
603    let created = !path.exists();
604    if created {
605        fs::create_dir_all(path).map_err(|error| {
606            HapError::PairingStore(format!("create {}: {error}", path.display()))
607        })?;
608    }
609    #[cfg(unix)]
610    if created {
611        use std::os::unix::fs::PermissionsExt;
612        fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
613            HapError::PairingStore(format!("chmod {}: {error}", path.display()))
614        })?;
615    }
616    Ok(())
617}
618
619fn set_private_file_permissions(_file: &File) -> Result<(), HapError> {
620    #[cfg(unix)]
621    {
622        use std::os::unix::fs::PermissionsExt;
623        _file
624            .set_permissions(fs::Permissions::from_mode(0o600))
625            .map_err(|error| HapError::PairingStore(format!("chmod temporary store: {error}")))?;
626    }
627    Ok(())
628}
629
630fn validate_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), HapError> {
631    #[cfg(unix)]
632    {
633        use std::os::unix::fs::PermissionsExt;
634        let mode = metadata.permissions().mode();
635        if mode & 0o077 != 0 {
636            return Err(HapError::InsecurePermissions {
637                path: path.to_path_buf(),
638                mode: mode & 0o777,
639            });
640        }
641    }
642    let _ = (path, metadata);
643    Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    fn store(directory: &tempfile::TempDir) -> PairingStore {
651        PairingStore::create(
652            directory.path().join("pairings.json"),
653            SetupCode::parse("518-26-003").unwrap(),
654            Some("AA:BB:CC:DD:EE:FF".into()),
655        )
656        .unwrap()
657    }
658
659    fn pairing(id: &str, byte: u8, admin: bool) -> ControllerPairing {
660        ControllerPairing {
661            controller_id: id.into(),
662            public_key: SigningKey::from_bytes(&[byte; 32])
663                .verifying_key()
664                .to_bytes(),
665            admin,
666        }
667    }
668
669    #[test]
670    fn first_provisioning_returns_code_but_restart_does_not() {
671        let directory = tempfile::tempdir().unwrap();
672        let path = directory.path().join("pairings.json");
673        let provisioned = PairingStore::load_or_create(&path).unwrap();
674        let id = provisioned.store.accessory_id().unwrap();
675        assert!(provisioned.setup_code.is_some());
676        drop(provisioned);
677        let reopened = PairingStore::load_or_create(path).unwrap();
678        assert!(reopened.setup_code.is_none());
679        assert_eq!(reopened.store.accessory_id().unwrap(), id);
680    }
681
682    #[test]
683    fn identity_and_pairings_survive_atomic_restart() {
684        let directory = tempfile::tempdir().unwrap();
685        let path = directory.path().join("pairings.json");
686        let store = store(&directory);
687        let public_key = store.accessory_public_key().unwrap();
688        store.add_initial(pairing("controller-1", 7, true)).unwrap();
689        drop(store);
690        let reopened = PairingStore::open(path).unwrap();
691        assert_eq!(reopened.accessory_public_key().unwrap(), public_key);
692        assert_eq!(
693            reopened.list().unwrap(),
694            vec![pairing("controller-1", 7, true)]
695        );
696    }
697
698    #[test]
699    fn prohibited_setup_codes_are_rejected_and_debug_is_redacted() {
700        assert!(SetupCode::parse("111-11-111").is_err());
701        assert!(SetupCode::parse("123-45-678").is_err());
702        let code = SetupCode::parse("518-26-003").unwrap();
703        assert_eq!(format!("{code:?}"), "SetupCode([REDACTED])");
704    }
705
706    /// A stray `format!("{store:?}")` (a future debug log line, a panic
707    /// message) must never print the accessory's permanent Ed25519 signing
708    /// seed or the SRP salt/verifier — both are compromise-forever secrets
709    /// with no rotation mechanism.
710    #[test]
711    fn stored_accessory_and_setup_debug_never_print_secret_material() {
712        let accessory = StoredAccessory {
713            device_id: "AA:BB:CC:DD:EE:FF".into(),
714            signing_seed: [0x42; 32],
715        };
716        let rendered = format!("{accessory:?}");
717        assert!(rendered.contains("device_id"));
718        assert!(rendered.contains("AA:BB:CC:DD:EE:FF"));
719        assert!(!rendered.contains("66"), "hex of 0x42 must not leak: {rendered}");
720        assert_eq!(
721            rendered,
722            "StoredAccessory { device_id: \"AA:BB:CC:DD:EE:FF\", signing_seed: \"[REDACTED]\" }"
723        );
724
725        let setup = StoredSetup { salt: [0x7a; 16], verifier: vec![0x13; 8] };
726        assert_eq!(format!("{setup:?}"), "StoredSetup([REDACTED])");
727
728        // The redaction must propagate through every derived-Debug container
729        // that embeds these structs, with no further code changes needed.
730        let state = StoreState {
731            accessory,
732            setup,
733            controllers: std::collections::BTreeMap::new(),
734        };
735        let rendered_state = format!("{state:?}");
736        assert!(!rendered_state.contains("0x42"));
737        assert!(rendered_state.contains("[REDACTED]"));
738    }
739
740    #[test]
741    fn removing_last_admin_clears_all_pairings() {
742        let directory = tempfile::tempdir().unwrap();
743        let store = store(&directory);
744        store.add_initial(pairing("admin", 1, true)).unwrap();
745        store.upsert(pairing("member", 2, false)).unwrap();
746        assert!(store.remove_hap("admin").unwrap());
747        assert!(store.list().unwrap().is_empty());
748    }
749
750    #[test]
751    fn malformed_or_legacy_records_fail_closed() {
752        let directory = tempfile::tempdir().unwrap();
753        let path = directory.path().join("pairings.json");
754        fs::write(&path, br#"{"version":1,"controllers":[]}"#).unwrap();
755        #[cfg(unix)]
756        {
757            use std::os::unix::fs::PermissionsExt;
758            fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
759        }
760        assert!(PairingStore::open(path).is_err());
761    }
762
763    #[cfg(unix)]
764    #[test]
765    fn permissive_existing_file_is_rejected() {
766        use std::os::unix::fs::PermissionsExt;
767        let directory = tempfile::tempdir().unwrap();
768        let path = directory.path().join("pairings.json");
769        let _ = store(&directory);
770        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
771        assert!(matches!(
772            PairingStore::open(path),
773            Err(HapError::InsecurePermissions { .. })
774        ));
775    }
776}