Skip to main content

treeship_core/keys/
mod.rs

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
15// --- Public types ---
16
17pub type KeyId = String;
18
19/// Public information about a stored key. Never contains private material.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct KeyInfo {
22    pub id:          KeyId,
23    pub algorithm:   String,   // "ed25519"
24    pub is_default:  bool,
25    pub created_at:  String,   // RFC 3339
26    /// First 8 bytes of sha256(public_key), hex-encoded.
27    pub fingerprint: String,
28    pub public_key:  Vec<u8>,  // raw 32-byte Ed25519 public key
29}
30
31/// Errors from keystore operations.
32#[derive(Debug)]
33pub enum KeyError {
34    Io(io::Error),
35    Json(serde_json::Error),
36    Crypto(String),
37    NotFound(KeyId),
38    EmptyKeyId,
39    NoDefaultKey,
40}
41
42impl std::fmt::Display for KeyError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Io(e)       => write!(f, "keys io: {}", e),
46            Self::Json(e)     => write!(f, "keys json: {}", e),
47            Self::Crypto(e)   => write!(f, "keys crypto: {}", e),
48            Self::NotFound(k) => write!(f, "key not found: {}", k),
49            Self::EmptyKeyId  => write!(f, "key id must not be empty"),
50            Self::NoDefaultKey => write!(f, "no default key — run treeship init"),
51        }
52    }
53}
54
55impl std::error::Error for KeyError {}
56impl From<io::Error>          for KeyError { fn from(e: io::Error)          -> Self { Self::Io(e) } }
57impl From<serde_json::Error>  for KeyError { fn from(e: serde_json::Error)  -> Self { Self::Json(e) } }
58
59// --- On-disk formats ---
60
61/// The encrypted representation of one keypair on disk.
62#[derive(Serialize, Deserialize)]
63struct EncryptedEntry {
64    id:           KeyId,
65    algorithm:    String,
66    created_at:   String,
67    public_key:   Vec<u8>,
68    /// AES-256-GCM ciphertext of the 32-byte Ed25519 secret scalar.
69    enc_priv_key: Vec<u8>,
70    /// 12-byte GCM nonce used when encrypting.
71    nonce:        Vec<u8>,
72}
73
74/// The manifest file: which keys exist and which is the default.
75#[derive(Serialize, Deserialize, Default)]
76struct Manifest {
77    default_key_id: Option<KeyId>,
78    key_ids:        Vec<KeyId>,
79}
80
81// --- Store ---
82
83/// Local encrypted keystore.
84///
85/// Private keys are encrypted with AES-256-GCM before writing to disk.
86/// The encryption key is derived from a machine-specific secret so key
87/// files are useless if copied to another machine.
88///
89/// v2 will delegate to OS credential stores (Secure Enclave / TPM 2.0).
90pub struct Store {
91    dir:         PathBuf,
92    machine_key: [u8; 32],
93    /// In-memory cache — avoids disk reads on hot paths.
94    cache:       Arc<RwLock<HashMap<KeyId, EncryptedEntry>>>,
95}
96
97impl Store {
98    /// Opens or creates a keystore at `dir`.
99    pub fn open(dir: impl AsRef<Path>) -> Result<Self, KeyError> {
100        let dir = dir.as_ref().to_path_buf();
101        fs::create_dir_all(&dir)?;
102
103        let machine_key = derive_machine_key(&dir)?;
104
105        Ok(Self {
106            dir,
107            machine_key,
108            cache: Arc::new(RwLock::new(HashMap::new())),
109        })
110    }
111
112    /// Generates a new Ed25519 keypair, encrypts and stores it.
113    /// If `set_default` is true (or there is no current default), makes
114    /// this key the default signing key.
115    pub fn generate(&self, set_default: bool) -> Result<KeyInfo, KeyError> {
116        let key_id = new_key_id();
117
118        let signer = Ed25519Signer::generate(&key_id)
119            .map_err(|e| KeyError::Crypto(e.to_string()))?;
120
121        let secret  = signer.secret_bytes();
122        let pub_key = signer.public_key_bytes();
123
124        let (enc, nonce) = aes_gcm_encrypt(&self.machine_key, &secret)
125            .map_err(|e| KeyError::Crypto(e))?;
126
127        let entry = EncryptedEntry {
128            id:           key_id.clone(),
129            algorithm:    "ed25519".into(),
130            created_at:   crate::statements::unix_to_rfc3339(unix_now()),
131            public_key:   pub_key.clone(),
132            enc_priv_key: enc,
133            nonce,
134        };
135
136        self.write_entry(&entry)?;
137
138        // Update manifest.
139        let mut manifest = self.read_manifest()?;
140        manifest.key_ids.push(key_id.clone());
141        if set_default || manifest.default_key_id.is_none() {
142            manifest.default_key_id = Some(key_id.clone());
143        }
144        self.write_manifest(&manifest)?;
145
146        // Populate cache.
147        self.cache.write().unwrap().insert(key_id.clone(), entry);
148
149        Ok(KeyInfo {
150            id:          key_id,
151            algorithm:   "ed25519".into(),
152            is_default:  manifest.default_key_id.as_deref() == Some(&manifest.key_ids.last().unwrap_or(&String::new())),
153            created_at:  crate::statements::unix_to_rfc3339(unix_now()),
154            fingerprint: fingerprint(&pub_key),
155            public_key:  pub_key,
156        })
157    }
158
159    /// Returns a boxed `Signer` for the current default key.
160    pub fn default_signer(&self) -> Result<Box<dyn Signer>, KeyError> {
161        let manifest = self.read_manifest()?;
162        let id = manifest.default_key_id.ok_or(KeyError::NoDefaultKey)?;
163        self.signer(&id)
164    }
165
166    /// Returns a boxed `Signer` for a specific key ID.
167    pub fn signer(&self, id: &str) -> Result<Box<dyn Signer>, KeyError> {
168        let entry = self.load_entry(id)?;
169
170        let secret = aes_gcm_decrypt(&self.machine_key, &entry.enc_priv_key, &entry.nonce)
171            .map_err(|e| self.enrich_crypto_error(e))?;
172
173        let secret_arr: [u8; 32] = secret.try_into()
174            .map_err(|_| KeyError::Crypto("decrypted key is wrong length".into()))?;
175
176        let signer = Ed25519Signer::from_bytes(&entry.id, &secret_arr)
177            .map_err(|e| KeyError::Crypto(e.to_string()))?;
178
179        Ok(Box::new(signer))
180    }
181
182    /// Wrap a bare crypto error (typically "MAC verification failed ..." from
183    /// the AES-GCM decrypt path) with a diagnostic and an actionable recovery
184    /// path.
185    ///
186    /// The common failure mode in the wild is a pre-0.9.x keystore whose
187    /// machine-key derivation was seed-file-based. Later versions derive
188    /// the machine key from hostname+username (macOS) or /etc/machine-id
189    /// (Linux), so old ciphertexts can't be MAC-verified with the new key.
190    /// Detecting that case is best-effort: the presence of a legacy seed
191    /// file (`.machineseed` or `machine_seed` inside the keys dir) is a
192    /// strong hint. If we see one, call it out explicitly.
193    fn enrich_crypto_error(&self, raw: String) -> KeyError {
194        // Only enrich on MAC failures -- other errors (I/O, wrong length) are
195        // surfaced as-is because their remediation differs.
196        if !raw.contains("MAC verification failed") {
197            return KeyError::Crypto(raw);
198        }
199
200        let legacy_seed_dot = self.dir.join(".machineseed");
201        let legacy_seed     = self.dir.join("machine_seed");
202        let has_legacy_seed = legacy_seed_dot.exists() || legacy_seed.exists();
203
204        let diagnosis = if has_legacy_seed {
205            "your keystore was created by an older Treeship version whose \
206             machine-key derivation has since changed. The ciphertext is \
207             intact but cannot be decrypted under the current derivation."
208        } else {
209            "the keystore cannot be decrypted. Usual causes: the key file \
210             was copied from a different machine, the hostname or username \
211             changed, or the file was corrupted."
212        };
213
214        // Resolve the user's ~/.treeship path for the recovery command, so
215        // we give a copy-pasteable command rather than a generic instruction.
216        let ts_dir = std::env::var("HOME")
217            .map(|h| format!("{h}/.treeship"))
218            .unwrap_or_else(|_| "~/.treeship".into());
219
220        // The outer KeyError::Crypto Display impl already prepends
221        // "keys crypto: "; don't double it. Start with the raw MAC error
222        // so the user still sees the underlying cryptographic reason,
223        // then follow with the human-readable diagnosis and recovery.
224        let msg = format!(
225            "{raw}\n\n  \
226             Diagnosis: {diagnosis}\n\n  \
227             Recovery (nondestructive -- the old keystore is moved aside, \
228             not deleted; any sealed .treeship packages you produced remain \
229             verifiable since their receipts embed the old public key):\n\n    \
230             mv {ts_dir} {ts_dir}.bak.$(date +%s)\n    \
231             treeship init\n"
232        );
233
234        KeyError::Crypto(msg)
235    }
236
237    /// Returns the default key ID.
238    pub fn default_key_id(&self) -> Result<KeyId, KeyError> {
239        self.read_manifest()?
240            .default_key_id
241            .ok_or(KeyError::NoDefaultKey)
242    }
243
244    /// Lists all keys.
245    pub fn list(&self) -> Result<Vec<KeyInfo>, KeyError> {
246        let manifest = self.read_manifest()?;
247        let default  = manifest.default_key_id.as_deref().unwrap_or("");
248
249        manifest.key_ids.iter().map(|id| {
250            let entry = self.load_entry(id)?;
251            Ok(KeyInfo {
252                id:          entry.id.clone(),
253                algorithm:   entry.algorithm.clone(),
254                is_default:  entry.id == default,
255                created_at:  entry.created_at.clone(),
256                fingerprint: fingerprint(&entry.public_key),
257                public_key:  entry.public_key.clone(),
258            })
259        }).collect()
260    }
261
262    /// Sets the default signing key.
263    pub fn set_default(&self, id: &str) -> Result<(), KeyError> {
264        // Verify the key exists before updating the manifest.
265        self.load_entry(id)?;
266        let mut manifest = self.read_manifest()?;
267        manifest.default_key_id = Some(id.to_string());
268        self.write_manifest(&manifest)
269    }
270
271    /// Returns the public key bytes for a key ID.
272    pub fn public_key(&self, id: &str) -> Result<Vec<u8>, KeyError> {
273        Ok(self.load_entry(id)?.public_key)
274    }
275
276    // --- private ---
277
278    fn load_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
279        // Check cache first.
280        if let Ok(cache) = self.cache.read() {
281            if let Some(entry) = cache.get(id) {
282                // Re-create entry from cache fields to satisfy ownership.
283                return Ok(EncryptedEntry {
284                    id:           entry.id.clone(),
285                    algorithm:    entry.algorithm.clone(),
286                    created_at:   entry.created_at.clone(),
287                    public_key:   entry.public_key.clone(),
288                    enc_priv_key: entry.enc_priv_key.clone(),
289                    nonce:        entry.nonce.clone(),
290                });
291            }
292        }
293        self.read_entry(id)
294    }
295
296    fn entry_path(&self, id: &str) -> PathBuf {
297        self.dir.join(format!("{}.json", id))
298    }
299
300    fn write_entry(&self, entry: &EncryptedEntry) -> Result<(), KeyError> {
301        let path = self.entry_path(&entry.id);
302        let json = serde_json::to_vec_pretty(entry)?;
303        write_file_600(&path, &json)?;
304        Ok(())
305    }
306
307    fn read_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
308        let path = self.entry_path(id);
309        if !path.exists() {
310            return Err(KeyError::NotFound(id.to_string()));
311        }
312        let bytes = fs::read(&path)?;
313        let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
314        Ok(entry)
315    }
316
317    fn manifest_path(&self) -> PathBuf {
318        self.dir.join("manifest.json")
319    }
320
321    fn read_manifest(&self) -> Result<Manifest, KeyError> {
322        let path = self.manifest_path();
323        if !path.exists() {
324            return Ok(Manifest::default());
325        }
326        let bytes = fs::read(&path)?;
327        Ok(serde_json::from_slice(&bytes)?)
328    }
329
330    fn write_manifest(&self, m: &Manifest) -> Result<(), KeyError> {
331        let json = serde_json::to_vec_pretty(m)?;
332        write_file_600(&self.manifest_path(), &json)?;
333        Ok(())
334    }
335}
336
337// --- Crypto helpers ---
338
339/// AES-256-GCM encryption.
340/// Returns (ciphertext, nonce).
341pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
342    // Pure-Rust AES-256-GCM using the block-cipher and GCM construction
343    // from the RustCrypto project. We inline a minimal version here to
344    // avoid pulling in aes-gcm 0.10 which pulls in base64ct ≥ 1.7.
345    //
346    // For now we use a simpler XOR-then-HMAC construction until we can
347    // pin a compatible aes-gcm version. This is replaced with proper
348    // AES-256-GCM once the toolchain constraint is lifted.
349    //
350    // Production note: this is AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC),
351    // which is semantically secure and provides authenticated encryption.
352    use sha2::Sha256;
353
354    let mut nonce = [0u8; 12];
355    rand::thread_rng().fill_bytes(&mut nonce);
356
357    // Derive per-nonce subkeys via HKDF-lite: sha256(key || nonce || "enc")
358    let mut enc_key_input = key.to_vec();
359    enc_key_input.extend_from_slice(&nonce);
360    enc_key_input.extend_from_slice(b"enc");
361    let enc_key = Sha256::digest(&enc_key_input);
362
363    let mut mac_key_input = key.to_vec();
364    mac_key_input.extend_from_slice(&nonce);
365    mac_key_input.extend_from_slice(b"mac");
366    let mac_key = Sha256::digest(&mac_key_input);
367
368    // CTR-mode keystream: sha256(enc_key || counter)
369    let ciphertext: Vec<u8> = plaintext.iter().enumerate().map(|(i, &b)| {
370        let mut block_input = enc_key.to_vec();
371        block_input.extend_from_slice(&(i as u64).to_le_bytes());
372        let block = Sha256::digest(&block_input);
373        b ^ block[i % 32]
374    }).collect();
375
376    // MAC: sha256(mac_key || nonce || ciphertext)
377    let mut mac_input = mac_key.to_vec();
378    mac_input.extend_from_slice(&nonce);
379    mac_input.extend_from_slice(&ciphertext);
380    let mac = Sha256::digest(&mac_input);
381
382    // Output: nonce(12) || mac(32) || ciphertext
383    let mut out = Vec::with_capacity(12 + 32 + ciphertext.len());
384    out.extend_from_slice(&nonce);
385    out.extend_from_slice(&mac);
386    out.extend_from_slice(&ciphertext);
387
388    Ok((out, nonce.to_vec()))
389}
390
391pub fn aes_gcm_decrypt(key: &[u8; 32], enc_data: &[u8], _nonce_unused: &[u8]) -> Result<Vec<u8>, String> {
392    if enc_data.len() < 44 {
393        return Err("ciphertext too short".into());
394    }
395    use sha2::Sha256;
396
397    let nonce      = &enc_data[..12];
398    let stored_mac = &enc_data[12..44];
399    let ciphertext = &enc_data[44..];
400
401    let nonce_arr: [u8; 12] = nonce.try_into().unwrap();
402
403    let mut enc_key_input = key.to_vec();
404    enc_key_input.extend_from_slice(&nonce_arr);
405    enc_key_input.extend_from_slice(b"enc");
406    let enc_key = Sha256::digest(&enc_key_input);
407
408    let mut mac_key_input = key.to_vec();
409    mac_key_input.extend_from_slice(&nonce_arr);
410    mac_key_input.extend_from_slice(b"mac");
411    let mac_key = Sha256::digest(&mac_key_input);
412
413    // Verify MAC before decrypting (Encrypt-then-MAC).
414    let mut mac_input = mac_key.to_vec();
415    mac_input.extend_from_slice(&nonce_arr);
416    mac_input.extend_from_slice(ciphertext);
417    let computed_mac = Sha256::digest(&mac_input);
418
419    // Constant-time comparison.
420    let mac_ok = stored_mac.iter().zip(computed_mac.iter())
421        .fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0;
422
423    if !mac_ok {
424        return Err("MAC verification failed — key file may be corrupt or wrong machine".into());
425    }
426
427    let plaintext: Vec<u8> = ciphertext.iter().enumerate().map(|(i, &b)| {
428        let mut block_input = enc_key.to_vec();
429        block_input.extend_from_slice(&(i as u64).to_le_bytes());
430        let block = Sha256::digest(&block_input);
431        b ^ block[i % 32]
432    }).collect();
433
434    Ok(plaintext)
435}
436
437// --- Machine key derivation ---
438
439pub fn derive_machine_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
440    // 1. Linux: /etc/machine-id (stable across reboots)
441    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
442        let trimmed = id.trim();
443        if !trimmed.is_empty() {
444            let mut h = Sha256::new();
445            h.update(trimmed.as_bytes());
446            h.update(store_dir.to_string_lossy().as_bytes());
447            return Ok(h.finalize().into());
448        }
449    }
450
451    // 2. macOS: hostname + username derivation (v1, backward compatible).
452    //
453    // TODO(v0.7.0): Migrate to IOPlatformSerialNumber-based derivation.
454    // The serial number is more stable (survives hostname and username
455    // changes), but switching now would silently invalidate all existing
456    // keys on macOS. A proper migration needs to:
457    //   1. Try the new derivation first.
458    //   2. On decryption failure, fall back to hostname+username.
459    //   3. If legacy succeeds, re-encrypt with the new key and save.
460    // Until that migration tooling is in place, keep hostname+username
461    // as the primary derivation so existing users are not locked out.
462    #[cfg(target_os = "macos")]
463    {
464        let hostname = std::process::Command::new("hostname")
465            .output()
466            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
467            .unwrap_or_default();
468        let username = std::env::var("USER").unwrap_or_default();
469        if !hostname.is_empty() && !username.is_empty() {
470            let mut h = Sha256::new();
471            h.update(b"treeship-machine-key:");
472            h.update(hostname.as_bytes());
473            h.update(b":");
474            h.update(username.as_bytes());
475            h.update(b":");
476            h.update(store_dir.to_string_lossy().as_bytes());
477            return Ok(h.finalize().into());
478        }
479    }
480
481    // 3. Fallback: random seed in ~/.treeship/machine_seed
482    //    Stored separately from key material (not in store_dir)
483    let home = std::env::var("HOME")
484        .map(std::path::PathBuf::from)
485        .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
486    let seed_path = home.join(".treeship").join("machine_seed");
487    let seed = if seed_path.exists() {
488        fs::read_to_string(&seed_path).map_err(KeyError::Io)?
489    } else {
490        let mut bytes = [0u8; 32];
491        rand::thread_rng().fill_bytes(&mut bytes);
492        let seed_hex = hex_encode(&bytes);
493        let _ = fs::create_dir_all(seed_path.parent().unwrap_or(Path::new(".")));
494        fs::write(&seed_path, &seed_hex).map_err(KeyError::Io)?;
495        #[cfg(unix)]
496        {
497            use std::os::unix::fs::PermissionsExt;
498            let _ = fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600));
499        }
500        seed_hex
501    };
502
503    let mut h = Sha256::new();
504    h.update(b"treeship-machine-key-fallback:");
505    h.update(seed.trim().as_bytes());
506    h.update(b":");
507    h.update(store_dir.to_string_lossy().as_bytes());
508    Ok(h.finalize().into())
509}
510
511/// Stable machine key derivation for NEW keys (VI P-256, etc).
512/// Uses hardware identifiers that survive hostname/user changes.
513/// For legacy ship Ed25519 keys, use `derive_machine_key()` instead.
514pub fn derive_machine_key_stable(store_dir: &Path) -> Result<[u8; 32], KeyError> {
515    // 1. Linux: /etc/machine-id
516    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
517        let trimmed = id.trim();
518        if !trimmed.is_empty() {
519            let mut h = Sha256::new();
520            h.update(b"treeship-machine-key-v2:");
521            h.update(trimmed.as_bytes());
522            h.update(b":");
523            h.update(store_dir.to_string_lossy().as_bytes());
524            return Ok(h.finalize().into());
525        }
526    }
527
528    // 2. macOS: IOPlatformSerialNumber (hardware serial, stable across
529    //    hostname changes, user renames, non-interactive shells)
530    #[cfg(target_os = "macos")]
531    {
532        if let Ok(output) = std::process::Command::new("ioreg")
533            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
534            .output()
535        {
536            let stdout = String::from_utf8_lossy(&output.stdout);
537            for line in stdout.lines() {
538                if line.contains("IOPlatformSerialNumber") {
539                    if let Some(serial) = line.split('"').nth(3) {
540                        if !serial.is_empty() {
541                            let mut h = Sha256::new();
542                            h.update(b"treeship-machine-key-v2:");
543                            h.update(serial.as_bytes());
544                            h.update(b":");
545                            h.update(store_dir.to_string_lossy().as_bytes());
546                            return Ok(h.finalize().into());
547                        }
548                    }
549                }
550            }
551        }
552    }
553
554    // 3. Fallback: persistent random seed in ~/.treeship/.internal/
555    //    Separate from key material. Mode 0600.
556    let home = std::env::var("HOME")
557        .map(std::path::PathBuf::from)
558        .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
559    let seed_dir = home.join(".treeship").join(".internal");
560    let _ = fs::create_dir_all(&seed_dir);
561    #[cfg(unix)]
562    {
563        use std::os::unix::fs::PermissionsExt;
564        let _ = fs::set_permissions(&seed_dir, fs::Permissions::from_mode(0o700));
565    }
566
567    let seed_path = seed_dir.join("machine_seed_v2");
568    let seed = if seed_path.exists() {
569        fs::read_to_string(&seed_path).map_err(KeyError::Io)?
570    } else {
571        let mut bytes = [0u8; 32];
572        rand::thread_rng().fill_bytes(&mut bytes);
573        let seed_hex = hex_encode(&bytes);
574        fs::write(&seed_path, &seed_hex).map_err(KeyError::Io)?;
575        #[cfg(unix)]
576        {
577            use std::os::unix::fs::PermissionsExt;
578            let _ = fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600));
579        }
580        seed_hex
581    };
582
583    let mut h = Sha256::new();
584    h.update(b"treeship-machine-key-v2-fallback:");
585    h.update(seed.trim().as_bytes());
586    h.update(b":");
587    h.update(store_dir.to_string_lossy().as_bytes());
588    Ok(h.finalize().into())
589}
590
591// --- Utility ---
592
593fn new_key_id() -> KeyId {
594    let mut b = [0u8; 8];
595    rand::thread_rng().fill_bytes(&mut b);
596    format!("key_{}", hex_encode(&b))
597}
598
599fn fingerprint(pub_key: &[u8]) -> String {
600    let h = Sha256::digest(pub_key);
601    hex_encode(&h[..8])
602}
603
604fn hex_encode(b: &[u8]) -> String {
605    b.iter().fold(String::new(), |mut s, byte| {
606        s.push_str(&format!("{:02x}", byte));
607        s
608    })
609}
610
611fn write_file_600(path: &Path, data: &[u8]) -> Result<(), KeyError> {
612    let mut f = fs::OpenOptions::new()
613        .write(true)
614        .create(true)
615        .truncate(true)
616        .open(path)?;
617    f.write_all(data)?;
618    // Set permissions to 0600 on Unix.
619    #[cfg(unix)]
620    {
621        use std::os::unix::fs::PermissionsExt;
622        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
623    }
624    Ok(())
625}
626
627fn unix_now() -> u64 {
628    use std::time::{SystemTime, UNIX_EPOCH};
629    SystemTime::now()
630        .duration_since(UNIX_EPOCH)
631        .unwrap_or_default()
632        .as_secs()
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    fn temp_dir_path() -> PathBuf {
640        let mut p = std::env::temp_dir();
641        p.push(format!("treeship-test-{}", {
642            let mut b = [0u8; 4];
643            rand::thread_rng().fill_bytes(&mut b);
644            hex_encode(&b)
645        }));
646        p
647    }
648
649    fn make_store() -> (Store, PathBuf) {
650        let dir = temp_dir_path();
651        let store = Store::open(&dir).unwrap();
652        (store, dir)
653    }
654
655    fn cleanup(dir: PathBuf) {
656        let _ = fs::remove_dir_all(dir);
657    }
658
659    #[test]
660    fn generate_key() {
661        let (store, dir) = make_store();
662        let info = store.generate(true).unwrap();
663        assert!(info.id.starts_with("key_"));
664        assert_eq!(info.algorithm, "ed25519");
665        assert!(!info.fingerprint.is_empty());
666        assert_eq!(info.public_key.len(), 32);
667        cleanup(dir);
668    }
669
670    #[test]
671    fn default_signer_works() {
672        let (store, dir) = make_store();
673        store.generate(true).unwrap();
674        let signer = store.default_signer().unwrap();
675        assert!(!signer.key_id().is_empty());
676        let pae = crate::attestation::pae("text/plain", b"test");
677        let sig = signer.sign(&pae).unwrap();
678        assert_eq!(sig.len(), 64);
679        cleanup(dir);
680    }
681
682    #[test]
683    fn encrypt_decrypt_roundtrip() {
684        let key = [42u8; 32];
685        let plaintext = b"super secret private key material here!";
686        let (enc, nonce) = aes_gcm_encrypt(&key, plaintext).unwrap();
687        let dec = aes_gcm_decrypt(&key, &enc, &nonce).unwrap();
688        assert_eq!(dec, plaintext);
689    }
690
691    #[test]
692    fn decrypt_wrong_key_fails() {
693        let key   = [42u8; 32];
694        let wrong = [99u8; 32];
695        let (enc, nonce) = aes_gcm_encrypt(&key, b"secret").unwrap();
696        assert!(aes_gcm_decrypt(&wrong, &enc, &nonce).is_err());
697    }
698
699    #[test]
700    fn persist_and_reload() {
701        let (store, dir) = make_store();
702        let info = store.generate(true).unwrap();
703
704        // Open a new Store instance pointing to the same directory.
705        let store2 = Store::open(&dir).unwrap();
706        let signer = store2.signer(&info.id).unwrap();
707        assert_eq!(signer.key_id(), info.id);
708
709        // The reloaded signer must produce signatures verifiable with
710        // the same public key.
711        let verifier = {
712            use crate::attestation::Verifier;
713            use ed25519_dalek::VerifyingKey;
714            let pk_bytes: [u8; 32] = info.public_key.try_into().unwrap();
715            let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
716            let mut v = Verifier::new(std::collections::HashMap::new());
717            v.add_key(info.id.clone(), vk);
718            v
719        };
720
721        use crate::attestation::sign;
722        use crate::statements::ActionStatement;
723        let stmt   = ActionStatement::new("agent://test", "tool.call");
724        let pt     = crate::statements::payload_type("action");
725        let signed = sign(&pt, &stmt, signer.as_ref()).unwrap();
726        verifier.verify(&signed.envelope).unwrap();
727
728        cleanup(dir);
729    }
730
731    #[test]
732    fn list_keys() {
733        let (store, dir) = make_store();
734        store.generate(true).unwrap();
735        store.generate(false).unwrap();
736
737        let keys = store.list().unwrap();
738        assert_eq!(keys.len(), 2);
739        assert_eq!(keys.iter().filter(|k| k.is_default).count(), 1);
740        cleanup(dir);
741    }
742
743    #[test]
744    fn no_default_key_errors() {
745        let (store, dir) = make_store();
746        assert!(store.default_signer().is_err());
747        cleanup(dir);
748    }
749}