1use std::{
2 collections::HashMap,
3 fs,
4 io::{self, Read, Write},
5 path::{Path, PathBuf},
6 sync::{Arc, RwLock},
7};
8
9use rand::RngCore;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest as Sha2Digest, Sha256};
12
13use crate::attestation::{Ed25519Signer, Signer};
14
15pub type KeyId = String;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct KeyInfo {
22 pub id: KeyId,
23 pub algorithm: String, pub is_default: bool,
25 pub created_at: String, pub fingerprint: String,
28 pub public_key: Vec<u8>, #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub valid_until: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub successor_key_id: Option<KeyId>,
41}
42
43#[derive(Debug, Clone)]
45pub struct RotationResult {
46 pub predecessor: KeyInfo,
48 pub successor: KeyInfo,
50 pub grace_period_until: String,
54}
55
56#[derive(Debug)]
58pub enum KeyError {
59 Io(io::Error),
60 Json(serde_json::Error),
61 Crypto(String),
62 NotFound(KeyId),
63 EmptyKeyId,
64 NoDefaultKey,
65 InsecureKeyPerms { path: PathBuf, mode: u32 },
70}
71
72impl std::fmt::Display for KeyError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::Io(e) => write!(f, "keys io: {}", e),
76 Self::Json(e) => write!(f, "keys json: {}", e),
77 Self::Crypto(e) => write!(f, "keys crypto: {}", e),
78 Self::NotFound(k) => write!(f, "key not found: {}", k),
79 Self::EmptyKeyId => write!(f, "key id must not be empty"),
80 Self::NoDefaultKey => write!(f, "no default key — run treeship init"),
81 Self::InsecureKeyPerms { path, mode } => write!(
82 f,
83 "private key {} has insecure permissions (mode {:o}); \
84 run `treeship doctor --fix` or chmod 600 the file. \
85 Set TREESHIP_ALLOW_INSECURE_KEY_PERMS=1 to bypass.",
86 path.display(),
87 mode & 0o777,
88 ),
89 }
90 }
91}
92
93impl std::error::Error for KeyError {}
94impl From<io::Error> for KeyError { fn from(e: io::Error) -> Self { Self::Io(e) } }
95impl From<serde_json::Error> for KeyError { fn from(e: serde_json::Error) -> Self { Self::Json(e) } }
96
97#[derive(Serialize, Deserialize, Clone)]
101struct EncryptedEntry {
102 id: KeyId,
103 algorithm: String,
104 created_at: String,
105 public_key: Vec<u8>,
106 enc_priv_key: Vec<u8>,
108 nonce: Vec<u8>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
114 valid_until: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 successor_key_id: Option<KeyId>,
119}
120
121#[derive(Serialize, Deserialize, Default)]
123struct Manifest {
124 default_key_id: Option<KeyId>,
125 key_ids: Vec<KeyId>,
126}
127
128pub struct Store {
138 dir: PathBuf,
139 machine_key: [u8; 32],
140 cache: Arc<RwLock<HashMap<KeyId, EncryptedEntry>>>,
142}
143
144impl Store {
145 pub fn open(dir: impl AsRef<Path>) -> Result<Self, KeyError> {
147 let dir = dir.as_ref().to_path_buf();
148 fs::create_dir_all(&dir)?;
149
150 let machine_key = derive_machine_key(&dir)?;
151
152 Ok(Self {
153 dir,
154 machine_key,
155 cache: Arc::new(RwLock::new(HashMap::new())),
156 })
157 }
158
159 pub fn generate(&self, set_default: bool) -> Result<KeyInfo, KeyError> {
163 let key_id = new_key_id();
164
165 let signer = Ed25519Signer::generate(&key_id)
166 .map_err(|e| KeyError::Crypto(e.to_string()))?;
167
168 let secret = signer.secret_bytes();
169 let pub_key = signer.public_key_bytes();
170
171 let (enc, nonce) = aes_gcm_encrypt(&self.machine_key, &secret)
172 .map_err(|e| KeyError::Crypto(e))?;
173
174 let entry = EncryptedEntry {
175 id: key_id.clone(),
176 algorithm: "ed25519".into(),
177 created_at: crate::statements::unix_to_rfc3339(unix_now()),
178 public_key: pub_key.clone(),
179 enc_priv_key: enc,
180 nonce,
181 valid_until: None,
182 successor_key_id: None,
183 };
184
185 self.write_entry(&entry)?;
186
187 let mut manifest = self.read_manifest()?;
189 manifest.key_ids.push(key_id.clone());
190 if set_default || manifest.default_key_id.is_none() {
191 manifest.default_key_id = Some(key_id.clone());
192 }
193 self.write_manifest(&manifest)?;
194
195 self.cache.write().unwrap().insert(key_id.clone(), entry);
197
198 Ok(KeyInfo {
199 id: key_id.clone(),
200 algorithm: "ed25519".into(),
201 is_default: manifest.default_key_id.as_deref() == Some(key_id.as_str()),
202 created_at: crate::statements::unix_to_rfc3339(unix_now()),
203 fingerprint: fingerprint(&pub_key),
204 public_key: pub_key,
205 valid_until: None,
206 successor_key_id: None,
207 })
208 }
209
210 pub fn rotate(
235 &self,
236 predecessor_id: Option<&str>,
237 grace_period: std::time::Duration,
238 set_default: bool,
239 ) -> Result<RotationResult, KeyError> {
240 let pred_id = match predecessor_id {
242 Some(id) => id.to_string(),
243 None => self.default_key_id()?,
244 };
245
246 let pred_entry_existing = self.load_entry(&pred_id)?;
250 if let Some(existing) = &pred_entry_existing.successor_key_id {
251 return Err(KeyError::Crypto(format!(
252 "key {pred_id} has already been rotated to {existing}; \
253 rotate the chain head instead"
254 )));
255 }
256
257 let succ_id = new_key_id();
262 let signer = Ed25519Signer::generate(&succ_id)
263 .map_err(|e| KeyError::Crypto(e.to_string()))?;
264 let succ_secret = signer.secret_bytes();
265 let succ_pub_key = signer.public_key_bytes();
266 let (succ_enc, succ_nonce) = aes_gcm_encrypt(&self.machine_key, &succ_secret)
267 .map_err(KeyError::Crypto)?;
268
269 let succ_created = crate::statements::unix_to_rfc3339(unix_now());
270 let succ_entry = EncryptedEntry {
271 id: succ_id.clone(),
272 algorithm: "ed25519".into(),
273 created_at: succ_created.clone(),
274 public_key: succ_pub_key.clone(),
275 enc_priv_key: succ_enc,
276 nonce: succ_nonce,
277 valid_until: None,
278 successor_key_id: None,
279 };
280
281 let valid_until = crate::statements::unix_to_rfc3339(
283 unix_now() + grace_period.as_secs(),
284 );
285 let mut pred_entry = pred_entry_existing;
286 pred_entry.valid_until = Some(valid_until.clone());
287 pred_entry.successor_key_id = Some(succ_id.clone());
288
289 self.write_entry(&succ_entry)?;
304 self.write_entry(&pred_entry)?;
305
306 {
316 let mut cache = self.cache.write().unwrap();
317 cache.insert(pred_entry.id.clone(), pred_entry.clone());
318 cache.insert(succ_id.clone(), succ_entry.clone());
319 }
320
321 let mut manifest = self.read_manifest()?;
323 manifest.key_ids.push(succ_id.clone());
324 if set_default {
325 manifest.default_key_id = Some(succ_id.clone());
326 }
327 self.write_manifest(&manifest)?;
328
329 let default_id = manifest.default_key_id.clone();
330 let predecessor = KeyInfo {
331 id: pred_entry.id.clone(),
332 algorithm: pred_entry.algorithm.clone(),
333 is_default: default_id.as_deref() == Some(pred_entry.id.as_str()),
334 created_at: pred_entry.created_at.clone(),
335 fingerprint: fingerprint(&pred_entry.public_key),
336 public_key: pred_entry.public_key.clone(),
337 valid_until: pred_entry.valid_until.clone(),
338 successor_key_id: pred_entry.successor_key_id.clone(),
339 };
340 let successor = KeyInfo {
341 id: succ_id.clone(),
342 algorithm: "ed25519".into(),
343 is_default: default_id.as_deref() == Some(succ_id.as_str()),
344 created_at: succ_created,
345 fingerprint: fingerprint(&succ_pub_key),
346 public_key: succ_pub_key,
347 valid_until: None,
348 successor_key_id: None,
349 };
350
351 Ok(RotationResult {
352 predecessor,
353 successor,
354 grace_period_until: valid_until,
355 })
356 }
357
358 pub fn successor_chain(&self, id: &str) -> Result<Vec<KeyId>, KeyError> {
362 let mut chain = Vec::new();
363 let mut cursor = id.to_string();
364 let max_steps = self.read_manifest()?.key_ids.len() + 1;
368 for _ in 0..max_steps {
369 chain.push(cursor.clone());
370 let entry = self.load_entry(&cursor)?;
371 match entry.successor_key_id {
372 Some(next) => cursor = next,
373 None => return Ok(chain),
374 }
375 }
376 Err(KeyError::Crypto(format!(
377 "rotation chain starting at {id} exceeds keystore size; suspected loop"
378 )))
379 }
380
381 pub fn valid_keys_at(&self, at_unix_secs: u64) -> Result<Vec<KeyInfo>, KeyError> {
386 let cutoff_rfc = crate::statements::unix_to_rfc3339(at_unix_secs);
387 Ok(self.list()?
388 .into_iter()
389 .filter(|k| match &k.valid_until {
390 None => true,
391 Some(until) => until.as_str() > cutoff_rfc.as_str(),
392 })
393 .collect())
394 }
395
396 pub fn default_signer(&self) -> Result<Box<dyn Signer>, KeyError> {
398 let manifest = self.read_manifest()?;
399 let id = manifest.default_key_id.ok_or(KeyError::NoDefaultKey)?;
400 self.signer(&id)
401 }
402
403 pub fn signer(&self, id: &str) -> Result<Box<dyn Signer>, KeyError> {
414 check_key_file_perms(&self.entry_path(id))?;
415
416 let entry = self.load_entry(id)?;
417
418 let secret = aes_gcm_decrypt(&self.machine_key, &entry.enc_priv_key, &entry.nonce)
419 .map_err(|e| self.enrich_crypto_error(e))?;
420
421 let secret_arr: [u8; 32] = secret.try_into()
422 .map_err(|_| KeyError::Crypto("decrypted key is wrong length".into()))?;
423
424 let signer = Ed25519Signer::from_bytes(&entry.id, &secret_arr)
425 .map_err(|e| KeyError::Crypto(e.to_string()))?;
426
427 Ok(Box::new(signer))
428 }
429
430 fn enrich_crypto_error(&self, raw: String) -> KeyError {
442 if !raw.contains("MAC verification failed") {
445 return KeyError::Crypto(raw);
446 }
447
448 let legacy_seed_dot = self.dir.join(".machineseed");
449 let legacy_seed = self.dir.join("machine_seed");
450 let has_legacy_seed = legacy_seed_dot.exists() || legacy_seed.exists();
451
452 let diagnosis = if has_legacy_seed {
453 "your keystore was created by an older Treeship version whose \
454 machine-key derivation has since changed. The ciphertext is \
455 intact but cannot be decrypted under the current derivation."
456 } else {
457 "the keystore cannot be decrypted. Usual causes: the key file \
458 was copied from a different machine, the hostname or username \
459 changed, or the file was corrupted."
460 };
461
462 let ts_dir = std::env::var("HOME")
465 .map(|h| format!("{h}/.treeship"))
466 .unwrap_or_else(|_| "~/.treeship".into());
467
468 let msg = format!(
473 "{raw}\n\n \
474 Diagnosis: {diagnosis}\n\n \
475 Recovery (nondestructive -- the old keystore is moved aside, \
476 not deleted; any sealed .treeship packages you produced remain \
477 verifiable since their receipts embed the old public key):\n\n \
478 mv {ts_dir} {ts_dir}.bak.$(date +%s)\n \
479 treeship init\n"
480 );
481
482 KeyError::Crypto(msg)
483 }
484
485 pub fn default_key_id(&self) -> Result<KeyId, KeyError> {
487 self.read_manifest()?
488 .default_key_id
489 .ok_or(KeyError::NoDefaultKey)
490 }
491
492 pub fn list(&self) -> Result<Vec<KeyInfo>, KeyError> {
494 let manifest = self.read_manifest()?;
495 let default = manifest.default_key_id.as_deref().unwrap_or("");
496
497 manifest.key_ids.iter().map(|id| {
498 let entry = self.load_entry(id)?;
499 Ok(KeyInfo {
500 id: entry.id.clone(),
501 algorithm: entry.algorithm.clone(),
502 is_default: entry.id == default,
503 created_at: entry.created_at.clone(),
504 fingerprint: fingerprint(&entry.public_key),
505 public_key: entry.public_key.clone(),
506 valid_until: entry.valid_until.clone(),
507 successor_key_id: entry.successor_key_id.clone(),
508 })
509 }).collect()
510 }
511
512 pub fn set_default(&self, id: &str) -> Result<(), KeyError> {
514 self.load_entry(id)?;
516 let mut manifest = self.read_manifest()?;
517 manifest.default_key_id = Some(id.to_string());
518 self.write_manifest(&manifest)
519 }
520
521 pub fn public_key(&self, id: &str) -> Result<Vec<u8>, KeyError> {
523 Ok(self.load_entry(id)?.public_key)
524 }
525
526 fn load_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
529 if let Ok(cache) = self.cache.read() {
531 if let Some(entry) = cache.get(id) {
532 return Ok(entry.clone());
533 }
534 }
535 self.read_entry(id)
536 }
537
538 fn entry_path(&self, id: &str) -> PathBuf {
539 self.dir.join(format!("{}.json", id))
540 }
541
542 fn write_entry(&self, entry: &EncryptedEntry) -> Result<(), KeyError> {
543 let path = self.entry_path(&entry.id);
544 let json = serde_json::to_vec_pretty(entry)?;
545 write_file_600(&path, &json)?;
546 Ok(())
547 }
548
549 fn read_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
550 let path = self.entry_path(id);
551 if !path.exists() {
552 return Err(KeyError::NotFound(id.to_string()));
553 }
554 let bytes = fs::read(&path)?;
555 let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
556 Ok(entry)
557 }
558
559 fn manifest_path(&self) -> PathBuf {
560 self.dir.join("manifest.json")
561 }
562
563 fn read_manifest(&self) -> Result<Manifest, KeyError> {
564 let path = self.manifest_path();
565 if !path.exists() {
566 return Ok(Manifest::default());
567 }
568 let bytes = fs::read(&path)?;
569 Ok(serde_json::from_slice(&bytes)?)
570 }
571
572 fn write_manifest(&self, m: &Manifest) -> Result<(), KeyError> {
573 let json = serde_json::to_vec_pretty(m)?;
574 write_file_600(&self.manifest_path(), &json)?;
575 Ok(())
576 }
577}
578
579pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
584 use sha2::Sha256;
595
596 let mut nonce = [0u8; 12];
597 rand::thread_rng().fill_bytes(&mut nonce);
598
599 let mut enc_key_input = key.to_vec();
601 enc_key_input.extend_from_slice(&nonce);
602 enc_key_input.extend_from_slice(b"enc");
603 let enc_key = Sha256::digest(&enc_key_input);
604
605 let mut mac_key_input = key.to_vec();
606 mac_key_input.extend_from_slice(&nonce);
607 mac_key_input.extend_from_slice(b"mac");
608 let mac_key = Sha256::digest(&mac_key_input);
609
610 let ciphertext: Vec<u8> = plaintext.iter().enumerate().map(|(i, &b)| {
612 let mut block_input = enc_key.to_vec();
613 block_input.extend_from_slice(&(i as u64).to_le_bytes());
614 let block = Sha256::digest(&block_input);
615 b ^ block[i % 32]
616 }).collect();
617
618 let mut mac_input = mac_key.to_vec();
620 mac_input.extend_from_slice(&nonce);
621 mac_input.extend_from_slice(&ciphertext);
622 let mac = Sha256::digest(&mac_input);
623
624 let mut out = Vec::with_capacity(12 + 32 + ciphertext.len());
626 out.extend_from_slice(&nonce);
627 out.extend_from_slice(&mac);
628 out.extend_from_slice(&ciphertext);
629
630 Ok((out, nonce.to_vec()))
631}
632
633pub fn aes_gcm_decrypt(key: &[u8; 32], enc_data: &[u8], _nonce_unused: &[u8]) -> Result<Vec<u8>, String> {
634 if enc_data.len() < 44 {
635 return Err("ciphertext too short".into());
636 }
637 use sha2::Sha256;
638
639 let nonce = &enc_data[..12];
640 let stored_mac = &enc_data[12..44];
641 let ciphertext = &enc_data[44..];
642
643 let nonce_arr: [u8; 12] = nonce.try_into().unwrap();
644
645 let mut enc_key_input = key.to_vec();
646 enc_key_input.extend_from_slice(&nonce_arr);
647 enc_key_input.extend_from_slice(b"enc");
648 let enc_key = Sha256::digest(&enc_key_input);
649
650 let mut mac_key_input = key.to_vec();
651 mac_key_input.extend_from_slice(&nonce_arr);
652 mac_key_input.extend_from_slice(b"mac");
653 let mac_key = Sha256::digest(&mac_key_input);
654
655 let mut mac_input = mac_key.to_vec();
657 mac_input.extend_from_slice(&nonce_arr);
658 mac_input.extend_from_slice(ciphertext);
659 let computed_mac = Sha256::digest(&mac_input);
660
661 let mac_ok = stored_mac.iter().zip(computed_mac.iter())
663 .fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0;
664
665 if !mac_ok {
666 return Err("MAC verification failed — key file may be corrupt or wrong machine".into());
667 }
668
669 let plaintext: Vec<u8> = ciphertext.iter().enumerate().map(|(i, &b)| {
670 let mut block_input = enc_key.to_vec();
671 block_input.extend_from_slice(&(i as u64).to_le_bytes());
672 let block = Sha256::digest(&block_input);
673 b ^ block[i % 32]
674 }).collect();
675
676 Ok(plaintext)
677}
678
679pub fn derive_machine_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
682 if let Ok(id) = fs::read_to_string("/etc/machine-id") {
684 let trimmed = id.trim();
685 if !trimmed.is_empty() {
686 let mut h = Sha256::new();
687 h.update(trimmed.as_bytes());
688 h.update(store_dir.to_string_lossy().as_bytes());
689 return Ok(h.finalize().into());
690 }
691 }
692
693 #[cfg(target_os = "macos")]
705 {
706 let hostname = std::process::Command::new("hostname")
707 .output()
708 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
709 .unwrap_or_default();
710 let username = std::env::var("USER").unwrap_or_default();
711 if !hostname.is_empty() && !username.is_empty() {
712 let mut h = Sha256::new();
713 h.update(b"treeship-machine-key:");
714 h.update(hostname.as_bytes());
715 h.update(b":");
716 h.update(username.as_bytes());
717 h.update(b":");
718 h.update(store_dir.to_string_lossy().as_bytes());
719 return Ok(h.finalize().into());
720 }
721 }
722
723 let local_seed_path = store_dir.parent().map(|p| p.join("machine_seed"));
743 let home = std::env::var("HOME")
744 .map(std::path::PathBuf::from)
745 .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
746 let global_seed_path = home.join(".treeship").join("machine_seed");
747
748 let seed = if let Some(local) = local_seed_path.as_ref().filter(|p| p.exists()) {
749 fs::read_to_string(local).map_err(KeyError::Io)?
750 } else if global_seed_path.exists() {
751 fs::read_to_string(&global_seed_path).map_err(KeyError::Io)?
755 } else {
756 let mut bytes = [0u8; 32];
757 rand::thread_rng().fill_bytes(&mut bytes);
758 let seed_hex = hex_encode(&bytes);
759
760 let target = match local_seed_path.as_ref() {
764 Some(p) => {
765 let _ = fs::create_dir_all(p.parent().unwrap_or(Path::new(".")));
766 p.clone()
767 }
768 None => {
769 let _ = fs::create_dir_all(global_seed_path.parent().unwrap_or(Path::new(".")));
770 global_seed_path.clone()
771 }
772 };
773 fs::write(&target, &seed_hex).map_err(KeyError::Io)?;
774 #[cfg(unix)]
775 {
776 use std::os::unix::fs::PermissionsExt;
777 let _ = fs::set_permissions(&target, fs::Permissions::from_mode(0o600));
778 }
779 seed_hex
780 };
781
782 let mut h = Sha256::new();
783 h.update(b"treeship-machine-key-fallback:");
784 h.update(seed.trim().as_bytes());
785 h.update(b":");
786 h.update(store_dir.to_string_lossy().as_bytes());
787 Ok(h.finalize().into())
788}
789
790pub fn derive_machine_key_stable(store_dir: &Path) -> Result<[u8; 32], KeyError> {
794 if let Ok(id) = fs::read_to_string("/etc/machine-id") {
796 let trimmed = id.trim();
797 if !trimmed.is_empty() {
798 let mut h = Sha256::new();
799 h.update(b"treeship-machine-key-v2:");
800 h.update(trimmed.as_bytes());
801 h.update(b":");
802 h.update(store_dir.to_string_lossy().as_bytes());
803 return Ok(h.finalize().into());
804 }
805 }
806
807 #[cfg(target_os = "macos")]
810 {
811 if let Ok(output) = std::process::Command::new("ioreg")
812 .args(["-rd1", "-c", "IOPlatformExpertDevice"])
813 .output()
814 {
815 let stdout = String::from_utf8_lossy(&output.stdout);
816 for line in stdout.lines() {
817 if line.contains("IOPlatformSerialNumber") {
818 if let Some(serial) = line.split('"').nth(3) {
819 if !serial.is_empty() {
820 let mut h = Sha256::new();
821 h.update(b"treeship-machine-key-v2:");
822 h.update(serial.as_bytes());
823 h.update(b":");
824 h.update(store_dir.to_string_lossy().as_bytes());
825 return Ok(h.finalize().into());
826 }
827 }
828 }
829 }
830 }
831 }
832
833 let home = std::env::var("HOME")
836 .map(std::path::PathBuf::from)
837 .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
838 let seed_dir = home.join(".treeship").join(".internal");
839 let _ = fs::create_dir_all(&seed_dir);
840 #[cfg(unix)]
841 {
842 use std::os::unix::fs::PermissionsExt;
843 let _ = fs::set_permissions(&seed_dir, fs::Permissions::from_mode(0o700));
844 }
845
846 let seed_path = seed_dir.join("machine_seed_v2");
847 let seed = if seed_path.exists() {
848 fs::read_to_string(&seed_path).map_err(KeyError::Io)?
849 } else {
850 let mut bytes = [0u8; 32];
851 rand::thread_rng().fill_bytes(&mut bytes);
852 let seed_hex = hex_encode(&bytes);
853 fs::write(&seed_path, &seed_hex).map_err(KeyError::Io)?;
854 #[cfg(unix)]
855 {
856 use std::os::unix::fs::PermissionsExt;
857 let _ = fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600));
858 }
859 seed_hex
860 };
861
862 let mut h = Sha256::new();
863 h.update(b"treeship-machine-key-v2-fallback:");
864 h.update(seed.trim().as_bytes());
865 h.update(b":");
866 h.update(store_dir.to_string_lossy().as_bytes());
867 Ok(h.finalize().into())
868}
869
870fn new_key_id() -> KeyId {
873 let mut b = [0u8; 8];
874 rand::thread_rng().fill_bytes(&mut b);
875 format!("key_{}", hex_encode(&b))
876}
877
878fn fingerprint(pub_key: &[u8]) -> String {
879 let h = Sha256::digest(pub_key);
880 hex_encode(&h[..8])
881}
882
883fn hex_encode(b: &[u8]) -> String {
884 b.iter().fold(String::new(), |mut s, byte| {
885 s.push_str(&format!("{:02x}", byte));
886 s
887 })
888}
889
890fn check_key_file_perms(path: &Path) -> Result<(), KeyError> {
896 #[cfg(unix)]
897 {
898 use std::os::unix::fs::PermissionsExt;
899 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
900 .map(|v| v == "1")
901 .unwrap_or(false)
902 {
903 return Ok(());
904 }
905 let meta = match fs::metadata(path) {
908 Ok(m) => m,
909 Err(_) => return Ok(()),
910 };
911 let mode = meta.permissions().mode();
912 if mode & 0o077 != 0 {
913 return Err(KeyError::InsecureKeyPerms {
914 path: path.to_path_buf(),
915 mode,
916 });
917 }
918 }
919 let _ = path;
920 Ok(())
921}
922
923impl Store {
924 pub fn fix_perms(&self) -> Result<Vec<(PathBuf, u32, u32)>, KeyError> {
931 let mut changed: Vec<(PathBuf, u32, u32)> = Vec::new();
932 #[cfg(unix)]
933 {
934 use std::os::unix::fs::PermissionsExt;
935
936 let dir_meta = fs::metadata(&self.dir)?;
937 let dir_mode = dir_meta.permissions().mode() & 0o777;
938 if dir_mode != 0o700 {
939 fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?;
940 changed.push((self.dir.clone(), dir_mode, 0o700));
941 }
942
943 for entry in fs::read_dir(&self.dir)? {
944 let entry = entry?;
945 let path = entry.path();
946 if !entry.file_type()?.is_file() {
947 continue;
948 }
949 let mode = entry.metadata()?.permissions().mode() & 0o777;
950 if mode != 0o600 {
951 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
952 changed.push((path, mode, 0o600));
953 }
954 }
955 }
956 Ok(changed)
957 }
958}
959
960fn write_file_600(path: &Path, data: &[u8]) -> Result<(), KeyError> {
961 let mut f = fs::OpenOptions::new()
962 .write(true)
963 .create(true)
964 .truncate(true)
965 .open(path)?;
966 f.write_all(data)?;
967 #[cfg(unix)]
969 {
970 use std::os::unix::fs::PermissionsExt;
971 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
972 }
973 Ok(())
974}
975
976fn unix_now() -> u64 {
977 use std::time::{SystemTime, UNIX_EPOCH};
978 SystemTime::now()
979 .duration_since(UNIX_EPOCH)
980 .unwrap_or_default()
981 .as_secs()
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987
988 fn temp_dir_path() -> PathBuf {
989 let mut p = std::env::temp_dir();
990 p.push(format!("treeship-test-{}", {
991 let mut b = [0u8; 4];
992 rand::thread_rng().fill_bytes(&mut b);
993 hex_encode(&b)
994 }));
995 p
996 }
997
998 fn make_store() -> (Store, PathBuf) {
999 let dir = temp_dir_path();
1000 let store = Store::open(&dir).unwrap();
1001 (store, dir)
1002 }
1003
1004 fn cleanup(dir: PathBuf) {
1005 let _ = fs::remove_dir_all(dir);
1006 }
1007
1008 #[test]
1009 fn generate_key() {
1010 let (store, dir) = make_store();
1011 let info = store.generate(true).unwrap();
1012 assert!(info.id.starts_with("key_"));
1013 assert_eq!(info.algorithm, "ed25519");
1014 assert!(!info.fingerprint.is_empty());
1015 assert_eq!(info.public_key.len(), 32);
1016 cleanup(dir);
1017 }
1018
1019 #[test]
1020 fn default_signer_works() {
1021 let (store, dir) = make_store();
1022 store.generate(true).unwrap();
1023 let signer = store.default_signer().unwrap();
1024 assert!(!signer.key_id().is_empty());
1025 let pae = crate::attestation::pae("text/plain", b"test");
1026 let sig = signer.sign(&pae).unwrap();
1027 assert_eq!(sig.len(), 64);
1028 cleanup(dir);
1029 }
1030
1031 #[test]
1032 fn encrypt_decrypt_roundtrip() {
1033 let key = [42u8; 32];
1034 let plaintext = b"super secret private key material here!";
1035 let (enc, nonce) = aes_gcm_encrypt(&key, plaintext).unwrap();
1036 let dec = aes_gcm_decrypt(&key, &enc, &nonce).unwrap();
1037 assert_eq!(dec, plaintext);
1038 }
1039
1040 #[test]
1041 fn decrypt_wrong_key_fails() {
1042 let key = [42u8; 32];
1043 let wrong = [99u8; 32];
1044 let (enc, nonce) = aes_gcm_encrypt(&key, b"secret").unwrap();
1045 assert!(aes_gcm_decrypt(&wrong, &enc, &nonce).is_err());
1046 }
1047
1048 #[test]
1049 fn persist_and_reload() {
1050 let (store, dir) = make_store();
1051 let info = store.generate(true).unwrap();
1052
1053 let store2 = Store::open(&dir).unwrap();
1055 let signer = store2.signer(&info.id).unwrap();
1056 assert_eq!(signer.key_id(), info.id);
1057
1058 let verifier = {
1061 use crate::attestation::Verifier;
1062 use ed25519_dalek::VerifyingKey;
1063 let pk_bytes: [u8; 32] = info.public_key.try_into().unwrap();
1064 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
1065 let mut v = Verifier::new(std::collections::HashMap::new());
1066 v.add_key(info.id.clone(), vk);
1067 v
1068 };
1069
1070 use crate::attestation::sign;
1071 use crate::statements::ActionStatement;
1072 let stmt = ActionStatement::new("agent://test", "tool.call");
1073 let pt = crate::statements::payload_type("action");
1074 let signed = sign(&pt, &stmt, signer.as_ref()).unwrap();
1075 verifier.verify(&signed.envelope).unwrap();
1076
1077 cleanup(dir);
1078 }
1079
1080 #[test]
1081 fn list_keys() {
1082 let (store, dir) = make_store();
1083 store.generate(true).unwrap();
1084 store.generate(false).unwrap();
1085
1086 let keys = store.list().unwrap();
1087 assert_eq!(keys.len(), 2);
1088 assert_eq!(keys.iter().filter(|k| k.is_default).count(), 1);
1089 cleanup(dir);
1090 }
1091
1092 #[test]
1093 fn no_default_key_errors() {
1094 let (store, dir) = make_store();
1095 assert!(store.default_signer().is_err());
1096 cleanup(dir);
1097 }
1098
1099 #[test]
1100 fn rotate_mints_successor_and_links_predecessor() {
1101 let (store, dir) = make_store();
1102 let pred = store.generate(true).unwrap();
1103 assert!(pred.valid_until.is_none(), "fresh key has no expiry");
1104 assert!(pred.successor_key_id.is_none(), "fresh key has no successor");
1105
1106 let result = store
1107 .rotate(None, std::time::Duration::from_secs(3600), true)
1108 .unwrap();
1109
1110 assert_eq!(result.predecessor.id, pred.id);
1112 assert!(result.predecessor.valid_until.is_some(),
1113 "predecessor must get valid_until after rotation");
1114 assert_eq!(result.predecessor.successor_key_id.as_deref(),
1115 Some(result.successor.id.as_str()),
1116 "predecessor must link forward to successor");
1117 assert!(!result.predecessor.is_default,
1118 "after rotation with set_default=true, predecessor is no longer default");
1119
1120 assert_ne!(result.successor.id, pred.id);
1122 assert!(result.successor.valid_until.is_none(), "successor has no expiry yet");
1123 assert!(result.successor.successor_key_id.is_none(), "successor is chain head");
1124 assert!(result.successor.is_default, "successor is the new default");
1125
1126 let listed = store.list().unwrap();
1128 assert_eq!(listed.len(), 2);
1129 let pred_listed = listed.iter().find(|k| k.id == pred.id).unwrap();
1130 assert!(pred_listed.valid_until.is_some());
1131 assert_eq!(pred_listed.successor_key_id.as_deref(),
1132 Some(result.successor.id.as_str()));
1133
1134 cleanup(dir);
1135 }
1136
1137 #[test]
1138 fn rotate_with_set_default_false_keeps_predecessor_active() {
1139 let (store, dir) = make_store();
1140 let pred = store.generate(true).unwrap();
1141
1142 let result = store
1143 .rotate(None, std::time::Duration::from_secs(3600), false)
1144 .unwrap();
1145
1146 assert!(result.predecessor.is_default);
1148 assert!(!result.successor.is_default);
1149 assert_eq!(store.default_key_id().unwrap(), pred.id);
1150
1151 cleanup(dir);
1152 }
1153
1154 #[test]
1155 fn rotate_predecessor_signing_still_works_during_grace_window() {
1156 let (store, dir) = make_store();
1157 let pred = store.generate(true).unwrap();
1158 let _ = store
1159 .rotate(None, std::time::Duration::from_secs(3600), true)
1160 .unwrap();
1161
1162 let signer = store.signer(&pred.id).unwrap();
1166 let pae = crate::attestation::pae("text/plain", b"grace-window-payload");
1167 let sig = signer.sign(&pae).unwrap();
1168 assert_eq!(sig.len(), 64);
1169
1170 cleanup(dir);
1171 }
1172
1173 #[test]
1174 fn rotate_refuses_to_rotate_already_rotated_key() {
1175 let (store, dir) = make_store();
1176 store.generate(true).unwrap();
1177 let r1 = store
1178 .rotate(None, std::time::Duration::from_secs(60), true)
1179 .unwrap();
1180
1181 let err = store
1184 .rotate(Some(&r1.predecessor.id),
1185 std::time::Duration::from_secs(60),
1186 true)
1187 .unwrap_err();
1188 match err {
1189 KeyError::Crypto(msg) => assert!(
1190 msg.contains("already been rotated"),
1191 "error must explain why: {msg}"
1192 ),
1193 other => panic!("expected Crypto error, got {other:?}"),
1194 }
1195 cleanup(dir);
1196 }
1197
1198 #[test]
1199 fn successor_chain_walks_forward() {
1200 let (store, dir) = make_store();
1201 let k0 = store.generate(true).unwrap();
1202 let r1 = store
1203 .rotate(None, std::time::Duration::from_secs(60), true)
1204 .unwrap();
1205 let r2 = store
1206 .rotate(None, std::time::Duration::from_secs(60), true)
1207 .unwrap();
1208
1209 let chain = store.successor_chain(&k0.id).unwrap();
1210 assert_eq!(chain, vec![k0.id.clone(), r1.successor.id.clone(), r2.successor.id.clone()],
1211 "chain must be ordered head -> tail");
1212
1213 let mid = store.successor_chain(&r1.successor.id).unwrap();
1215 assert_eq!(mid, vec![r1.successor.id.clone(), r2.successor.id.clone()]);
1216
1217 let tail = store.successor_chain(&r2.successor.id).unwrap();
1219 assert_eq!(tail, vec![r2.successor.id.clone()]);
1220
1221 cleanup(dir);
1222 }
1223
1224 #[test]
1225 fn valid_keys_at_filters_by_grace_window() {
1226 let (store, dir) = make_store();
1227 let _ = store.generate(true).unwrap();
1228 let result = store
1229 .rotate(None, std::time::Duration::from_secs(3600), true)
1230 .unwrap();
1231
1232 let now = unix_now();
1235 let valid_now = store.valid_keys_at(now).unwrap();
1236 assert_eq!(valid_now.len(), 2, "both predecessor (in grace) and successor should be valid");
1237
1238 let after_grace = unix_now() + 7200;
1240 let valid_after = store.valid_keys_at(after_grace).unwrap();
1241 assert_eq!(valid_after.len(), 1,
1242 "after grace window only successor remains valid");
1243 assert_eq!(valid_after[0].id, result.successor.id);
1244
1245 cleanup(dir);
1246 }
1247
1248 #[test]
1270 fn rotate_cache_reflects_stamped_predecessor_for_retry_safety() {
1271 let (store, dir) = make_store();
1272 let pred = store.generate(true).unwrap();
1273 let _ = store
1274 .rotate(None, std::time::Duration::from_secs(60), true)
1275 .unwrap();
1276
1277 let err = store
1282 .rotate(Some(&pred.id),
1283 std::time::Duration::from_secs(60),
1284 true)
1285 .unwrap_err();
1286 match err {
1287 KeyError::Crypto(msg) => assert!(
1288 msg.contains("already been rotated"),
1289 "cache should reflect stamped predecessor; got: {msg}"
1290 ),
1291 other => panic!("expected Crypto error, got {other:?}"),
1292 }
1293
1294 cleanup(dir);
1295 }
1296
1297 #[test]
1298 fn rotated_predecessor_pointing_at_missing_successor_surfaces_clear_error() {
1299 let (store, dir) = make_store();
1300 store.generate(true).unwrap();
1301 let result = store
1302 .rotate(None, std::time::Duration::from_secs(60), true)
1303 .unwrap();
1304
1305 let succ_path = store.entry_path(&result.successor.id);
1309 fs::remove_file(&succ_path).unwrap();
1310
1311 let store2 = Store::open(&dir).unwrap();
1316 let err = store2.successor_chain(&result.predecessor.id).unwrap_err();
1317 match err {
1318 KeyError::NotFound(id) => assert_eq!(id, result.successor.id),
1319 other => panic!("expected NotFound error, got {other:?}"),
1320 }
1321
1322 cleanup(dir);
1323 }
1324
1325 #[test]
1329 fn legacy_entry_without_lifecycle_fields_loads() {
1330 let (store, dir) = make_store();
1331 let info = store.generate(true).unwrap();
1332
1333 let path = store.entry_path(&info.id);
1336 let raw = fs::read(&path).unwrap();
1337 let mut json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1338 let obj = json.as_object_mut().unwrap();
1339 obj.remove("valid_until");
1340 obj.remove("successor_key_id");
1341 fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap();
1342
1343 let store2 = Store::open(&dir).unwrap();
1346 let listed = store2.list().unwrap();
1347 assert_eq!(listed.len(), 1);
1348 assert!(listed[0].valid_until.is_none(),
1349 "missing valid_until must default to None on legacy entry");
1350 assert!(listed[0].successor_key_id.is_none(),
1351 "missing successor_key_id must default to None on legacy entry");
1352 let signer = store2.default_signer().unwrap();
1353 assert_eq!(signer.key_id(), info.id);
1354
1355 cleanup(dir);
1356 }
1357
1358 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1367
1368 #[test]
1369 #[cfg(unix)]
1370 fn write_entry_creates_file_with_0600() {
1371 use std::os::unix::fs::PermissionsExt;
1372 let (store, dir) = make_store();
1373 let info = store.generate(true).unwrap();
1374 let mode = fs::metadata(store.entry_path(&info.id))
1375 .unwrap()
1376 .permissions()
1377 .mode()
1378 & 0o777;
1379 assert_eq!(mode, 0o600, "freshly written key file must be 0600, got {:o}", mode);
1380 cleanup(dir);
1381 }
1382
1383 #[test]
1384 #[cfg(unix)]
1385 fn signer_refuses_world_readable_key() {
1386 use std::os::unix::fs::PermissionsExt;
1387 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1390 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
1392
1393 let (store, dir) = make_store();
1394 let info = store.generate(true).unwrap();
1395
1396 let path = store.entry_path(&info.id);
1399 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1400
1401 match store.signer(&info.id) {
1402 Err(KeyError::InsecureKeyPerms { path: p, mode }) => {
1403 assert_eq!(p, path);
1404 assert_eq!(mode & 0o777, 0o644);
1405 }
1406 other => panic!("expected InsecureKeyPerms, got {:?}", other.map(|_| "ok")),
1407 }
1408 cleanup(dir);
1409 }
1410
1411 #[test]
1412 #[cfg(unix)]
1413 fn signer_bypass_via_env_var() {
1414 use std::os::unix::fs::PermissionsExt;
1415 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1416 let (store, dir) = make_store();
1417 let info = store.generate(true).unwrap();
1418 let path = store.entry_path(&info.id);
1419 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1420
1421 std::env::set_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS", "1");
1422 let result = store.signer(&info.id);
1423 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
1424
1425 assert!(
1426 result.is_ok(),
1427 "bypass env var must allow signing: {:?}",
1428 result.err()
1429 );
1430 cleanup(dir);
1431 }
1432
1433 #[test]
1434 #[cfg(unix)]
1435 fn fix_perms_repairs_loose_modes() {
1436 use std::os::unix::fs::PermissionsExt;
1437 let (store, dir) = make_store();
1438 let info = store.generate(true).unwrap();
1439 let key_path = store.entry_path(&info.id);
1440
1441 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
1442 fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
1443
1444 let changes = store.fix_perms().unwrap();
1445 assert!(
1448 changes.iter().any(|(p, _, _)| p == &dir),
1449 "dir should be repaired"
1450 );
1451 assert!(
1452 changes.iter().any(|(p, _, _)| p == &key_path),
1453 "key file should be repaired"
1454 );
1455
1456 let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
1457 let key_mode = fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
1458 assert_eq!(dir_mode, 0o700);
1459 assert_eq!(key_mode, 0o600);
1460
1461 store.signer(&info.id).expect("signing must work after fix_perms");
1463
1464 cleanup(dir);
1465 }
1466}