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#[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#[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#[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(¤t_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 ¤t_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
686const 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#[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 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 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 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 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 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 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 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 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}