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    /// RFC 3339 timestamp after which signatures by this key should be
30    /// considered stale. `None` means the key has not been rotated and is
31    /// indefinitely valid. Set automatically by `Store::rotate` to
32    /// `now + grace_period` on the predecessor key.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub valid_until: Option<String>,
35    /// If this key was rotated to a successor, the successor's key id.
36    /// Lets verifiers walk a rotation chain forward when validating an old
37    /// receipt against the current keystore. `None` means this is the head
38    /// of its chain.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub successor_key_id: Option<KeyId>,
41}
42
43/// Outcome of a `Store::rotate` call.
44#[derive(Debug, Clone)]
45pub struct RotationResult {
46    /// The key that was rotated. Its `valid_until` is now set.
47    pub predecessor: KeyInfo,
48    /// The freshly minted successor key.
49    pub successor: KeyInfo,
50    /// RFC 3339 timestamp until which the predecessor remains valid for
51    /// signature verification under the grace period. Equal to
52    /// `predecessor.valid_until.unwrap()`.
53    pub grace_period_until: String,
54}
55
56/// Errors from keystore operations.
57#[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    /// Private key file has insecure permissions (group- or world-readable).
66    /// Carries the path and the observed octal mode so the caller can show
67    /// an actionable error. Set `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` to
68    /// bypass during testing or controlled environments.
69    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// --- On-disk formats ---
98
99/// The encrypted representation of one keypair on disk.
100#[derive(Serialize, Deserialize, Clone)]
101struct EncryptedEntry {
102    id:           KeyId,
103    algorithm:    String,
104    created_at:   String,
105    public_key:   Vec<u8>,
106    /// AES-256-GCM ciphertext of the 32-byte Ed25519 secret scalar.
107    enc_priv_key: Vec<u8>,
108    /// 12-byte GCM nonce used when encrypting.
109    nonce:        Vec<u8>,
110    /// RFC 3339 timestamp after which signatures by this key should be
111    /// considered stale. `None` means the key is indefinitely valid.
112    /// Defaulted on deserialization so pre-0.9.5 entry files still load.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    valid_until: Option<String>,
115    /// Successor key id if this key was rotated. Defaulted on
116    /// deserialization for pre-0.9.5 entry files.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    successor_key_id: Option<KeyId>,
119}
120
121/// The manifest file: which keys exist and which is the default.
122#[derive(Serialize, Deserialize, Default)]
123struct Manifest {
124    default_key_id: Option<KeyId>,
125    key_ids:        Vec<KeyId>,
126}
127
128// --- Store ---
129
130/// Local encrypted keystore.
131///
132/// Private keys are encrypted with AES-256-GCM before writing to disk.
133/// The encryption key is derived from a machine-specific secret so key
134/// files are useless if copied to another machine.
135///
136/// v2 will delegate to OS credential stores (Secure Enclave / TPM 2.0).
137pub struct Store {
138    dir:         PathBuf,
139    machine_key: [u8; 32],
140    /// In-memory cache — avoids disk reads on hot paths.
141    cache:       Arc<RwLock<HashMap<KeyId, EncryptedEntry>>>,
142}
143
144impl Store {
145    /// Opens or creates a keystore at `dir`.
146    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    /// Generates a new Ed25519 keypair, encrypts and stores it.
160    /// If `set_default` is true (or there is no current default), makes
161    /// this key the default signing key.
162    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        // Update manifest.
188        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        // Populate cache.
196        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    /// Rotate the current default key (or a specific key) to a freshly
211    /// generated successor.
212    ///
213    /// Mints a new Ed25519 keypair, links the predecessor to it via
214    /// `successor_key_id`, and stamps the predecessor with a `valid_until`
215    /// of `now + grace_period`. The grace window lets verifiers continue to
216    /// accept signatures from the predecessor while clients catch up to
217    /// the new public key.
218    ///
219    /// If `set_default` is true (the typical case -- you rotate because you
220    /// want to start signing with the new key immediately), the successor
221    /// becomes the default. Pass `false` to stage a rotation for review
222    /// without flipping the active signer.
223    ///
224    /// `predecessor_id` may be `None` to rotate the current default. Pass
225    /// an explicit id to rotate a non-default key (e.g. a per-environment
226    /// secondary).
227    ///
228    /// Note on threat model: this is a graceful rotation primitive, not a
229    /// revocation primitive. If the predecessor key is suspected compromised
230    /// the grace_period should be `Duration::ZERO` (or use a future
231    /// `revoke()` call once that lands) so the predecessor's `valid_until`
232    /// is in the past and any verifier honoring the metadata refuses
233    /// further signatures from it.
234    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        // Resolve predecessor: explicit id, else the current default.
241        let pred_id = match predecessor_id {
242            Some(id) => id.to_string(),
243            None => self.default_key_id()?,
244        };
245
246        // Refuse to rotate a key that has already been rotated -- the
247        // chain head is the only valid rotation source. This makes the
248        // operation idempotent in the face of accidental re-runs.
249        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        // Mint the successor. We deliberately do NOT call `self.generate()`
258        // because that path also updates the manifest's default. We need a
259        // single transactional update that sets both predecessor metadata
260        // AND (optionally) the new default in one manifest write.
261        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        // Stamp the predecessor with the grace deadline and link forward.
282        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        // Write order matters for partial-failure recovery. Persist the
290        // successor entry FIRST, then stamp the predecessor pointing at
291        // it. If we wrote the predecessor first and then the successor
292        // write failed, the predecessor's successor_key_id would dangle
293        // at a key that doesn't exist on disk -- and the
294        // already-been-rotated guard would refuse to retry. With this
295        // order:
296        //   - successor write fails: nothing observable changed; retry clean.
297        //   - predecessor write fails: orphan successor key file on disk
298        //     (not yet referenced by manifest or by any other key); retry
299        //     generates a new successor and the orphan is harmless.
300        //   - manifest write fails: predecessor + successor both on disk,
301        //     manifest stale; retry's already-rotated guard catches the
302        //     half-finished state and surfaces a clear error.
303        self.write_entry(&succ_entry)?;
304        self.write_entry(&pred_entry)?;
305
306        // Refresh the cache to mirror the on-disk state we just wrote --
307        // BEFORE the manifest update. If the manifest write fails, the
308        // cache must still match disk so a same-process retry sees the
309        // half-rotated state and the already-rotated guard fires
310        // correctly. Doing this AFTER write_manifest would leave a
311        // window where disk reflects the rotation but the in-memory
312        // cache still serves the unstamped predecessor, and a retry
313        // from the same Store instance would generate a duplicate
314        // successor -- defeating the whole point of the guard.
315        {
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        // Update the manifest: register the new key, optionally promote it.
322        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    /// Walk the rotation chain forward from `id`, returning the ordered
359    /// list of key ids: `[id, successor_of_id, ...]`. The first element is
360    /// always `id` itself. Stops at a key with no `successor_key_id`.
361    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        // Cap iterations at the manifest size to defend against a corrupt
365        // chain that loops back on itself. A well-formed chain is bounded
366        // by the number of keys in the keystore.
367        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    /// Returns the `KeyInfo` for every key whose `valid_until` is either
382    /// unset or strictly after `at_unix_secs`. The result includes both
383    /// rotated-but-still-in-grace predecessors and never-rotated keys.
384    /// Useful for building a verifier's accept-set as of a given time.
385    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    /// Returns a boxed `Signer` for the current default key.
397    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    /// Returns a boxed `Signer` for a specific key ID.
404    ///
405    /// Refuses to load if the on-disk key file has insecure permissions
406    /// (any group or world bits). This is the choke point for *all*
407    /// signing — public-key reads and successor lookups go through
408    /// `read_entry` / `public_key` and are not affected.
409    ///
410    /// Bypass with `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` for controlled
411    /// environments (CI sandboxes, recovery flows). The bypass should
412    /// not be set in normal operation.
413    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    /// Wrap a bare crypto error (typically "MAC verification failed ..." from
431    /// the AES-GCM decrypt path) with a diagnostic and an actionable recovery
432    /// path.
433    ///
434    /// The common failure mode in the wild is a pre-0.9.x keystore whose
435    /// machine-key derivation was seed-file-based. Later versions derive
436    /// the machine key from hostname+username (macOS) or /etc/machine-id
437    /// (Linux), so old ciphertexts can't be MAC-verified with the new key.
438    /// Detecting that case is best-effort: the presence of a legacy seed
439    /// file (`.machineseed` or `machine_seed` inside the keys dir) is a
440    /// strong hint. If we see one, call it out explicitly.
441    fn enrich_crypto_error(&self, raw: String) -> KeyError {
442        // Only enrich on MAC failures -- other errors (I/O, wrong length) are
443        // surfaced as-is because their remediation differs.
444        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        // Resolve the user's ~/.treeship path for the recovery command, so
463        // we give a copy-pasteable command rather than a generic instruction.
464        let ts_dir = std::env::var("HOME")
465            .map(|h| format!("{h}/.treeship"))
466            .unwrap_or_else(|_| "~/.treeship".into());
467
468        // The outer KeyError::Crypto Display impl already prepends
469        // "keys crypto: "; don't double it. Start with the raw MAC error
470        // so the user still sees the underlying cryptographic reason,
471        // then follow with the human-readable diagnosis and recovery.
472        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    /// Returns the default key ID.
486    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    /// Lists all keys.
493    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    /// Sets the default signing key.
513    pub fn set_default(&self, id: &str) -> Result<(), KeyError> {
514        // Verify the key exists before updating the manifest.
515        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    /// Returns the public key bytes for a key ID.
522    pub fn public_key(&self, id: &str) -> Result<Vec<u8>, KeyError> {
523        Ok(self.load_entry(id)?.public_key)
524    }
525
526    // --- private ---
527
528    fn load_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
529        // Check cache first.
530        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
579// --- Crypto helpers ---
580
581/// AES-256-GCM encryption.
582/// Returns (ciphertext, nonce).
583pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
584    // Pure-Rust AES-256-GCM using the block-cipher and GCM construction
585    // from the RustCrypto project. We inline a minimal version here to
586    // avoid pulling in aes-gcm 0.10 which pulls in base64ct ≥ 1.7.
587    //
588    // For now we use a simpler XOR-then-HMAC construction until we can
589    // pin a compatible aes-gcm version. This is replaced with proper
590    // AES-256-GCM once the toolchain constraint is lifted.
591    //
592    // Production note: this is AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC),
593    // which is semantically secure and provides authenticated encryption.
594    use sha2::Sha256;
595
596    let mut nonce = [0u8; 12];
597    rand::thread_rng().fill_bytes(&mut nonce);
598
599    // Derive per-nonce subkeys via HKDF-lite: sha256(key || nonce || "enc")
600    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    // CTR-mode keystream: sha256(enc_key || counter)
611    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    // MAC: sha256(mac_key || nonce || ciphertext)
619    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    // Output: nonce(12) || mac(32) || ciphertext
625    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    // Verify MAC before decrypting (Encrypt-then-MAC).
656    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    // Constant-time comparison.
662    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
679// --- Machine key derivation ---
680
681pub fn derive_machine_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
682    // 1. Linux: /etc/machine-id (stable across reboots)
683    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    // 2. macOS: hostname + username derivation (v1, backward compatible).
694    //
695    // TODO(v0.7.0): Migrate to IOPlatformSerialNumber-based derivation.
696    // The serial number is more stable (survives hostname and username
697    // changes), but switching now would silently invalidate all existing
698    // keys on macOS. A proper migration needs to:
699    //   1. Try the new derivation first.
700    //   2. On decryption failure, fall back to hostname+username.
701    //   3. If legacy succeeds, re-encrypt with the new key and save.
702    // Until that migration tooling is in place, keep hostname+username
703    // as the primary derivation so existing users are not locked out.
704    #[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    // 3. Fallback: random seed file. Co-located with the keystore so a
724    //    project-local keystore (/proj/.treeship/keys/) keeps its seed at
725    //    /proj/.treeship/machine_seed -- never reaching for ~/.treeship.
726    //    A global keystore (~/.treeship/keys/) co-locates to
727    //    ~/.treeship/machine_seed, which is byte-identical to the
728    //    pre-v0.9.6 location, so existing global keystores keep working.
729    //
730    //    Backward-compat read order:
731    //      1. <store_dir>/../machine_seed  (the new co-located path)
732    //      2. ~/.treeship/machine_seed     (the old hardcoded path)
733    //    Write order on first creation:
734    //      1. <store_dir>/../machine_seed  if the parent exists/is writable
735    //      2. ~/.treeship/machine_seed     as a last resort
736    //
737    //    This makes project-local config truly self-contained: an
738    //    isolated /proj keystore can decrypt its own keys even when
739    //    the user's ~/.treeship is corrupt or on a different machine,
740    //    closing the trust-fabric isolation gap that blocked
741    //    project-local smoke tests.
742    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        // Backward-compat: an existing global seed keeps decrypting any
752        // keystore that was encrypted under it (in particular the
753        // standard ~/.treeship/keys/ case where local == global).
754        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        // Prefer creating the seed locally. Falls back to the global
761        // path only when the keystore has no usable parent (rare;
762        // happens when store_dir is "/" or similar pathological input).
763        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
790/// Stable machine key derivation for NEW keys (VI P-256, etc).
791/// Uses hardware identifiers that survive hostname/user changes.
792/// For legacy ship Ed25519 keys, use `derive_machine_key()` instead.
793pub fn derive_machine_key_stable(store_dir: &Path) -> Result<[u8; 32], KeyError> {
794    // 1. Linux: /etc/machine-id
795    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    // 2. macOS: IOPlatformSerialNumber (hardware serial, stable across
808    //    hostname changes, user renames, non-interactive shells)
809    #[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    // 3. Fallback: persistent random seed in ~/.treeship/.internal/
834    //    Separate from key material. Mode 0600.
835    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
870// --- Utility ---
871
872fn 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
890/// Verify a private-key file has restrictive permissions before loading
891/// it for signing. Returns `Ok(())` on non-Unix platforms, when the
892/// `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` escape hatch is set, or when
893/// the file is not group/world accessible. Otherwise returns
894/// `KeyError::InsecureKeyPerms` with the offending path and mode.
895fn 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        // Missing files are reported by the caller as NotFound -- don't
906        // mask that with a perm error.
907        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    /// Repair file permissions on the keystore directory and every file
925    /// inside it: dir to 0700, key entry files and manifest to 0600.
926    /// Used by `treeship doctor --fix`. No-op on non-Unix.
927    ///
928    /// Returns the list of (path, old_mode, new_mode) tuples for paths
929    /// that were actually changed, so the caller can report what it did.
930    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    // Set permissions to 0600 on Unix.
968    #[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        // Open a new Store instance pointing to the same directory.
1054        let store2 = Store::open(&dir).unwrap();
1055        let signer = store2.signer(&info.id).unwrap();
1056        assert_eq!(signer.key_id(), info.id);
1057
1058        // The reloaded signer must produce signatures verifiable with
1059        // the same public key.
1060        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        // Predecessor metadata is updated.
1111        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        // Successor is fresh.
1121        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        // Same metadata visible via list().
1127        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        // Predecessor is still default. Successor exists but is not default.
1147        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        // Predecessor key must still be loadable and capable of signing
1163        // during its grace window. Verifiers can refuse on lifecycle, but
1164        // the keystore must not preemptively destroy material.
1165        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        // Rotating the predecessor again must be refused -- it already
1182        // points at r1.successor. Caller should rotate the chain head.
1183        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        // Mid-chain start: chain from r1.successor should drop k0.
1214        let mid = store.successor_chain(&r1.successor.id).unwrap();
1215        assert_eq!(mid, vec![r1.successor.id.clone(), r2.successor.id.clone()]);
1216
1217        // Tail: just itself.
1218        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        // At time-of-rotation, both keys must be valid -- predecessor is
1233        // mid-grace, successor is freshly minted.
1234        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        // After the grace window expires, only the successor remains.
1239        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    /// Regression: if the successor key file is missing on disk (because a
1249    /// prior rotate() crashed AFTER stamping the predecessor but BEFORE
1250    /// writing the successor), retrying must NOT be wedged. With the
1251    /// successor-first write order this scenario can't be reached by a
1252    /// single-process crash, but we still need to defend against an operator
1253    /// who manually deletes a successor file mid-life. The recovery path
1254    /// is: clear the predecessor's successor pointer (or restore the file
1255    /// from backup) and try again.
1256    /// Regression: even if the manifest write FAILED (say, disk full at
1257    /// the worst possible moment), the in-memory cache must reflect the
1258    /// stamped predecessor that already landed on disk -- otherwise a
1259    /// same-process retry would skip the already-rotated guard and mint
1260    /// a duplicate successor.
1261    ///
1262    /// We can't easily inject a manifest-write failure mid-test, but we
1263    /// can verify the precondition that makes the recovery work: after a
1264    /// successful rotate(), the cache holds the stamped predecessor (so
1265    /// any subsequent rotate would correctly refuse). Combined with the
1266    /// write order (cache update BEFORE manifest write in rotate()),
1267    /// this proves a manifest-write crash leaves the cache aligned with
1268    /// disk, not behind it.
1269    #[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        // The cache must have the stamped predecessor; a same-process
1278        // retry of rotate(predecessor) MUST be refused. If the cache
1279        // were stale (still showing the unstamped predecessor), this
1280        // call would proceed and mint a duplicate successor.
1281        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        // Simulate operator-deleted successor file. The manifest still
1306        // references it, so a cold-cache reader trying to walk the chain
1307        // hits a clear NotFound for the missing key.
1308        let succ_path = store.entry_path(&result.successor.id);
1309        fs::remove_file(&succ_path).unwrap();
1310
1311        // Open a fresh Store instance so the cache doesn't paper over the
1312        // missing on-disk entry. successor_chain() walks via load_entry;
1313        // the missing file must produce KeyError::NotFound, not a panic
1314        // and not an infinite loop.
1315        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    /// Pre-0.9.5 entry files lack `valid_until` and `successor_key_id`.
1326    /// They must still deserialize cleanly and be visible via `list()` /
1327    /// `default_signer()` etc.
1328    #[test]
1329    fn legacy_entry_without_lifecycle_fields_loads() {
1330        let (store, dir) = make_store();
1331        let info = store.generate(true).unwrap();
1332
1333        // Re-serialize the on-disk entry without the new fields, simulating
1334        // a file created by a 0.9.4 or earlier CLI.
1335        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        // A fresh Store (cold cache) must still load the entry and treat
1344        // the missing fields as None.
1345        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    // --- keystore permission hardening (PR 1) -------------------------------
1359
1360    // The perm tests below mutate the process-global env var
1361    // TREESHIP_ALLOW_INSECURE_KEY_PERMS. cargo test runs cases in
1362    // parallel by default, so without serialization one test can set
1363    // the bypass while another expects it unset and racefully fail.
1364    // This mutex serializes them; everything else in the file remains
1365    // parallel-safe.
1366    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        // Mutex prevents the bypass var from being toggled by a
1388        // sibling test mid-flight (cargo test parallel runner).
1389        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1390        // Make sure the bypass var is not leaking from the host env.
1391        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        // Loosen perms on the key file -- simulates a checkout, scp, or
1397        // shared-volume mishap.
1398        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        // dir + key file + manifest = 3 paths to fix (manifest may already be 0600
1446        // depending on Manifest write path; we only assert the loose ones moved).
1447        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        // After repair, signing must work again.
1462        store.signer(&info.id).expect("signing must work after fix_perms");
1463
1464        cleanup(dir);
1465    }
1466}