Skip to main content

igc_net/
keys.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use aes_gcm::aead::{Aead, KeyInit, Payload};
5use aes_gcm::{Aes256Gcm, Nonce};
6use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
7use argon2::{Algorithm, Argon2, Params, Version};
8use hkdf::Hkdf;
9use serde::{Deserialize, Serialize};
10use sha2::Sha256;
11use zeroize::{Zeroize, ZeroizeOnDrop};
12
13use crate::id::PilotId;
14use crate::identity::DidKey;
15use crate::util::write_json_file_atomic as write_json_file_atomic_impl;
16
17const PILOT_KEYS_DIRNAME: &str = "pilot-keys";
18const PILOT_CREDENTIALS_DIRNAME: &str = "pilot-credentials";
19const PILOT_ID_FILENAME: &str = "pilot_id.json";
20const PILOT_PROFILE_FILENAME: &str = "profile.json";
21const PILOT_CREDENTIAL_FILENAME: &str = "credential.json";
22const ACTIVE_PILOT_AUTH_DIRNAME: &str = "pilot_auth";
23const ACTIVE_PILOT_AUTH_FILENAME: &str = "current.json";
24const ARCHIVE_DIRNAME: &str = "archive";
25const KEY_FILE_SCHEMA: &str = "igc-net/pilot-key-file";
26const KEY_FILE_VERSION: u8 = 1;
27const NONCE_LEN: usize = 12;
28const HKDF_LABEL: &[u8] = b"igc-net-pilot-keys-v1";
29
30#[derive(Debug, thiserror::Error)]
31pub enum PilotKeyStoreError {
32    #[error("I/O: {0}")]
33    Io(#[from] std::io::Error),
34    #[error("JSON: {0}")]
35    Json(#[from] serde_json::Error),
36    #[error("pilot key store already initialized")]
37    AlreadyInitialized,
38    #[error("pilot identity does not exist")]
39    MissingPilotIdentity,
40    #[error("pilot key store is incomplete: {0}")]
41    Incomplete(&'static str),
42    #[error("pilot key file is malformed: {0}")]
43    Malformed(&'static str),
44    #[error("pilot key file for role {found:?} does not match expected role {expected:?}")]
45    WrongRole {
46        expected: &'static str,
47        found: String,
48    },
49    #[error("pilot key file schema mismatch")]
50    SchemaMismatch,
51    #[error("pilot key file version {0} is unsupported")]
52    UnsupportedVersion(u8),
53    #[error("pilot key file could not be decrypted with this node identity")]
54    WrongNodeIdentity,
55    #[error("unsafe filesystem permissions on {path}: mode {mode:o}")]
56    UnsafePermissions { path: PathBuf, mode: u32 },
57    #[error("credential hash failed: {0}")]
58    CredentialHash(String),
59    #[error("pilot credential is malformed")]
60    MalformedCredential,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct PilotKeyStoreStatus {
65    pub root_dir: PathBuf,
66    pub pilot_id_file: PathBuf,
67    pub active_pilot_auth_file: PathBuf,
68    pub archive_dir: PathBuf,
69    pub has_pilot_id: bool,
70    pub has_active_pilot_auth: bool,
71    pub archived_key_count: usize,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct PilotPublicIdentity {
76    pub pilot_id: PilotId,
77    pub pilot_id_public_key_hex: String,
78    pub active_pilot_auth_public_key_hex: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct PilotProfile {
83    pub display_name: String,
84    pub country: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct PilotPublicIdentityWithProfile {
89    pub pilot_id: PilotId,
90    pub pilot_id_public_key_hex: String,
91    pub active_pilot_auth_public_key_hex: String,
92    pub display_name: String,
93    pub country: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct PilotCredentialFile {
98    pub argon2id_hash: String,
99    pub created_at: String,
100}
101
102pub struct PilotIdentity {
103    pilot_id_root: SensitiveKeyMaterial,
104    active_pilot_auth: SensitiveKeyMaterial,
105}
106
107impl fmt::Debug for PilotIdentity {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("PilotIdentity")
110            .field("pilot_id", &self.pilot_id())
111            .field(
112                "active_pilot_auth_public_key_hex",
113                &self.active_pilot_auth_public_key_hex(),
114            )
115            .finish()
116    }
117}
118
119impl PilotIdentity {
120    #[cfg(test)]
121    pub(crate) fn from_secret_keys(
122        pilot_id_root: iroh::SecretKey,
123        active_pilot_auth: iroh::SecretKey,
124    ) -> Self {
125        Self {
126            pilot_id_root: SensitiveKeyMaterial::from_secret_key(pilot_id_root),
127            active_pilot_auth: SensitiveKeyMaterial::from_secret_key(active_pilot_auth),
128        }
129    }
130
131    pub fn pilot_id(&self) -> PilotId {
132        PilotId::from_public_key(self.pilot_id_root.public_key())
133    }
134
135    pub fn pilot_id_public_key_hex(&self) -> String {
136        self.pilot_id_root.public_key().to_string()
137    }
138
139    pub fn active_pilot_auth_public_key_hex(&self) -> String {
140        self.active_pilot_auth.public_key().to_string()
141    }
142
143    pub fn active_pilot_auth_did(&self) -> DidKey {
144        DidKey::from_public_key(self.active_pilot_auth.public_key())
145    }
146
147    pub fn pilot_id_secret_key(&self) -> iroh::SecretKey {
148        self.pilot_id_root.secret_key()
149    }
150
151    pub fn active_pilot_auth_secret_key(&self) -> iroh::SecretKey {
152        self.active_pilot_auth.secret_key()
153    }
154
155    pub fn export_public_identity(&self) -> PilotPublicIdentity {
156        PilotPublicIdentity {
157            pilot_id: self.pilot_id(),
158            pilot_id_public_key_hex: self.pilot_id_public_key_hex(),
159            active_pilot_auth_public_key_hex: self.active_pilot_auth_public_key_hex(),
160        }
161    }
162}
163
164// ── MultiPilotKeyStore ───────────────────────────────────────────────────────
165
166#[derive(Debug, Clone)]
167pub struct MultiPilotKeyStore {
168    root: PathBuf,
169}
170
171impl MultiPilotKeyStore {
172    pub fn open(root: impl Into<PathBuf>) -> Self {
173        Self { root: root.into() }
174    }
175
176    pub fn for_data_dir(data_dir: impl AsRef<Path>) -> Self {
177        Self::open(data_dir.as_ref().join(PILOT_KEYS_DIRNAME))
178    }
179
180    pub fn root_dir(&self) -> &Path {
181        &self.root
182    }
183
184    pub fn init(&self) -> Result<(), PilotKeyStoreError> {
185        ensure_dir(self.root_dir())
186    }
187
188    pub fn generate_pilot(
189        &self,
190        display_name: impl Into<String>,
191        country: Option<String>,
192        node_secret_key: &iroh::SecretKey,
193    ) -> Result<PilotIdentity, PilotKeyStoreError> {
194        self.init()?;
195        let profile = PilotProfile {
196            display_name: display_name.into(),
197            country,
198        };
199        validate_profile(&profile)?;
200
201        let staging_dir = self.fresh_staging_dir();
202        let staging_store = PilotKeyStore::open(&staging_dir);
203        let identity = match staging_store.generate(node_secret_key) {
204            Ok(identity) => identity,
205            Err(err) => {
206                let _ = std::fs::remove_dir_all(&staging_dir);
207                return Err(err);
208            }
209        };
210        if let Err(err) = write_profile(&profile_path(&staging_dir), &profile) {
211            let _ = std::fs::remove_dir_all(&staging_dir);
212            return Err(err);
213        }
214
215        let pilot_dir = self.pilot_dir(&identity.pilot_id());
216        if pilot_dir.exists() {
217            let _ = std::fs::remove_dir_all(&staging_dir);
218            return Err(PilotKeyStoreError::AlreadyInitialized);
219        }
220        if let Err(err) = std::fs::rename(&staging_dir, &pilot_dir) {
221            let _ = std::fs::remove_dir_all(&staging_dir);
222            return Err(PilotKeyStoreError::Io(err));
223        }
224
225        Ok(identity)
226    }
227
228    pub fn load_pilot(
229        &self,
230        pilot_id: &PilotId,
231        node_secret_key: &iroh::SecretKey,
232    ) -> Result<Option<PilotIdentity>, PilotKeyStoreError> {
233        let pilot_dir = self.pilot_dir(pilot_id);
234        if !pilot_dir.exists() {
235            return Ok(None);
236        }
237        let identity = PilotKeyStore::open(pilot_dir).load(node_secret_key)?;
238        match identity {
239            Some(identity) if identity.pilot_id() == *pilot_id => Ok(Some(identity)),
240            Some(_) => Err(PilotKeyStoreError::Malformed(
241                "pilot directory does not match encrypted pilot_id",
242            )),
243            None => Err(PilotKeyStoreError::Incomplete(
244                "pilot directory exists without pilot identity",
245            )),
246        }
247    }
248
249    pub fn list_pilots(
250        &self,
251        node_secret_key: &iroh::SecretKey,
252    ) -> Result<Vec<PilotPublicIdentityWithProfile>, PilotKeyStoreError> {
253        self.init()?;
254        let mut pilots = Vec::new();
255        for entry in std::fs::read_dir(&self.root)? {
256            let entry = entry?;
257            if !entry.file_type()?.is_dir() {
258                continue;
259            }
260            let name = entry
261                .file_name()
262                .into_string()
263                .map_err(|_| PilotKeyStoreError::Malformed("pilot key directory must be UTF-8"))?;
264            if name.starts_with('.') || name == ACTIVE_PILOT_AUTH_DIRNAME {
265                continue;
266            }
267            let pilot_id =
268                PilotId::parse(format!("{}{}", PilotId::PREFIX, name)).map_err(|_| {
269                    PilotKeyStoreError::Malformed("pilot directory must be 32-byte lowercase hex")
270                })?;
271            let identity = self
272                .load_pilot(&pilot_id, node_secret_key)?
273                .ok_or(PilotKeyStoreError::MissingPilotIdentity)?;
274            let profile = read_profile(&profile_path(&entry.path()))?;
275            let public = identity.export_public_identity();
276            pilots.push(PilotPublicIdentityWithProfile {
277                pilot_id: public.pilot_id,
278                pilot_id_public_key_hex: public.pilot_id_public_key_hex,
279                active_pilot_auth_public_key_hex: public.active_pilot_auth_public_key_hex,
280                display_name: profile.display_name,
281                country: profile.country,
282            });
283        }
284        pilots.sort_by(|left, right| left.pilot_id.as_str().cmp(right.pilot_id.as_str()));
285        Ok(pilots)
286    }
287
288    pub fn pilot_store(&self, pilot_id: &PilotId) -> PilotKeyStore {
289        PilotKeyStore::open(self.pilot_dir(pilot_id))
290    }
291
292    pub fn load_profile(
293        &self,
294        pilot_id: &PilotId,
295    ) -> Result<Option<PilotProfile>, PilotKeyStoreError> {
296        let path = profile_path(&self.pilot_dir(pilot_id));
297        if !path.exists() {
298            return Ok(None);
299        }
300        Ok(Some(read_profile(&path)?))
301    }
302
303    fn pilot_dir(&self, pilot_id: &PilotId) -> PathBuf {
304        self.root.join(pilot_id.public_key_hex())
305    }
306
307    fn fresh_staging_dir(&self) -> PathBuf {
308        loop {
309            let path = self.root.join(format!(
310                ".new-pilot-{}-{}",
311                std::process::id(),
312                rand::random::<u64>()
313            ));
314            if !path.exists() {
315                return path;
316            }
317        }
318    }
319}
320
321fn validate_profile(profile: &PilotProfile) -> Result<(), PilotKeyStoreError> {
322    if profile.display_name.trim().is_empty() {
323        return Err(PilotKeyStoreError::Malformed(
324            "pilot display_name must not be empty",
325        ));
326    }
327    if let Some(country) = &profile.country {
328        if !country.is_empty()
329            && !(country.len() == 2 && country.bytes().all(|b| b.is_ascii_uppercase()))
330        {
331            return Err(PilotKeyStoreError::Malformed(
332                "pilot country must be ISO 3166-1 alpha-2 uppercase",
333            ));
334        }
335    }
336    Ok(())
337}
338
339fn profile_path(root: &Path) -> PathBuf {
340    root.join(PILOT_PROFILE_FILENAME)
341}
342
343fn write_profile(path: &Path, profile: &PilotProfile) -> Result<(), PilotKeyStoreError> {
344    validate_profile(profile)?;
345    write_json_file_atomic(path, profile)
346}
347
348fn read_profile(path: &Path) -> Result<PilotProfile, PilotKeyStoreError> {
349    if !path.exists() {
350        return Err(PilotKeyStoreError::Incomplete("pilot profile is missing"));
351    }
352    ensure_file_permissions(path)?;
353    let profile: PilotProfile = serde_json::from_slice(&std::fs::read(path)?)?;
354    validate_profile(&profile)?;
355    Ok(profile)
356}
357
358// ── PilotCredentialStore ─────────────────────────────────────────────────────
359
360#[derive(Debug, Clone)]
361pub struct PilotCredentialStore {
362    root: PathBuf,
363}
364
365impl PilotCredentialStore {
366    pub fn open(root: impl Into<PathBuf>) -> Self {
367        Self { root: root.into() }
368    }
369
370    pub fn for_data_dir(data_dir: impl AsRef<Path>) -> Self {
371        Self::open(data_dir.as_ref().join(PILOT_CREDENTIALS_DIRNAME))
372    }
373
374    pub fn init(&self) -> Result<(), PilotKeyStoreError> {
375        ensure_dir(&self.root)
376    }
377
378    pub fn set_credential(
379        &self,
380        pilot_id: &PilotId,
381        access_pin: &str,
382    ) -> Result<(), PilotKeyStoreError> {
383        if access_pin.is_empty() {
384            return Err(PilotKeyStoreError::Malformed(
385                "pilot access_pin must not be empty",
386            ));
387        }
388        self.init()?;
389        ensure_dir(&self.pilot_dir(pilot_id))?;
390        let file = PilotCredentialFile {
391            argon2id_hash: hash_access_pin(access_pin)?,
392            created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
393        };
394        write_json_file_atomic(&self.credential_path(pilot_id), &file)
395    }
396
397    pub fn verify_credential(
398        &self,
399        pilot_id: &PilotId,
400        access_pin: &str,
401    ) -> Result<bool, PilotKeyStoreError> {
402        if access_pin.is_empty() {
403            return Ok(false);
404        }
405        let path = self.credential_path(pilot_id);
406        if !path.exists() {
407            return Ok(false);
408        }
409        ensure_file_permissions(&path)?;
410        let file: PilotCredentialFile = serde_json::from_slice(&std::fs::read(path)?)?;
411        let parsed = PasswordHash::new(&file.argon2id_hash)
412            .map_err(|_| PilotKeyStoreError::MalformedCredential)?;
413        match argon2id().verify_password(access_pin.as_bytes(), &parsed) {
414            Ok(()) => Ok(true),
415            Err(argon2::password_hash::Error::Password) => Ok(false),
416            Err(_) => Err(PilotKeyStoreError::MalformedCredential),
417        }
418    }
419
420    fn pilot_dir(&self, pilot_id: &PilotId) -> PathBuf {
421        self.root.join(pilot_id.public_key_hex())
422    }
423
424    fn credential_path(&self, pilot_id: &PilotId) -> PathBuf {
425        self.pilot_dir(pilot_id).join(PILOT_CREDENTIAL_FILENAME)
426    }
427}
428
429fn hash_access_pin(access_pin: &str) -> Result<String, PilotKeyStoreError> {
430    let mut salt_bytes = [0u8; 16];
431    rand::fill(&mut salt_bytes);
432    let salt = SaltString::encode_b64(&salt_bytes)
433        .map_err(|err| PilotKeyStoreError::CredentialHash(err.to_string()))?;
434    let hash = argon2id()
435        .hash_password(access_pin.as_bytes(), &salt)
436        .map_err(|err| PilotKeyStoreError::CredentialHash(err.to_string()))?;
437    Ok(hash.to_string())
438}
439
440fn argon2id() -> Argon2<'static> {
441    Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default())
442}
443
444// ── PilotKeyStore ────────────────────────────────────────────────────────────
445
446#[derive(Debug, Clone)]
447pub struct PilotKeyStore {
448    root: PathBuf,
449}
450
451impl PilotKeyStore {
452    pub fn open(root: impl Into<PathBuf>) -> Self {
453        Self { root: root.into() }
454    }
455
456    pub fn root_dir(&self) -> &Path {
457        &self.root
458    }
459
460    pub fn init(&self) -> Result<(), PilotKeyStoreError> {
461        ensure_dir(self.root_dir())?;
462        ensure_dir(&self.active_pilot_auth_dir())?;
463        ensure_dir(&self.archive_dir())?;
464        Ok(())
465    }
466
467    pub fn inspect(&self) -> Result<PilotKeyStoreStatus, PilotKeyStoreError> {
468        self.init()?;
469        self.inspect_initialized()
470    }
471
472    fn inspect_initialized(&self) -> Result<PilotKeyStoreStatus, PilotKeyStoreError> {
473        if self.pilot_id_path().exists() {
474            ensure_file_permissions(&self.pilot_id_path())?;
475        }
476        if self.active_pilot_auth_path().exists() {
477            ensure_file_permissions(&self.active_pilot_auth_path())?;
478        }
479        Ok(PilotKeyStoreStatus {
480            root_dir: self.root.clone(),
481            pilot_id_file: self.pilot_id_path(),
482            active_pilot_auth_file: self.active_pilot_auth_path(),
483            archive_dir: self.archive_dir(),
484            has_pilot_id: self.pilot_id_path().exists(),
485            has_active_pilot_auth: self.active_pilot_auth_path().exists(),
486            archived_key_count: count_regular_files(&self.archive_dir())?,
487        })
488    }
489
490    pub fn load(
491        &self,
492        node_secret_key: &iroh::SecretKey,
493    ) -> Result<Option<PilotIdentity>, PilotKeyStoreError> {
494        self.init()?;
495        let has_pilot_id = self.pilot_id_path().exists();
496        let has_active_pilot_auth = self.active_pilot_auth_path().exists();
497
498        match (has_pilot_id, has_active_pilot_auth) {
499            (false, false) => Ok(None),
500            (true, true) => {
501                let node_sealing_key = derive_sealing_key(node_secret_key);
502                let pilot_id_root = read_encrypted_secret_key(
503                    &self.pilot_id_path(),
504                    "pilot_id",
505                    &node_sealing_key,
506                )?;
507                let active_pilot_auth = read_encrypted_secret_key(
508                    &self.active_pilot_auth_path(),
509                    "pilot_auth",
510                    &node_sealing_key,
511                )?;
512                Ok(Some(PilotIdentity {
513                    pilot_id_root,
514                    active_pilot_auth,
515                }))
516            }
517            _ => Err(PilotKeyStoreError::Incomplete(
518                "both pilot_id and active pilot_auth files must exist",
519            )),
520        }
521    }
522
523    pub fn generate(
524        &self,
525        node_secret_key: &iroh::SecretKey,
526    ) -> Result<PilotIdentity, PilotKeyStoreError> {
527        self.init()?;
528        let status = self.inspect_initialized()?;
529        if status.has_pilot_id || status.has_active_pilot_auth {
530            return Err(PilotKeyStoreError::AlreadyInitialized);
531        }
532
533        let mut rng = rand::rng();
534        let node_key_bytes = node_secret_key.to_bytes();
535        let pilot_id_root = generate_distinct_secret_key(&mut rng, &[&node_key_bytes]);
536        let active_pilot_auth =
537            generate_distinct_secret_key(&mut rng, &[&node_key_bytes, pilot_id_root.as_bytes()]);
538
539        let node_sealing_key = derive_sealing_key(node_secret_key);
540        write_encrypted_secret_key(
541            &self.pilot_id_path(),
542            "pilot_id",
543            &pilot_id_root,
544            &node_sealing_key,
545        )?;
546        write_encrypted_secret_key(
547            &self.active_pilot_auth_path(),
548            "pilot_auth",
549            &active_pilot_auth,
550            &node_sealing_key,
551        )?;
552
553        Ok(PilotIdentity {
554            pilot_id_root,
555            active_pilot_auth,
556        })
557    }
558
559    pub fn export_public_identity(
560        &self,
561        node_secret_key: &iroh::SecretKey,
562    ) -> Result<Option<PilotPublicIdentity>, PilotKeyStoreError> {
563        Ok(self
564            .load(node_secret_key)?
565            .map(|identity| identity.export_public_identity()))
566    }
567
568    pub fn archived_pilot_auth_dids(&self) -> Result<Vec<DidKey>, PilotKeyStoreError> {
569        self.init()?;
570        let mut dids = Vec::new();
571        for entry in std::fs::read_dir(self.archive_dir())? {
572            let entry = entry?;
573            if !entry.file_type()?.is_file() {
574                continue;
575            }
576            let path = entry.path();
577            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
578                continue;
579            }
580            ensure_file_permissions(&path)?;
581            let public_key_hex = path.file_stem().and_then(|stem| stem.to_str()).ok_or(
582                PilotKeyStoreError::Malformed("archived pilot_auth filename must be UTF-8"),
583            )?;
584            let public_key_bytes = decode_fixed_hex::<32>(
585                public_key_hex,
586                "archived pilot_auth filename must be 32-byte lowercase hex",
587            )?;
588            let public_key = iroh::PublicKey::from_bytes(&public_key_bytes).map_err(|_| {
589                PilotKeyStoreError::Malformed(
590                    "archived pilot_auth filename must be a valid Ed25519 public key",
591                )
592            })?;
593            dids.push(DidKey::from_public_key(public_key));
594        }
595        dids.sort_by(|left, right| left.as_str().cmp(right.as_str()));
596        Ok(dids)
597    }
598
599    pub fn generate_next_active_pilot_auth_secret_key(
600        &self,
601        node_secret_key: &iroh::SecretKey,
602    ) -> Result<iroh::SecretKey, PilotKeyStoreError> {
603        let identity = self
604            .load(node_secret_key)?
605            .ok_or(PilotKeyStoreError::MissingPilotIdentity)?;
606        let mut rng = rand::rng();
607        let node_key_bytes = node_secret_key.to_bytes();
608        let next = generate_distinct_secret_key(
609            &mut rng,
610            &[
611                &node_key_bytes,
612                identity.pilot_id_root.as_bytes(),
613                identity.active_pilot_auth.as_bytes(),
614            ],
615        );
616        Ok(next.secret_key())
617    }
618
619    pub fn replace_active_pilot_auth(
620        &self,
621        node_secret_key: &iroh::SecretKey,
622        next_active_pilot_auth_secret_key: &iroh::SecretKey,
623    ) -> Result<PilotIdentity, PilotKeyStoreError> {
624        self.init()?;
625        let current_identity = self
626            .load(node_secret_key)?
627            .ok_or(PilotKeyStoreError::MissingPilotIdentity)?;
628        let next_active_pilot_auth =
629            SensitiveKeyMaterial::from_secret_key(next_active_pilot_auth_secret_key.clone());
630        let node_key_bytes = node_secret_key.to_bytes();
631        if next_active_pilot_auth.as_bytes() == current_identity.active_pilot_auth.as_bytes()
632            || next_active_pilot_auth.as_bytes() == current_identity.pilot_id_root.as_bytes()
633            || next_active_pilot_auth.as_bytes() == &node_key_bytes
634        {
635            return Err(PilotKeyStoreError::Malformed(
636                "replacement pilot_auth key must be distinct",
637            ));
638        }
639
640        let node_sealing_key = derive_sealing_key(node_secret_key);
641        let archive_path = self
642            .archived_pilot_auth_path(&current_identity.active_pilot_auth.public_key().to_string());
643        if !archive_path.exists() {
644            write_encrypted_secret_key(
645                &archive_path,
646                "pilot_auth",
647                &current_identity.active_pilot_auth,
648                &node_sealing_key,
649            )?;
650        }
651        write_encrypted_secret_key(
652            &self.active_pilot_auth_path(),
653            "pilot_auth",
654            &next_active_pilot_auth,
655            &node_sealing_key,
656        )?;
657
658        Ok(PilotIdentity {
659            pilot_id_root: current_identity.pilot_id_root,
660            active_pilot_auth: next_active_pilot_auth,
661        })
662    }
663
664    fn pilot_id_path(&self) -> PathBuf {
665        self.root.join(PILOT_ID_FILENAME)
666    }
667
668    fn active_pilot_auth_dir(&self) -> PathBuf {
669        self.root.join(ACTIVE_PILOT_AUTH_DIRNAME)
670    }
671
672    fn active_pilot_auth_path(&self) -> PathBuf {
673        self.active_pilot_auth_dir()
674            .join(ACTIVE_PILOT_AUTH_FILENAME)
675    }
676
677    fn archive_dir(&self) -> PathBuf {
678        self.active_pilot_auth_dir().join(ARCHIVE_DIRNAME)
679    }
680
681    fn archived_pilot_auth_path(&self, public_key_hex: &str) -> PathBuf {
682        self.archive_dir().join(format!("{public_key_hex}.json"))
683    }
684}
685
686// ── PrivateAccessKeyStore ─────────────────────────────────────────────────────
687
688const PRIVATE_ACCESS_KEY_DIRNAME: &str = "private-access-key";
689const PRIVATE_ACCESS_KEY_FILENAME: &str = "current.json";
690const PRIVATE_ACCESS_ROLE: &str = "private_access";
691
692/// Encrypted-at-rest store for a pilot's `private_access_keypair` private half.
693///
694/// The key is sealed with the same AES-256-GCM + HKDF envelope as `PilotKeyStore`.
695/// After a successful `provision` or `generate`, the node holds Category 2
696/// (`private-access`) capability for that pilot until `delete` is called.
697#[derive(Debug, Clone)]
698pub struct PrivateAccessKeyStore {
699    root: PathBuf,
700}
701
702impl PrivateAccessKeyStore {
703    pub fn open(root: impl Into<PathBuf>) -> Self {
704        Self { root: root.into() }
705    }
706
707    pub fn for_data_dir(data_dir: impl AsRef<Path>) -> Self {
708        Self::open(data_dir.as_ref().join(PRIVATE_ACCESS_KEY_DIRNAME))
709    }
710
711    /// Generate a fresh random `private_access_keypair` for `pilot_id` and store
712    /// the private half.
713    ///
714    /// Fails if that pilot already has a key (use `delete_for_pilot` first to
715    /// replace with an implementation-generated key).
716    pub fn generate_for_pilot(
717        &self,
718        pilot_id: &PilotId,
719        node_secret_key: &iroh::SecretKey,
720    ) -> Result<iroh::SecretKey, PilotKeyStoreError> {
721        ensure_dir(&self.root)?;
722        ensure_dir(&self.pilot_dir(pilot_id))?;
723        if self.key_path(pilot_id).exists() {
724            return Err(PilotKeyStoreError::AlreadyInitialized);
725        }
726        let mut rng = rand::rng();
727        let key = generate_distinct_secret_key(&mut rng, &[&node_secret_key.to_bytes()]);
728        let sealing_key = derive_sealing_key(node_secret_key);
729        write_encrypted_secret_key(
730            &self.key_path(pilot_id),
731            PRIVATE_ACCESS_ROLE,
732            &key,
733            &sealing_key,
734        )?;
735        Ok(key.secret_key())
736    }
737
738    /// Store an externally supplied private key for `pilot_id` (used during the
739    /// §5 key-provisioning handover).  Overwrites that pilot's previously stored
740    /// key atomically without affecting other pilots.
741    pub fn provision_for_pilot(
742        &self,
743        pilot_id: &PilotId,
744        private_key: &iroh::SecretKey,
745        node_secret_key: &iroh::SecretKey,
746    ) -> Result<(), PilotKeyStoreError> {
747        ensure_dir(&self.root)?;
748        ensure_dir(&self.pilot_dir(pilot_id))?;
749        let material = SensitiveKeyMaterial::from_secret_key(private_key.clone());
750        let sealing_key = derive_sealing_key(node_secret_key);
751        write_encrypted_secret_key(
752            &self.key_path(pilot_id),
753            PRIVATE_ACCESS_ROLE,
754            &material,
755            &sealing_key,
756        )
757    }
758
759    /// Load the stored private key for `pilot_id`.
760    ///
761    /// Returns `None` if no key has been provisioned for that pilot.
762    pub fn load_for_pilot(
763        &self,
764        pilot_id: &PilotId,
765        node_secret_key: &iroh::SecretKey,
766    ) -> Result<Option<iroh::SecretKey>, PilotKeyStoreError> {
767        let path = self.key_path(pilot_id);
768        if !path.exists() {
769            return Ok(None);
770        }
771        let sealing_key = derive_sealing_key(node_secret_key);
772        let material = read_encrypted_secret_key(&path, PRIVATE_ACCESS_ROLE, &sealing_key)?;
773        Ok(Some(material.secret_key()))
774    }
775
776    /// Load a stored private key by its public half.
777    ///
778    /// This is an interim lookup for the current gRPC POC until artifact
779    /// ownership resolution can map `raw_igc_hash` to `pilot_id` directly.
780    pub fn load_by_public_key(
781        &self,
782        public_key_hex: &str,
783        node_secret_key: &iroh::SecretKey,
784    ) -> Result<Option<(PilotId, iroh::SecretKey)>, PilotKeyStoreError> {
785        if !self.root.exists() {
786            return Ok(None);
787        }
788        ensure_dir(&self.root)?;
789        for entry in std::fs::read_dir(&self.root)? {
790            let entry = entry?;
791            if !entry.file_type()?.is_dir() {
792                continue;
793            }
794            let pilot_key_hex = entry.file_name().into_string().map_err(|_| {
795                PilotKeyStoreError::Malformed("private access pilot directory must be UTF-8")
796            })?;
797            let pilot_id = PilotId::parse(format!("{}{}", PilotId::PREFIX, pilot_key_hex))
798                .map_err(|_| {
799                    PilotKeyStoreError::Malformed(
800                        "private access pilot directory must be 32-byte lowercase hex",
801                    )
802                })?;
803            if let Some(private_key) = self.load_for_pilot(&pilot_id, node_secret_key)? {
804                if private_key.public().to_string() == public_key_hex {
805                    return Ok(Some((pilot_id, private_key)));
806                }
807            }
808        }
809        Ok(None)
810    }
811
812    /// Delete the stored key for `pilot_id` (revocation / key rotation cleanup).
813    ///
814    /// After this call the node loses Category 2 capability for this pilot.
815    /// Callers are responsible for deleting any cached restricted plaintext
816    /// per R-ACCESS-17.
817    pub fn delete_for_pilot(&self, pilot_id: &PilotId) -> Result<(), PilotKeyStoreError> {
818        let path = self.key_path(pilot_id);
819        if path.exists() {
820            std::fs::remove_file(&path)?;
821        }
822        Ok(())
823    }
824
825    fn pilot_dir(&self, pilot_id: &PilotId) -> PathBuf {
826        self.root.join(pilot_id.public_key_hex())
827    }
828
829    fn key_path(&self, pilot_id: &PilotId) -> PathBuf {
830        self.pilot_dir(pilot_id).join(PRIVATE_ACCESS_KEY_FILENAME)
831    }
832}
833
834#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
835struct SensitiveKeyMaterial([u8; 32]);
836
837impl fmt::Debug for SensitiveKeyMaterial {
838    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
839        f.write_str("SensitiveKeyMaterial(..)")
840    }
841}
842
843impl SensitiveKeyMaterial {
844    fn from_secret_key(secret_key: iroh::SecretKey) -> Self {
845        Self(secret_key.to_bytes())
846    }
847
848    fn from_bytes(bytes: [u8; 32]) -> Self {
849        Self(bytes)
850    }
851
852    fn as_bytes(&self) -> &[u8; 32] {
853        &self.0
854    }
855
856    fn secret_key(&self) -> iroh::SecretKey {
857        iroh::SecretKey::from_bytes(self.as_bytes())
858    }
859
860    fn public_key(&self) -> iroh::PublicKey {
861        self.secret_key().public()
862    }
863}
864
865#[derive(Serialize, Deserialize)]
866struct EncryptedKeyFile {
867    schema: String,
868    schema_version: u8,
869    role: String,
870    nonce: String,
871    ciphertext: String,
872}
873
874fn write_encrypted_secret_key(
875    path: &Path,
876    role: &'static str,
877    secret_key: &SensitiveKeyMaterial,
878    node_sealing_key: &[u8; 32],
879) -> Result<(), PilotKeyStoreError> {
880    let envelope = seal_secret_key(role, secret_key, node_sealing_key)?;
881    write_json_file_atomic(path, &envelope)
882}
883
884fn read_encrypted_secret_key(
885    path: &Path,
886    expected_role: &'static str,
887    node_sealing_key: &[u8; 32],
888) -> Result<SensitiveKeyMaterial, PilotKeyStoreError> {
889    ensure_file_permissions(path)?;
890    let envelope: EncryptedKeyFile = serde_json::from_slice(&std::fs::read(path)?)?;
891    unseal_secret_key(expected_role, envelope, node_sealing_key)
892}
893
894fn seal_secret_key(
895    role: &'static str,
896    secret_key: &SensitiveKeyMaterial,
897    node_sealing_key: &[u8; 32],
898) -> Result<EncryptedKeyFile, PilotKeyStoreError> {
899    let cipher = Aes256Gcm::new_from_slice(node_sealing_key)
900        .map_err(|_| PilotKeyStoreError::Malformed("invalid AES-256-GCM key"))?;
901    let mut nonce_bytes = [0u8; NONCE_LEN];
902    rand::fill(&mut nonce_bytes);
903    let aad = aad_for_role(role);
904    let ciphertext = cipher
905        .encrypt(
906            Nonce::from_slice(&nonce_bytes),
907            Payload {
908                msg: secret_key.as_bytes(),
909                aad: aad.as_bytes(),
910            },
911        )
912        .map_err(|_| PilotKeyStoreError::Malformed("encryption failed"))?;
913
914    Ok(EncryptedKeyFile {
915        schema: KEY_FILE_SCHEMA.to_string(),
916        schema_version: KEY_FILE_VERSION,
917        role: role.to_string(),
918        nonce: hex::encode(nonce_bytes),
919        ciphertext: hex::encode(ciphertext),
920    })
921}
922
923fn unseal_secret_key(
924    expected_role: &'static str,
925    envelope: EncryptedKeyFile,
926    node_sealing_key: &[u8; 32],
927) -> Result<SensitiveKeyMaterial, PilotKeyStoreError> {
928    if envelope.schema != KEY_FILE_SCHEMA {
929        return Err(PilotKeyStoreError::SchemaMismatch);
930    }
931    if envelope.schema_version != KEY_FILE_VERSION {
932        return Err(PilotKeyStoreError::UnsupportedVersion(
933            envelope.schema_version,
934        ));
935    }
936    if envelope.role != expected_role {
937        return Err(PilotKeyStoreError::WrongRole {
938            expected: expected_role,
939            found: envelope.role,
940        });
941    }
942
943    let nonce_bytes = decode_fixed_hex::<NONCE_LEN>(&envelope.nonce, "invalid nonce encoding")?;
944    let ciphertext = hex::decode(&envelope.ciphertext)
945        .map_err(|_| PilotKeyStoreError::Malformed("invalid ciphertext encoding"))?;
946    if ciphertext.is_empty() {
947        return Err(PilotKeyStoreError::Malformed(
948            "ciphertext must not be empty",
949        ));
950    }
951
952    let cipher = Aes256Gcm::new_from_slice(node_sealing_key)
953        .map_err(|_| PilotKeyStoreError::Malformed("invalid AES-256-GCM key"))?;
954    let plaintext = cipher
955        .decrypt(
956            Nonce::from_slice(&nonce_bytes),
957            Payload {
958                msg: ciphertext.as_ref(),
959                aad: aad_for_role(expected_role).as_bytes(),
960            },
961        )
962        .map_err(|_| PilotKeyStoreError::WrongNodeIdentity)?;
963
964    let secret_key_bytes: [u8; 32] = plaintext
965        .as_slice()
966        .try_into()
967        .map_err(|_| PilotKeyStoreError::Malformed("decrypted key must be 32 bytes"))?;
968    Ok(SensitiveKeyMaterial::from_bytes(secret_key_bytes))
969}
970
971fn aad_for_role(role: &str) -> String {
972    format!("{KEY_FILE_SCHEMA}:v{KEY_FILE_VERSION}:{role}")
973}
974
975fn write_json_file_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), PilotKeyStoreError> {
976    write_json_file_atomic_impl(
977        path,
978        value,
979        ensure_dir,
980        write_private_file,
981        PilotKeyStoreError::Incomplete("pilot key file path has no parent directory"),
982    )?;
983    ensure_file_permissions(path)?;
984    Ok(())
985}
986
987fn count_regular_files(path: &Path) -> Result<usize, PilotKeyStoreError> {
988    if !path.exists() {
989        return Ok(0);
990    }
991    Ok(std::fs::read_dir(path)?
992        .filter_map(Result::ok)
993        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
994        .count())
995}
996
997fn derive_sealing_key(node_secret_key: &iroh::SecretKey) -> [u8; 32] {
998    let ikm = node_secret_key.to_bytes();
999    let hkdf = Hkdf::<Sha256>::new(None, &ikm);
1000    let mut out = [0u8; 32];
1001    hkdf.expand(HKDF_LABEL, &mut out)
1002        .expect("32-byte HKDF expand is always valid for SHA-256");
1003    out
1004}
1005
1006fn generate_distinct_secret_key(
1007    rng: &mut impl rand::CryptoRng,
1008    disallowed_keys: &[&[u8; 32]],
1009) -> SensitiveKeyMaterial {
1010    loop {
1011        let candidate = SensitiveKeyMaterial::from_secret_key(iroh::SecretKey::generate(rng));
1012        if disallowed_keys
1013            .iter()
1014            .all(|other| candidate.as_bytes() != *other)
1015        {
1016            return candidate;
1017        }
1018    }
1019}
1020
1021fn decode_fixed_hex<const N: usize>(
1022    value: &str,
1023    error: &'static str,
1024) -> Result<[u8; N], PilotKeyStoreError> {
1025    let decoded = hex::decode(value).map_err(|_| PilotKeyStoreError::Malformed(error))?;
1026    decoded
1027        .try_into()
1028        .map_err(|_| PilotKeyStoreError::Malformed(error))
1029}
1030
1031#[cfg(unix)]
1032fn ensure_dir(path: &Path) -> Result<(), PilotKeyStoreError> {
1033    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
1034
1035    if !path.exists() {
1036        let mut builder = std::fs::DirBuilder::new();
1037        builder.mode(0o700);
1038        builder.create(path)?;
1039    }
1040
1041    let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
1042    if mode & 0o077 != 0 {
1043        return Err(PilotKeyStoreError::UnsafePermissions {
1044            path: path.to_path_buf(),
1045            mode,
1046        });
1047    }
1048    Ok(())
1049}
1050
1051#[cfg(not(unix))]
1052fn ensure_dir(path: &Path) -> Result<(), PilotKeyStoreError> {
1053    std::fs::create_dir_all(path)?;
1054    Ok(())
1055}
1056
1057#[cfg(unix)]
1058fn ensure_file_permissions(path: &Path) -> Result<(), PilotKeyStoreError> {
1059    use std::os::unix::fs::PermissionsExt;
1060
1061    let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
1062    if mode & 0o077 != 0 {
1063        return Err(PilotKeyStoreError::UnsafePermissions {
1064            path: path.to_path_buf(),
1065            mode,
1066        });
1067    }
1068    Ok(())
1069}
1070
1071#[cfg(not(unix))]
1072fn ensure_file_permissions(_path: &Path) -> Result<(), PilotKeyStoreError> {
1073    Ok(())
1074}
1075
1076#[cfg(unix)]
1077fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), PilotKeyStoreError> {
1078    use std::io::Write;
1079    use std::os::unix::fs::OpenOptionsExt;
1080
1081    let mut file = std::fs::OpenOptions::new()
1082        .create(true)
1083        .truncate(true)
1084        .write(true)
1085        .mode(0o600)
1086        .open(path)?;
1087    file.write_all(bytes)?;
1088    file.flush()?;
1089    Ok(())
1090}
1091
1092#[cfg(not(unix))]
1093fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), PilotKeyStoreError> {
1094    use std::io::Write;
1095
1096    let mut file = std::fs::File::create(path)?;
1097    file.write_all(bytes)?;
1098    file.flush()?;
1099    Ok(())
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105
1106    fn deterministic_secret_key(byte: u8) -> iroh::SecretKey {
1107        iroh::SecretKey::from_bytes(&[byte; 32])
1108    }
1109
1110    fn temp_store() -> (PilotKeyStore, tempfile::TempDir) {
1111        let dir = tempfile::tempdir().unwrap();
1112        let store = PilotKeyStore::open(dir.path().join("pilot"));
1113        store.init().unwrap();
1114        (store, dir)
1115    }
1116
1117    #[test]
1118    fn hkdf_matches_rfc_5869_test_vector_case_1() {
1119        let ikm = [0x0b; 22];
1120        let salt = hex::decode("000102030405060708090a0b0c").unwrap();
1121        let info = hex::decode("f0f1f2f3f4f5f6f7f8f9").unwrap();
1122        let (prk, hkdf) = Hkdf::<Sha256>::extract(Some(&salt), &ikm);
1123        assert_eq!(
1124            hex::encode(prk),
1125            "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5"
1126        );
1127
1128        let mut okm = [0u8; 32];
1129        hkdf.expand(&info, &mut okm).unwrap();
1130        assert_eq!(
1131            hex::encode(okm),
1132            "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
1133        );
1134    }
1135
1136    #[test]
1137    fn generate_and_load_round_trip_same_node_identity() {
1138        let (store, _dir) = temp_store();
1139        let node_secret_key = deterministic_secret_key(7);
1140
1141        let generated = store.generate(&node_secret_key).unwrap();
1142        let loaded = store.load(&node_secret_key).unwrap().unwrap();
1143
1144        assert_eq!(generated.pilot_id(), loaded.pilot_id());
1145        assert_eq!(
1146            generated.active_pilot_auth_public_key_hex(),
1147            loaded.active_pilot_auth_public_key_hex()
1148        );
1149    }
1150
1151    #[test]
1152    fn encrypted_key_files_do_not_store_plaintext_secret_bytes() {
1153        let (store, _dir) = temp_store();
1154        let node_secret_key = deterministic_secret_key(9);
1155        let identity = store.generate(&node_secret_key).unwrap();
1156
1157        let pilot_id_file = std::fs::read(store.pilot_id_path()).unwrap();
1158        let pilot_auth_file = std::fs::read(store.active_pilot_auth_path()).unwrap();
1159
1160        assert!(
1161            !pilot_id_file
1162                .windows(32)
1163                .any(|window| window == identity.pilot_id_secret_key().to_bytes())
1164        );
1165        assert!(
1166            !pilot_auth_file
1167                .windows(32)
1168                .any(|window| window == identity.active_pilot_auth_secret_key().to_bytes())
1169        );
1170    }
1171
1172    #[test]
1173    fn wrong_node_identity_cannot_decrypt_pilot_keys() {
1174        let (store, _dir) = temp_store();
1175        store.generate(&deterministic_secret_key(11)).unwrap();
1176
1177        let err = store.load(&deterministic_secret_key(12)).unwrap_err();
1178        assert!(matches!(err, PilotKeyStoreError::WrongNodeIdentity));
1179    }
1180
1181    #[test]
1182    fn malformed_key_files_are_rejected() {
1183        let (store, _dir) = temp_store();
1184        let node_secret_key = deterministic_secret_key(13);
1185        store.generate(&node_secret_key).unwrap();
1186
1187        std::fs::write(store.pilot_id_path(), b"{not json").unwrap();
1188        assert!(matches!(
1189            store.load(&node_secret_key).unwrap_err(),
1190            PilotKeyStoreError::Json(_)
1191        ));
1192
1193        std::fs::write(
1194            store.active_pilot_auth_path(),
1195            serde_json::to_vec(&EncryptedKeyFile {
1196                schema: KEY_FILE_SCHEMA.to_string(),
1197                schema_version: KEY_FILE_VERSION,
1198                role: "pilot_auth".to_string(),
1199                nonce: "abcd".to_string(),
1200                ciphertext: "00".to_string(),
1201            })
1202            .unwrap(),
1203        )
1204        .unwrap();
1205        assert!(matches!(
1206            store.load(&node_secret_key).unwrap_err(),
1207            PilotKeyStoreError::Json(_) | PilotKeyStoreError::Malformed(_)
1208        ));
1209    }
1210
1211    #[test]
1212    fn inspect_reports_layout_and_public_export() {
1213        let (store, _dir) = temp_store();
1214        let node_secret_key = deterministic_secret_key(21);
1215        let identity = store.generate(&node_secret_key).unwrap();
1216
1217        let status = store.inspect().unwrap();
1218        assert!(status.has_pilot_id);
1219        assert!(status.has_active_pilot_auth);
1220        assert_eq!(status.archived_key_count, 0);
1221
1222        let exported = store
1223            .export_public_identity(&node_secret_key)
1224            .unwrap()
1225            .unwrap();
1226        assert_eq!(exported.pilot_id, identity.pilot_id());
1227        assert_eq!(
1228            exported.active_pilot_auth_public_key_hex,
1229            identity.active_pilot_auth_public_key_hex()
1230        );
1231    }
1232
1233    #[cfg(unix)]
1234    #[test]
1235    fn unsafe_permissions_are_rejected() {
1236        use std::os::unix::fs::PermissionsExt;
1237
1238        let (store, _dir) = temp_store();
1239        let node_secret_key = deterministic_secret_key(31);
1240        store.generate(&node_secret_key).unwrap();
1241
1242        let mut file_permissions = std::fs::metadata(store.pilot_id_path())
1243            .unwrap()
1244            .permissions();
1245        file_permissions.set_mode(0o644);
1246        std::fs::set_permissions(store.pilot_id_path(), file_permissions).unwrap();
1247        assert!(matches!(
1248            store.load(&node_secret_key).unwrap_err(),
1249            PilotKeyStoreError::UnsafePermissions { .. }
1250        ));
1251
1252        let mut dir_permissions = std::fs::metadata(store.active_pilot_auth_dir())
1253            .unwrap()
1254            .permissions();
1255        dir_permissions.set_mode(0o755);
1256        std::fs::set_permissions(store.active_pilot_auth_dir(), dir_permissions).unwrap();
1257        assert!(matches!(
1258            store.inspect().unwrap_err(),
1259            PilotKeyStoreError::UnsafePermissions { .. }
1260        ));
1261    }
1262
1263    #[test]
1264    fn incomplete_key_store_is_rejected() {
1265        let (store, _dir) = temp_store();
1266        let node_secret_key = deterministic_secret_key(41);
1267        store.generate(&node_secret_key).unwrap();
1268        std::fs::remove_file(store.active_pilot_auth_path()).unwrap();
1269
1270        assert!(matches!(
1271            store.load(&node_secret_key).unwrap_err(),
1272            PilotKeyStoreError::Incomplete(_)
1273        ));
1274    }
1275
1276    #[test]
1277    fn archived_pilot_auth_dids_lists_retired_identifiers_only() {
1278        let (store, _dir) = temp_store();
1279        let node_secret_key = deterministic_secret_key(51);
1280        let identity = store.generate(&node_secret_key).unwrap();
1281        let next = store
1282            .generate_next_active_pilot_auth_secret_key(&node_secret_key)
1283            .unwrap();
1284        let retired = identity.active_pilot_auth_did();
1285
1286        let rotated = store
1287            .replace_active_pilot_auth(&node_secret_key, &next)
1288            .unwrap();
1289        let archived = store.archived_pilot_auth_dids().unwrap();
1290
1291        assert_eq!(archived, vec![retired]);
1292        assert_ne!(archived[0], rotated.active_pilot_auth_did());
1293    }
1294
1295    // ── MultiPilotKeyStore ───────────────────────────────────────────────────
1296
1297    fn temp_multi_pilot_store() -> (MultiPilotKeyStore, tempfile::TempDir) {
1298        let dir = tempfile::tempdir().unwrap();
1299        let store = MultiPilotKeyStore::for_data_dir(dir.path());
1300        store.init().unwrap();
1301        (store, dir)
1302    }
1303
1304    #[test]
1305    fn multi_pilot_generate_load_and_list_round_trip() {
1306        let (store, _dir) = temp_multi_pilot_store();
1307        let node_secret_key = deterministic_secret_key(81);
1308
1309        let alice = store
1310            .generate_pilot("Alice", Some("NO".to_string()), &node_secret_key)
1311            .unwrap();
1312        let bob = store.generate_pilot("Bob", None, &node_secret_key).unwrap();
1313
1314        assert_ne!(alice.pilot_id(), bob.pilot_id());
1315        assert_eq!(
1316            store
1317                .load_pilot(&alice.pilot_id(), &node_secret_key)
1318                .unwrap()
1319                .unwrap()
1320                .pilot_id(),
1321            alice.pilot_id()
1322        );
1323        assert_eq!(
1324            store
1325                .load_pilot(&bob.pilot_id(), &node_secret_key)
1326                .unwrap()
1327                .unwrap()
1328                .pilot_id(),
1329            bob.pilot_id()
1330        );
1331
1332        let pilots = store.list_pilots(&node_secret_key).unwrap();
1333        assert_eq!(pilots.len(), 2);
1334        assert!(pilots.iter().any(|pilot| {
1335            pilot.pilot_id == alice.pilot_id()
1336                && pilot.display_name == "Alice"
1337                && pilot.country.as_deref() == Some("NO")
1338        }));
1339        assert!(pilots.iter().any(|pilot| {
1340            pilot.pilot_id == bob.pilot_id()
1341                && pilot.display_name == "Bob"
1342                && pilot.country.is_none()
1343        }));
1344    }
1345
1346    #[test]
1347    fn multi_pilot_load_absent_does_not_create_directory() {
1348        let (store, _dir) = temp_multi_pilot_store();
1349        let node_secret_key = deterministic_secret_key(82);
1350        let absent = pilot_id(182);
1351
1352        assert!(
1353            store
1354                .load_pilot(&absent, &node_secret_key)
1355                .unwrap()
1356                .is_none()
1357        );
1358        assert!(!store.pilot_dir(&absent).exists());
1359    }
1360
1361    #[test]
1362    fn multi_pilot_store_returns_per_pilot_key_store_for_rotation() {
1363        let (store, _dir) = temp_multi_pilot_store();
1364        let node_secret_key = deterministic_secret_key(83);
1365        let identity = store
1366            .generate_pilot("Carol", Some("SE".to_string()), &node_secret_key)
1367            .unwrap();
1368        let pilot_store = store.pilot_store(&identity.pilot_id());
1369        let next = pilot_store
1370            .generate_next_active_pilot_auth_secret_key(&node_secret_key)
1371            .unwrap();
1372        let retired = identity.active_pilot_auth_did();
1373
1374        let rotated = pilot_store
1375            .replace_active_pilot_auth(&node_secret_key, &next)
1376            .unwrap();
1377
1378        assert_eq!(
1379            pilot_store.archived_pilot_auth_dids().unwrap(),
1380            vec![retired]
1381        );
1382        assert_eq!(
1383            store
1384                .load_pilot(&identity.pilot_id(), &node_secret_key)
1385                .unwrap()
1386                .unwrap()
1387                .active_pilot_auth_did(),
1388            rotated.active_pilot_auth_did()
1389        );
1390    }
1391
1392    #[test]
1393    fn multi_pilot_wrong_node_key_cannot_decrypt() {
1394        let (store, _dir) = temp_multi_pilot_store();
1395        let identity = store
1396            .generate_pilot("Dana", None, &deterministic_secret_key(84))
1397            .unwrap();
1398
1399        assert!(matches!(
1400            store.load_pilot(&identity.pilot_id(), &deterministic_secret_key(85)),
1401            Err(PilotKeyStoreError::WrongNodeIdentity)
1402        ));
1403    }
1404
1405    #[test]
1406    fn multi_pilot_rejects_invalid_profile() {
1407        let (store, _dir) = temp_multi_pilot_store();
1408        let node_secret_key = deterministic_secret_key(86);
1409
1410        assert!(matches!(
1411            store.generate_pilot("", None, &node_secret_key),
1412            Err(PilotKeyStoreError::Malformed(_))
1413        ));
1414        assert!(matches!(
1415            store.generate_pilot("Eve", Some("zz".to_string()), &node_secret_key),
1416            Err(PilotKeyStoreError::Malformed(_))
1417        ));
1418    }
1419
1420    // ── PilotCredentialStore ─────────────────────────────────────────────────
1421
1422    fn temp_credential_store() -> (PilotCredentialStore, tempfile::TempDir) {
1423        let dir = tempfile::tempdir().unwrap();
1424        let store = PilotCredentialStore::for_data_dir(dir.path());
1425        store.init().unwrap();
1426        (store, dir)
1427    }
1428
1429    #[test]
1430    fn credential_store_hashes_and_verifies_pin() {
1431        let (store, _dir) = temp_credential_store();
1432        let pilot = pilot_id(190);
1433
1434        store.set_credential(&pilot, "1234").unwrap();
1435
1436        assert!(store.verify_credential(&pilot, "1234").unwrap());
1437        assert!(!store.verify_credential(&pilot, "9999").unwrap());
1438        assert!(!store.verify_credential(&pilot_id(191), "1234").unwrap());
1439    }
1440
1441    #[test]
1442    fn credential_store_does_not_store_plaintext_pin() {
1443        let (store, _dir) = temp_credential_store();
1444        let pilot = pilot_id(192);
1445
1446        store.set_credential(&pilot, "1234").unwrap();
1447        let bytes = std::fs::read(store.credential_path(&pilot)).unwrap();
1448        let text = String::from_utf8(bytes).unwrap();
1449
1450        assert!(!text.contains("1234"));
1451        assert!(text.contains("$argon2id$"));
1452    }
1453
1454    #[test]
1455    fn credential_store_rejects_empty_pin() {
1456        let (store, _dir) = temp_credential_store();
1457
1458        assert!(matches!(
1459            store.set_credential(&pilot_id(193), ""),
1460            Err(PilotKeyStoreError::Malformed(_))
1461        ));
1462        assert!(!store.verify_credential(&pilot_id(193), "").unwrap());
1463    }
1464
1465    #[test]
1466    fn credential_store_overwrites_existing_pin() {
1467        let (store, _dir) = temp_credential_store();
1468        let pilot = pilot_id(194);
1469
1470        store.set_credential(&pilot, "1234").unwrap();
1471        store.set_credential(&pilot, "5678").unwrap();
1472
1473        assert!(!store.verify_credential(&pilot, "1234").unwrap());
1474        assert!(store.verify_credential(&pilot, "5678").unwrap());
1475    }
1476
1477    // ── PrivateAccessKeyStore ─────────────────────────────────────────────────
1478
1479    fn temp_private_access_store() -> (PrivateAccessKeyStore, tempfile::TempDir) {
1480        let dir = tempfile::tempdir().unwrap();
1481        let store = PrivateAccessKeyStore::for_data_dir(dir.path());
1482        (store, dir)
1483    }
1484
1485    fn pilot_id(byte: u8) -> PilotId {
1486        PilotId::from_public_key(deterministic_secret_key(byte).public())
1487    }
1488
1489    #[test]
1490    fn private_access_generate_and_load_round_trip() {
1491        let (store, _dir) = temp_private_access_store();
1492        let node_key = deterministic_secret_key(61);
1493        let pilot = pilot_id(161);
1494
1495        let generated = store.generate_for_pilot(&pilot, &node_key).unwrap();
1496        let loaded = store.load_for_pilot(&pilot, &node_key).unwrap().unwrap();
1497
1498        assert_eq!(generated.to_bytes(), loaded.to_bytes());
1499    }
1500
1501    #[test]
1502    fn private_access_load_returns_none_when_absent() {
1503        let (store, _dir) = temp_private_access_store();
1504        let node_key = deterministic_secret_key(62);
1505        assert!(
1506            store
1507                .load_for_pilot(&pilot_id(162), &node_key)
1508                .unwrap()
1509                .is_none()
1510        );
1511    }
1512
1513    #[test]
1514    fn private_access_generate_rejects_second_call_for_same_pilot() {
1515        let (store, _dir) = temp_private_access_store();
1516        let node_key = deterministic_secret_key(63);
1517        let pilot = pilot_id(163);
1518        store.generate_for_pilot(&pilot, &node_key).unwrap();
1519        assert!(matches!(
1520            store.generate_for_pilot(&pilot, &node_key),
1521            Err(PilotKeyStoreError::AlreadyInitialized)
1522        ));
1523    }
1524
1525    #[test]
1526    fn private_access_generate_allows_multiple_pilots() {
1527        let (store, _dir) = temp_private_access_store();
1528        let node_key = deterministic_secret_key(63);
1529        let pilot_a = pilot_id(164);
1530        let pilot_b = pilot_id(165);
1531
1532        let key_a = store.generate_for_pilot(&pilot_a, &node_key).unwrap();
1533        let key_b = store.generate_for_pilot(&pilot_b, &node_key).unwrap();
1534
1535        assert_eq!(
1536            store
1537                .load_for_pilot(&pilot_a, &node_key)
1538                .unwrap()
1539                .unwrap()
1540                .to_bytes(),
1541            key_a.to_bytes()
1542        );
1543        assert_eq!(
1544            store
1545                .load_for_pilot(&pilot_b, &node_key)
1546                .unwrap()
1547                .unwrap()
1548                .to_bytes(),
1549            key_b.to_bytes()
1550        );
1551        assert_ne!(key_a.to_bytes(), key_b.to_bytes());
1552    }
1553
1554    #[test]
1555    fn private_access_provision_stores_external_key() {
1556        let (store, _dir) = temp_private_access_store();
1557        let node_key = deterministic_secret_key(64);
1558        let external_key = deterministic_secret_key(65);
1559        let pilot = pilot_id(166);
1560
1561        store
1562            .provision_for_pilot(&pilot, &external_key, &node_key)
1563            .unwrap();
1564        let loaded = store.load_for_pilot(&pilot, &node_key).unwrap().unwrap();
1565
1566        assert_eq!(external_key.to_bytes(), loaded.to_bytes());
1567    }
1568
1569    #[test]
1570    fn private_access_provision_overwrites_existing_key() {
1571        let (store, _dir) = temp_private_access_store();
1572        let node_key = deterministic_secret_key(66);
1573        let pilot = pilot_id(167);
1574
1575        store
1576            .provision_for_pilot(&pilot, &deterministic_secret_key(67), &node_key)
1577            .unwrap();
1578        store
1579            .provision_for_pilot(&pilot, &deterministic_secret_key(68), &node_key)
1580            .unwrap();
1581        let loaded = store.load_for_pilot(&pilot, &node_key).unwrap().unwrap();
1582
1583        assert_eq!(loaded.to_bytes(), deterministic_secret_key(68).to_bytes());
1584    }
1585
1586    #[test]
1587    fn private_access_delete_clears_stored_key() {
1588        let (store, _dir) = temp_private_access_store();
1589        let node_key = deterministic_secret_key(69);
1590        let pilot = pilot_id(168);
1591        store.generate_for_pilot(&pilot, &node_key).unwrap();
1592
1593        store.delete_for_pilot(&pilot).unwrap();
1594
1595        assert!(store.load_for_pilot(&pilot, &node_key).unwrap().is_none());
1596    }
1597
1598    #[test]
1599    fn private_access_delete_is_idempotent_when_pilot_absent() {
1600        let (store, _dir) = temp_private_access_store();
1601        store.delete_for_pilot(&pilot_id(169)).unwrap();
1602    }
1603
1604    #[test]
1605    fn private_access_delete_only_removes_target_pilot() {
1606        let (store, _dir) = temp_private_access_store();
1607        let node_key = deterministic_secret_key(70);
1608        let pilot_a = pilot_id(170);
1609        let pilot_b = pilot_id(171);
1610        let key_b = deterministic_secret_key(72);
1611
1612        store
1613            .provision_for_pilot(&pilot_a, &deterministic_secret_key(71), &node_key)
1614            .unwrap();
1615        store
1616            .provision_for_pilot(&pilot_b, &key_b, &node_key)
1617            .unwrap();
1618
1619        store.delete_for_pilot(&pilot_a).unwrap();
1620
1621        assert!(store.load_for_pilot(&pilot_a, &node_key).unwrap().is_none());
1622        assert_eq!(
1623            store
1624                .load_for_pilot(&pilot_b, &node_key)
1625                .unwrap()
1626                .unwrap()
1627                .to_bytes(),
1628            key_b.to_bytes()
1629        );
1630    }
1631
1632    #[test]
1633    fn private_access_load_by_public_key_finds_matching_pilot() {
1634        let (store, _dir) = temp_private_access_store();
1635        let node_key = deterministic_secret_key(73);
1636        let pilot_a = pilot_id(172);
1637        let pilot_b = pilot_id(173);
1638        let key_b = deterministic_secret_key(74);
1639
1640        store
1641            .provision_for_pilot(&pilot_a, &deterministic_secret_key(75), &node_key)
1642            .unwrap();
1643        store
1644            .provision_for_pilot(&pilot_b, &key_b, &node_key)
1645            .unwrap();
1646
1647        let (matched_pilot, matched_key) = store
1648            .load_by_public_key(&key_b.public().to_string(), &node_key)
1649            .unwrap()
1650            .unwrap();
1651
1652        assert_eq!(matched_pilot, pilot_b);
1653        assert_eq!(matched_key.to_bytes(), key_b.to_bytes());
1654    }
1655
1656    #[test]
1657    fn private_access_wrong_node_key_cannot_decrypt() {
1658        let (store, _dir) = temp_private_access_store();
1659        let pilot = pilot_id(174);
1660        store
1661            .generate_for_pilot(&pilot, &deterministic_secret_key(76))
1662            .unwrap();
1663        assert!(matches!(
1664            store.load_for_pilot(&pilot, &deterministic_secret_key(77)),
1665            Err(PilotKeyStoreError::WrongNodeIdentity)
1666        ));
1667    }
1668}