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}
66
67impl std::fmt::Display for KeyError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Io(e)       => write!(f, "keys io: {}", e),
71            Self::Json(e)     => write!(f, "keys json: {}", e),
72            Self::Crypto(e)   => write!(f, "keys crypto: {}", e),
73            Self::NotFound(k) => write!(f, "key not found: {}", k),
74            Self::EmptyKeyId  => write!(f, "key id must not be empty"),
75            Self::NoDefaultKey => write!(f, "no default key — run treeship init"),
76        }
77    }
78}
79
80impl std::error::Error for KeyError {}
81impl From<io::Error>          for KeyError { fn from(e: io::Error)          -> Self { Self::Io(e) } }
82impl From<serde_json::Error>  for KeyError { fn from(e: serde_json::Error)  -> Self { Self::Json(e) } }
83
84// --- On-disk formats ---
85
86/// The encrypted representation of one keypair on disk.
87#[derive(Serialize, Deserialize, Clone)]
88struct EncryptedEntry {
89    id:           KeyId,
90    algorithm:    String,
91    created_at:   String,
92    public_key:   Vec<u8>,
93    /// AES-256-GCM ciphertext of the 32-byte Ed25519 secret scalar.
94    enc_priv_key: Vec<u8>,
95    /// 12-byte GCM nonce used when encrypting.
96    nonce:        Vec<u8>,
97    /// RFC 3339 timestamp after which signatures by this key should be
98    /// considered stale. `None` means the key is indefinitely valid.
99    /// Defaulted on deserialization so pre-0.9.5 entry files still load.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    valid_until: Option<String>,
102    /// Successor key id if this key was rotated. Defaulted on
103    /// deserialization for pre-0.9.5 entry files.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    successor_key_id: Option<KeyId>,
106}
107
108/// The manifest file: which keys exist and which is the default.
109#[derive(Serialize, Deserialize, Default)]
110struct Manifest {
111    default_key_id: Option<KeyId>,
112    key_ids:        Vec<KeyId>,
113}
114
115// --- Store ---
116
117/// Local encrypted keystore.
118///
119/// Private keys are encrypted with AES-256-GCM before writing to disk.
120/// The encryption key is derived from a machine-specific secret so key
121/// files are useless if copied to another machine.
122///
123/// v2 will delegate to OS credential stores (Secure Enclave / TPM 2.0).
124pub struct Store {
125    dir:         PathBuf,
126    machine_key: [u8; 32],
127    /// In-memory cache — avoids disk reads on hot paths.
128    cache:       Arc<RwLock<HashMap<KeyId, EncryptedEntry>>>,
129}
130
131impl Store {
132    /// Opens or creates a keystore at `dir`.
133    pub fn open(dir: impl AsRef<Path>) -> Result<Self, KeyError> {
134        let dir = dir.as_ref().to_path_buf();
135        fs::create_dir_all(&dir)?;
136
137        let machine_key = derive_machine_key(&dir)?;
138
139        Ok(Self {
140            dir,
141            machine_key,
142            cache: Arc::new(RwLock::new(HashMap::new())),
143        })
144    }
145
146    /// Generates a new Ed25519 keypair, encrypts and stores it.
147    /// If `set_default` is true (or there is no current default), makes
148    /// this key the default signing key.
149    pub fn generate(&self, set_default: bool) -> Result<KeyInfo, KeyError> {
150        let key_id = new_key_id();
151
152        let signer = Ed25519Signer::generate(&key_id)
153            .map_err(|e| KeyError::Crypto(e.to_string()))?;
154
155        let secret  = signer.secret_bytes();
156        let pub_key = signer.public_key_bytes();
157
158        let (enc, nonce) = aes_gcm_encrypt(&self.machine_key, &secret)
159            .map_err(|e| KeyError::Crypto(e))?;
160
161        let entry = EncryptedEntry {
162            id:               key_id.clone(),
163            algorithm:        "ed25519".into(),
164            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
165            public_key:       pub_key.clone(),
166            enc_priv_key:     enc,
167            nonce,
168            valid_until:      None,
169            successor_key_id: None,
170        };
171
172        self.write_entry(&entry)?;
173
174        // Update manifest.
175        let mut manifest = self.read_manifest()?;
176        manifest.key_ids.push(key_id.clone());
177        if set_default || manifest.default_key_id.is_none() {
178            manifest.default_key_id = Some(key_id.clone());
179        }
180        self.write_manifest(&manifest)?;
181
182        // Populate cache.
183        self.cache.write().unwrap().insert(key_id.clone(), entry);
184
185        Ok(KeyInfo {
186            id:               key_id.clone(),
187            algorithm:        "ed25519".into(),
188            is_default:       manifest.default_key_id.as_deref() == Some(key_id.as_str()),
189            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
190            fingerprint:      fingerprint(&pub_key),
191            public_key:       pub_key,
192            valid_until:      None,
193            successor_key_id: None,
194        })
195    }
196
197    /// Rotate the current default key (or a specific key) to a freshly
198    /// generated successor.
199    ///
200    /// Mints a new Ed25519 keypair, links the predecessor to it via
201    /// `successor_key_id`, and stamps the predecessor with a `valid_until`
202    /// of `now + grace_period`. The grace window lets verifiers continue to
203    /// accept signatures from the predecessor while clients catch up to
204    /// the new public key.
205    ///
206    /// If `set_default` is true (the typical case -- you rotate because you
207    /// want to start signing with the new key immediately), the successor
208    /// becomes the default. Pass `false` to stage a rotation for review
209    /// without flipping the active signer.
210    ///
211    /// `predecessor_id` may be `None` to rotate the current default. Pass
212    /// an explicit id to rotate a non-default key (e.g. a per-environment
213    /// secondary).
214    ///
215    /// Note on threat model: this is a graceful rotation primitive, not a
216    /// revocation primitive. If the predecessor key is suspected compromised
217    /// the grace_period should be `Duration::ZERO` (or use a future
218    /// `revoke()` call once that lands) so the predecessor's `valid_until`
219    /// is in the past and any verifier honoring the metadata refuses
220    /// further signatures from it.
221    pub fn rotate(
222        &self,
223        predecessor_id: Option<&str>,
224        grace_period: std::time::Duration,
225        set_default: bool,
226    ) -> Result<RotationResult, KeyError> {
227        // Resolve predecessor: explicit id, else the current default.
228        let pred_id = match predecessor_id {
229            Some(id) => id.to_string(),
230            None => self.default_key_id()?,
231        };
232
233        // Refuse to rotate a key that has already been rotated -- the
234        // chain head is the only valid rotation source. This makes the
235        // operation idempotent in the face of accidental re-runs.
236        let pred_entry_existing = self.load_entry(&pred_id)?;
237        if let Some(existing) = &pred_entry_existing.successor_key_id {
238            return Err(KeyError::Crypto(format!(
239                "key {pred_id} has already been rotated to {existing}; \
240                 rotate the chain head instead"
241            )));
242        }
243
244        // Mint the successor. We deliberately do NOT call `self.generate()`
245        // because that path also updates the manifest's default. We need a
246        // single transactional update that sets both predecessor metadata
247        // AND (optionally) the new default in one manifest write.
248        let succ_id = new_key_id();
249        let signer = Ed25519Signer::generate(&succ_id)
250            .map_err(|e| KeyError::Crypto(e.to_string()))?;
251        let succ_secret  = signer.secret_bytes();
252        let succ_pub_key = signer.public_key_bytes();
253        let (succ_enc, succ_nonce) = aes_gcm_encrypt(&self.machine_key, &succ_secret)
254            .map_err(KeyError::Crypto)?;
255
256        let succ_created = crate::statements::unix_to_rfc3339(unix_now());
257        let succ_entry = EncryptedEntry {
258            id:               succ_id.clone(),
259            algorithm:        "ed25519".into(),
260            created_at:       succ_created.clone(),
261            public_key:       succ_pub_key.clone(),
262            enc_priv_key:     succ_enc,
263            nonce:            succ_nonce,
264            valid_until:      None,
265            successor_key_id: None,
266        };
267
268        // Stamp the predecessor with the grace deadline and link forward.
269        let valid_until = crate::statements::unix_to_rfc3339(
270            unix_now() + grace_period.as_secs(),
271        );
272        let mut pred_entry = pred_entry_existing;
273        pred_entry.valid_until      = Some(valid_until.clone());
274        pred_entry.successor_key_id = Some(succ_id.clone());
275
276        // Write order matters for partial-failure recovery. Persist the
277        // successor entry FIRST, then stamp the predecessor pointing at
278        // it. If we wrote the predecessor first and then the successor
279        // write failed, the predecessor's successor_key_id would dangle
280        // at a key that doesn't exist on disk -- and the
281        // already-been-rotated guard would refuse to retry. With this
282        // order:
283        //   - successor write fails: nothing observable changed; retry clean.
284        //   - predecessor write fails: orphan successor key file on disk
285        //     (not yet referenced by manifest or by any other key); retry
286        //     generates a new successor and the orphan is harmless.
287        //   - manifest write fails: predecessor + successor both on disk,
288        //     manifest stale; retry's already-rotated guard catches the
289        //     half-finished state and surfaces a clear error.
290        self.write_entry(&succ_entry)?;
291        self.write_entry(&pred_entry)?;
292
293        // Refresh the cache to mirror the on-disk state we just wrote --
294        // BEFORE the manifest update. If the manifest write fails, the
295        // cache must still match disk so a same-process retry sees the
296        // half-rotated state and the already-rotated guard fires
297        // correctly. Doing this AFTER write_manifest would leave a
298        // window where disk reflects the rotation but the in-memory
299        // cache still serves the unstamped predecessor, and a retry
300        // from the same Store instance would generate a duplicate
301        // successor -- defeating the whole point of the guard.
302        {
303            let mut cache = self.cache.write().unwrap();
304            cache.insert(pred_entry.id.clone(), pred_entry.clone());
305            cache.insert(succ_id.clone(),       succ_entry.clone());
306        }
307
308        // Update the manifest: register the new key, optionally promote it.
309        let mut manifest = self.read_manifest()?;
310        manifest.key_ids.push(succ_id.clone());
311        if set_default {
312            manifest.default_key_id = Some(succ_id.clone());
313        }
314        self.write_manifest(&manifest)?;
315
316        let default_id = manifest.default_key_id.clone();
317        let predecessor = KeyInfo {
318            id:               pred_entry.id.clone(),
319            algorithm:        pred_entry.algorithm.clone(),
320            is_default:       default_id.as_deref() == Some(pred_entry.id.as_str()),
321            created_at:       pred_entry.created_at.clone(),
322            fingerprint:      fingerprint(&pred_entry.public_key),
323            public_key:       pred_entry.public_key.clone(),
324            valid_until:      pred_entry.valid_until.clone(),
325            successor_key_id: pred_entry.successor_key_id.clone(),
326        };
327        let successor = KeyInfo {
328            id:               succ_id.clone(),
329            algorithm:        "ed25519".into(),
330            is_default:       default_id.as_deref() == Some(succ_id.as_str()),
331            created_at:       succ_created,
332            fingerprint:      fingerprint(&succ_pub_key),
333            public_key:       succ_pub_key,
334            valid_until:      None,
335            successor_key_id: None,
336        };
337
338        Ok(RotationResult {
339            predecessor,
340            successor,
341            grace_period_until: valid_until,
342        })
343    }
344
345    /// Walk the rotation chain forward from `id`, returning the ordered
346    /// list of key ids: `[id, successor_of_id, ...]`. The first element is
347    /// always `id` itself. Stops at a key with no `successor_key_id`.
348    pub fn successor_chain(&self, id: &str) -> Result<Vec<KeyId>, KeyError> {
349        let mut chain = Vec::new();
350        let mut cursor = id.to_string();
351        // Cap iterations at the manifest size to defend against a corrupt
352        // chain that loops back on itself. A well-formed chain is bounded
353        // by the number of keys in the keystore.
354        let max_steps = self.read_manifest()?.key_ids.len() + 1;
355        for _ in 0..max_steps {
356            chain.push(cursor.clone());
357            let entry = self.load_entry(&cursor)?;
358            match entry.successor_key_id {
359                Some(next) => cursor = next,
360                None => return Ok(chain),
361            }
362        }
363        Err(KeyError::Crypto(format!(
364            "rotation chain starting at {id} exceeds keystore size; suspected loop"
365        )))
366    }
367
368    /// Returns the `KeyInfo` for every key whose `valid_until` is either
369    /// unset or strictly after `at_unix_secs`. The result includes both
370    /// rotated-but-still-in-grace predecessors and never-rotated keys.
371    /// Useful for building a verifier's accept-set as of a given time.
372    pub fn valid_keys_at(&self, at_unix_secs: u64) -> Result<Vec<KeyInfo>, KeyError> {
373        let cutoff_rfc = crate::statements::unix_to_rfc3339(at_unix_secs);
374        Ok(self.list()?
375            .into_iter()
376            .filter(|k| match &k.valid_until {
377                None => true,
378                Some(until) => until.as_str() > cutoff_rfc.as_str(),
379            })
380            .collect())
381    }
382
383    /// Returns a boxed `Signer` for the current default key.
384    pub fn default_signer(&self) -> Result<Box<dyn Signer>, KeyError> {
385        let manifest = self.read_manifest()?;
386        let id = manifest.default_key_id.ok_or(KeyError::NoDefaultKey)?;
387        self.signer(&id)
388    }
389
390    /// Returns a boxed `Signer` for a specific key ID.
391    pub fn signer(&self, id: &str) -> Result<Box<dyn Signer>, KeyError> {
392        let entry = self.load_entry(id)?;
393
394        let secret = aes_gcm_decrypt(&self.machine_key, &entry.enc_priv_key, &entry.nonce)
395            .map_err(|e| self.enrich_crypto_error(e))?;
396
397        let secret_arr: [u8; 32] = secret.try_into()
398            .map_err(|_| KeyError::Crypto("decrypted key is wrong length".into()))?;
399
400        let signer = Ed25519Signer::from_bytes(&entry.id, &secret_arr)
401            .map_err(|e| KeyError::Crypto(e.to_string()))?;
402
403        Ok(Box::new(signer))
404    }
405
406    /// Wrap a bare crypto error (typically "MAC verification failed ..." from
407    /// the AES-GCM decrypt path) with a diagnostic and an actionable recovery
408    /// path.
409    ///
410    /// The common failure mode in the wild is a pre-0.9.x keystore whose
411    /// machine-key derivation was seed-file-based. Later versions derive
412    /// the machine key from hostname+username (macOS) or /etc/machine-id
413    /// (Linux), so old ciphertexts can't be MAC-verified with the new key.
414    /// Detecting that case is best-effort: the presence of a legacy seed
415    /// file (`.machineseed` or `machine_seed` inside the keys dir) is a
416    /// strong hint. If we see one, call it out explicitly.
417    fn enrich_crypto_error(&self, raw: String) -> KeyError {
418        // Only enrich on MAC failures -- other errors (I/O, wrong length) are
419        // surfaced as-is because their remediation differs.
420        if !raw.contains("MAC verification failed") {
421            return KeyError::Crypto(raw);
422        }
423
424        let legacy_seed_dot = self.dir.join(".machineseed");
425        let legacy_seed     = self.dir.join("machine_seed");
426        let has_legacy_seed = legacy_seed_dot.exists() || legacy_seed.exists();
427
428        let diagnosis = if has_legacy_seed {
429            "your keystore was created by an older Treeship version whose \
430             machine-key derivation has since changed. The ciphertext is \
431             intact but cannot be decrypted under the current derivation."
432        } else {
433            "the keystore cannot be decrypted. Usual causes: the key file \
434             was copied from a different machine, the hostname or username \
435             changed, or the file was corrupted."
436        };
437
438        // Resolve the user's ~/.treeship path for the recovery command, so
439        // we give a copy-pasteable command rather than a generic instruction.
440        let ts_dir = std::env::var("HOME")
441            .map(|h| format!("{h}/.treeship"))
442            .unwrap_or_else(|_| "~/.treeship".into());
443
444        // The outer KeyError::Crypto Display impl already prepends
445        // "keys crypto: "; don't double it. Start with the raw MAC error
446        // so the user still sees the underlying cryptographic reason,
447        // then follow with the human-readable diagnosis and recovery.
448        let msg = format!(
449            "{raw}\n\n  \
450             Diagnosis: {diagnosis}\n\n  \
451             Recovery (nondestructive -- the old keystore is moved aside, \
452             not deleted; any sealed .treeship packages you produced remain \
453             verifiable since their receipts embed the old public key):\n\n    \
454             mv {ts_dir} {ts_dir}.bak.$(date +%s)\n    \
455             treeship init\n"
456        );
457
458        KeyError::Crypto(msg)
459    }
460
461    /// Returns the default key ID.
462    pub fn default_key_id(&self) -> Result<KeyId, KeyError> {
463        self.read_manifest()?
464            .default_key_id
465            .ok_or(KeyError::NoDefaultKey)
466    }
467
468    /// Lists all keys.
469    pub fn list(&self) -> Result<Vec<KeyInfo>, KeyError> {
470        let manifest = self.read_manifest()?;
471        let default  = manifest.default_key_id.as_deref().unwrap_or("");
472
473        manifest.key_ids.iter().map(|id| {
474            let entry = self.load_entry(id)?;
475            Ok(KeyInfo {
476                id:               entry.id.clone(),
477                algorithm:        entry.algorithm.clone(),
478                is_default:       entry.id == default,
479                created_at:       entry.created_at.clone(),
480                fingerprint:      fingerprint(&entry.public_key),
481                public_key:       entry.public_key.clone(),
482                valid_until:      entry.valid_until.clone(),
483                successor_key_id: entry.successor_key_id.clone(),
484            })
485        }).collect()
486    }
487
488    /// Sets the default signing key.
489    pub fn set_default(&self, id: &str) -> Result<(), KeyError> {
490        // Verify the key exists before updating the manifest.
491        self.load_entry(id)?;
492        let mut manifest = self.read_manifest()?;
493        manifest.default_key_id = Some(id.to_string());
494        self.write_manifest(&manifest)
495    }
496
497    /// Returns the public key bytes for a key ID.
498    pub fn public_key(&self, id: &str) -> Result<Vec<u8>, KeyError> {
499        Ok(self.load_entry(id)?.public_key)
500    }
501
502    // --- private ---
503
504    fn load_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
505        // Check cache first.
506        if let Ok(cache) = self.cache.read() {
507            if let Some(entry) = cache.get(id) {
508                return Ok(entry.clone());
509            }
510        }
511        self.read_entry(id)
512    }
513
514    fn entry_path(&self, id: &str) -> PathBuf {
515        self.dir.join(format!("{}.json", id))
516    }
517
518    fn write_entry(&self, entry: &EncryptedEntry) -> Result<(), KeyError> {
519        let path = self.entry_path(&entry.id);
520        let json = serde_json::to_vec_pretty(entry)?;
521        write_file_600(&path, &json)?;
522        Ok(())
523    }
524
525    fn read_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
526        let path = self.entry_path(id);
527        if !path.exists() {
528            return Err(KeyError::NotFound(id.to_string()));
529        }
530        let bytes = fs::read(&path)?;
531        let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
532        Ok(entry)
533    }
534
535    fn manifest_path(&self) -> PathBuf {
536        self.dir.join("manifest.json")
537    }
538
539    fn read_manifest(&self) -> Result<Manifest, KeyError> {
540        let path = self.manifest_path();
541        if !path.exists() {
542            return Ok(Manifest::default());
543        }
544        let bytes = fs::read(&path)?;
545        Ok(serde_json::from_slice(&bytes)?)
546    }
547
548    fn write_manifest(&self, m: &Manifest) -> Result<(), KeyError> {
549        let json = serde_json::to_vec_pretty(m)?;
550        write_file_600(&self.manifest_path(), &json)?;
551        Ok(())
552    }
553}
554
555// --- Crypto helpers ---
556
557/// AES-256-GCM encryption.
558/// Returns (ciphertext, nonce).
559pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
560    // Pure-Rust AES-256-GCM using the block-cipher and GCM construction
561    // from the RustCrypto project. We inline a minimal version here to
562    // avoid pulling in aes-gcm 0.10 which pulls in base64ct ≥ 1.7.
563    //
564    // For now we use a simpler XOR-then-HMAC construction until we can
565    // pin a compatible aes-gcm version. This is replaced with proper
566    // AES-256-GCM once the toolchain constraint is lifted.
567    //
568    // Production note: this is AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC),
569    // which is semantically secure and provides authenticated encryption.
570    use sha2::Sha256;
571
572    let mut nonce = [0u8; 12];
573    rand::thread_rng().fill_bytes(&mut nonce);
574
575    // Derive per-nonce subkeys via HKDF-lite: sha256(key || nonce || "enc")
576    let mut enc_key_input = key.to_vec();
577    enc_key_input.extend_from_slice(&nonce);
578    enc_key_input.extend_from_slice(b"enc");
579    let enc_key = Sha256::digest(&enc_key_input);
580
581    let mut mac_key_input = key.to_vec();
582    mac_key_input.extend_from_slice(&nonce);
583    mac_key_input.extend_from_slice(b"mac");
584    let mac_key = Sha256::digest(&mac_key_input);
585
586    // CTR-mode keystream: sha256(enc_key || counter)
587    let ciphertext: Vec<u8> = plaintext.iter().enumerate().map(|(i, &b)| {
588        let mut block_input = enc_key.to_vec();
589        block_input.extend_from_slice(&(i as u64).to_le_bytes());
590        let block = Sha256::digest(&block_input);
591        b ^ block[i % 32]
592    }).collect();
593
594    // MAC: sha256(mac_key || nonce || ciphertext)
595    let mut mac_input = mac_key.to_vec();
596    mac_input.extend_from_slice(&nonce);
597    mac_input.extend_from_slice(&ciphertext);
598    let mac = Sha256::digest(&mac_input);
599
600    // Output: nonce(12) || mac(32) || ciphertext
601    let mut out = Vec::with_capacity(12 + 32 + ciphertext.len());
602    out.extend_from_slice(&nonce);
603    out.extend_from_slice(&mac);
604    out.extend_from_slice(&ciphertext);
605
606    Ok((out, nonce.to_vec()))
607}
608
609pub fn aes_gcm_decrypt(key: &[u8; 32], enc_data: &[u8], _nonce_unused: &[u8]) -> Result<Vec<u8>, String> {
610    if enc_data.len() < 44 {
611        return Err("ciphertext too short".into());
612    }
613    use sha2::Sha256;
614
615    let nonce      = &enc_data[..12];
616    let stored_mac = &enc_data[12..44];
617    let ciphertext = &enc_data[44..];
618
619    let nonce_arr: [u8; 12] = nonce.try_into().unwrap();
620
621    let mut enc_key_input = key.to_vec();
622    enc_key_input.extend_from_slice(&nonce_arr);
623    enc_key_input.extend_from_slice(b"enc");
624    let enc_key = Sha256::digest(&enc_key_input);
625
626    let mut mac_key_input = key.to_vec();
627    mac_key_input.extend_from_slice(&nonce_arr);
628    mac_key_input.extend_from_slice(b"mac");
629    let mac_key = Sha256::digest(&mac_key_input);
630
631    // Verify MAC before decrypting (Encrypt-then-MAC).
632    let mut mac_input = mac_key.to_vec();
633    mac_input.extend_from_slice(&nonce_arr);
634    mac_input.extend_from_slice(ciphertext);
635    let computed_mac = Sha256::digest(&mac_input);
636
637    // Constant-time comparison.
638    let mac_ok = stored_mac.iter().zip(computed_mac.iter())
639        .fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0;
640
641    if !mac_ok {
642        return Err("MAC verification failed — key file may be corrupt or wrong machine".into());
643    }
644
645    let plaintext: Vec<u8> = ciphertext.iter().enumerate().map(|(i, &b)| {
646        let mut block_input = enc_key.to_vec();
647        block_input.extend_from_slice(&(i as u64).to_le_bytes());
648        let block = Sha256::digest(&block_input);
649        b ^ block[i % 32]
650    }).collect();
651
652    Ok(plaintext)
653}
654
655// --- Machine key derivation ---
656
657pub fn derive_machine_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
658    // 1. Linux: /etc/machine-id (stable across reboots)
659    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
660        let trimmed = id.trim();
661        if !trimmed.is_empty() {
662            let mut h = Sha256::new();
663            h.update(trimmed.as_bytes());
664            h.update(store_dir.to_string_lossy().as_bytes());
665            return Ok(h.finalize().into());
666        }
667    }
668
669    // 2. macOS: hostname + username derivation (v1, backward compatible).
670    //
671    // TODO(v0.7.0): Migrate to IOPlatformSerialNumber-based derivation.
672    // The serial number is more stable (survives hostname and username
673    // changes), but switching now would silently invalidate all existing
674    // keys on macOS. A proper migration needs to:
675    //   1. Try the new derivation first.
676    //   2. On decryption failure, fall back to hostname+username.
677    //   3. If legacy succeeds, re-encrypt with the new key and save.
678    // Until that migration tooling is in place, keep hostname+username
679    // as the primary derivation so existing users are not locked out.
680    #[cfg(target_os = "macos")]
681    {
682        let hostname = std::process::Command::new("hostname")
683            .output()
684            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
685            .unwrap_or_default();
686        let username = std::env::var("USER").unwrap_or_default();
687        if !hostname.is_empty() && !username.is_empty() {
688            let mut h = Sha256::new();
689            h.update(b"treeship-machine-key:");
690            h.update(hostname.as_bytes());
691            h.update(b":");
692            h.update(username.as_bytes());
693            h.update(b":");
694            h.update(store_dir.to_string_lossy().as_bytes());
695            return Ok(h.finalize().into());
696        }
697    }
698
699    // 3. Fallback: random seed file. Co-located with the keystore so a
700    //    project-local keystore (/proj/.treeship/keys/) keeps its seed at
701    //    /proj/.treeship/machine_seed -- never reaching for ~/.treeship.
702    //    A global keystore (~/.treeship/keys/) co-locates to
703    //    ~/.treeship/machine_seed, which is byte-identical to the
704    //    pre-v0.9.6 location, so existing global keystores keep working.
705    //
706    //    Backward-compat read order:
707    //      1. <store_dir>/../machine_seed  (the new co-located path)
708    //      2. ~/.treeship/machine_seed     (the old hardcoded path)
709    //    Write order on first creation:
710    //      1. <store_dir>/../machine_seed  if the parent exists/is writable
711    //      2. ~/.treeship/machine_seed     as a last resort
712    //
713    //    This makes project-local config truly self-contained: an
714    //    isolated /proj keystore can decrypt its own keys even when
715    //    the user's ~/.treeship is corrupt or on a different machine,
716    //    closing the trust-fabric isolation gap that blocked
717    //    project-local smoke tests.
718    let local_seed_path = store_dir.parent().map(|p| p.join("machine_seed"));
719    let home = std::env::var("HOME")
720        .map(std::path::PathBuf::from)
721        .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
722    let global_seed_path = home.join(".treeship").join("machine_seed");
723
724    let seed = if let Some(local) = local_seed_path.as_ref().filter(|p| p.exists()) {
725        fs::read_to_string(local).map_err(KeyError::Io)?
726    } else if global_seed_path.exists() {
727        // Backward-compat: an existing global seed keeps decrypting any
728        // keystore that was encrypted under it (in particular the
729        // standard ~/.treeship/keys/ case where local == global).
730        fs::read_to_string(&global_seed_path).map_err(KeyError::Io)?
731    } else {
732        let mut bytes = [0u8; 32];
733        rand::thread_rng().fill_bytes(&mut bytes);
734        let seed_hex = hex_encode(&bytes);
735
736        // Prefer creating the seed locally. Falls back to the global
737        // path only when the keystore has no usable parent (rare;
738        // happens when store_dir is "/" or similar pathological input).
739        let target = match local_seed_path.as_ref() {
740            Some(p) => {
741                let _ = fs::create_dir_all(p.parent().unwrap_or(Path::new(".")));
742                p.clone()
743            }
744            None => {
745                let _ = fs::create_dir_all(global_seed_path.parent().unwrap_or(Path::new(".")));
746                global_seed_path.clone()
747            }
748        };
749        fs::write(&target, &seed_hex).map_err(KeyError::Io)?;
750        #[cfg(unix)]
751        {
752            use std::os::unix::fs::PermissionsExt;
753            let _ = fs::set_permissions(&target, fs::Permissions::from_mode(0o600));
754        }
755        seed_hex
756    };
757
758    let mut h = Sha256::new();
759    h.update(b"treeship-machine-key-fallback:");
760    h.update(seed.trim().as_bytes());
761    h.update(b":");
762    h.update(store_dir.to_string_lossy().as_bytes());
763    Ok(h.finalize().into())
764}
765
766/// Stable machine key derivation for NEW keys (VI P-256, etc).
767/// Uses hardware identifiers that survive hostname/user changes.
768/// For legacy ship Ed25519 keys, use `derive_machine_key()` instead.
769pub fn derive_machine_key_stable(store_dir: &Path) -> Result<[u8; 32], KeyError> {
770    // 1. Linux: /etc/machine-id
771    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
772        let trimmed = id.trim();
773        if !trimmed.is_empty() {
774            let mut h = Sha256::new();
775            h.update(b"treeship-machine-key-v2:");
776            h.update(trimmed.as_bytes());
777            h.update(b":");
778            h.update(store_dir.to_string_lossy().as_bytes());
779            return Ok(h.finalize().into());
780        }
781    }
782
783    // 2. macOS: IOPlatformSerialNumber (hardware serial, stable across
784    //    hostname changes, user renames, non-interactive shells)
785    #[cfg(target_os = "macos")]
786    {
787        if let Ok(output) = std::process::Command::new("ioreg")
788            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
789            .output()
790        {
791            let stdout = String::from_utf8_lossy(&output.stdout);
792            for line in stdout.lines() {
793                if line.contains("IOPlatformSerialNumber") {
794                    if let Some(serial) = line.split('"').nth(3) {
795                        if !serial.is_empty() {
796                            let mut h = Sha256::new();
797                            h.update(b"treeship-machine-key-v2:");
798                            h.update(serial.as_bytes());
799                            h.update(b":");
800                            h.update(store_dir.to_string_lossy().as_bytes());
801                            return Ok(h.finalize().into());
802                        }
803                    }
804                }
805            }
806        }
807    }
808
809    // 3. Fallback: persistent random seed in ~/.treeship/.internal/
810    //    Separate from key material. Mode 0600.
811    let home = std::env::var("HOME")
812        .map(std::path::PathBuf::from)
813        .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
814    let seed_dir = home.join(".treeship").join(".internal");
815    let _ = fs::create_dir_all(&seed_dir);
816    #[cfg(unix)]
817    {
818        use std::os::unix::fs::PermissionsExt;
819        let _ = fs::set_permissions(&seed_dir, fs::Permissions::from_mode(0o700));
820    }
821
822    let seed_path = seed_dir.join("machine_seed_v2");
823    let seed = if seed_path.exists() {
824        fs::read_to_string(&seed_path).map_err(KeyError::Io)?
825    } else {
826        let mut bytes = [0u8; 32];
827        rand::thread_rng().fill_bytes(&mut bytes);
828        let seed_hex = hex_encode(&bytes);
829        fs::write(&seed_path, &seed_hex).map_err(KeyError::Io)?;
830        #[cfg(unix)]
831        {
832            use std::os::unix::fs::PermissionsExt;
833            let _ = fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600));
834        }
835        seed_hex
836    };
837
838    let mut h = Sha256::new();
839    h.update(b"treeship-machine-key-v2-fallback:");
840    h.update(seed.trim().as_bytes());
841    h.update(b":");
842    h.update(store_dir.to_string_lossy().as_bytes());
843    Ok(h.finalize().into())
844}
845
846// --- Utility ---
847
848fn new_key_id() -> KeyId {
849    let mut b = [0u8; 8];
850    rand::thread_rng().fill_bytes(&mut b);
851    format!("key_{}", hex_encode(&b))
852}
853
854fn fingerprint(pub_key: &[u8]) -> String {
855    let h = Sha256::digest(pub_key);
856    hex_encode(&h[..8])
857}
858
859fn hex_encode(b: &[u8]) -> String {
860    b.iter().fold(String::new(), |mut s, byte| {
861        s.push_str(&format!("{:02x}", byte));
862        s
863    })
864}
865
866fn write_file_600(path: &Path, data: &[u8]) -> Result<(), KeyError> {
867    let mut f = fs::OpenOptions::new()
868        .write(true)
869        .create(true)
870        .truncate(true)
871        .open(path)?;
872    f.write_all(data)?;
873    // Set permissions to 0600 on Unix.
874    #[cfg(unix)]
875    {
876        use std::os::unix::fs::PermissionsExt;
877        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
878    }
879    Ok(())
880}
881
882fn unix_now() -> u64 {
883    use std::time::{SystemTime, UNIX_EPOCH};
884    SystemTime::now()
885        .duration_since(UNIX_EPOCH)
886        .unwrap_or_default()
887        .as_secs()
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    fn temp_dir_path() -> PathBuf {
895        let mut p = std::env::temp_dir();
896        p.push(format!("treeship-test-{}", {
897            let mut b = [0u8; 4];
898            rand::thread_rng().fill_bytes(&mut b);
899            hex_encode(&b)
900        }));
901        p
902    }
903
904    fn make_store() -> (Store, PathBuf) {
905        let dir = temp_dir_path();
906        let store = Store::open(&dir).unwrap();
907        (store, dir)
908    }
909
910    fn cleanup(dir: PathBuf) {
911        let _ = fs::remove_dir_all(dir);
912    }
913
914    #[test]
915    fn generate_key() {
916        let (store, dir) = make_store();
917        let info = store.generate(true).unwrap();
918        assert!(info.id.starts_with("key_"));
919        assert_eq!(info.algorithm, "ed25519");
920        assert!(!info.fingerprint.is_empty());
921        assert_eq!(info.public_key.len(), 32);
922        cleanup(dir);
923    }
924
925    #[test]
926    fn default_signer_works() {
927        let (store, dir) = make_store();
928        store.generate(true).unwrap();
929        let signer = store.default_signer().unwrap();
930        assert!(!signer.key_id().is_empty());
931        let pae = crate::attestation::pae("text/plain", b"test");
932        let sig = signer.sign(&pae).unwrap();
933        assert_eq!(sig.len(), 64);
934        cleanup(dir);
935    }
936
937    #[test]
938    fn encrypt_decrypt_roundtrip() {
939        let key = [42u8; 32];
940        let plaintext = b"super secret private key material here!";
941        let (enc, nonce) = aes_gcm_encrypt(&key, plaintext).unwrap();
942        let dec = aes_gcm_decrypt(&key, &enc, &nonce).unwrap();
943        assert_eq!(dec, plaintext);
944    }
945
946    #[test]
947    fn decrypt_wrong_key_fails() {
948        let key   = [42u8; 32];
949        let wrong = [99u8; 32];
950        let (enc, nonce) = aes_gcm_encrypt(&key, b"secret").unwrap();
951        assert!(aes_gcm_decrypt(&wrong, &enc, &nonce).is_err());
952    }
953
954    #[test]
955    fn persist_and_reload() {
956        let (store, dir) = make_store();
957        let info = store.generate(true).unwrap();
958
959        // Open a new Store instance pointing to the same directory.
960        let store2 = Store::open(&dir).unwrap();
961        let signer = store2.signer(&info.id).unwrap();
962        assert_eq!(signer.key_id(), info.id);
963
964        // The reloaded signer must produce signatures verifiable with
965        // the same public key.
966        let verifier = {
967            use crate::attestation::Verifier;
968            use ed25519_dalek::VerifyingKey;
969            let pk_bytes: [u8; 32] = info.public_key.try_into().unwrap();
970            let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
971            let mut v = Verifier::new(std::collections::HashMap::new());
972            v.add_key(info.id.clone(), vk);
973            v
974        };
975
976        use crate::attestation::sign;
977        use crate::statements::ActionStatement;
978        let stmt   = ActionStatement::new("agent://test", "tool.call");
979        let pt     = crate::statements::payload_type("action");
980        let signed = sign(&pt, &stmt, signer.as_ref()).unwrap();
981        verifier.verify(&signed.envelope).unwrap();
982
983        cleanup(dir);
984    }
985
986    #[test]
987    fn list_keys() {
988        let (store, dir) = make_store();
989        store.generate(true).unwrap();
990        store.generate(false).unwrap();
991
992        let keys = store.list().unwrap();
993        assert_eq!(keys.len(), 2);
994        assert_eq!(keys.iter().filter(|k| k.is_default).count(), 1);
995        cleanup(dir);
996    }
997
998    #[test]
999    fn no_default_key_errors() {
1000        let (store, dir) = make_store();
1001        assert!(store.default_signer().is_err());
1002        cleanup(dir);
1003    }
1004
1005    #[test]
1006    fn rotate_mints_successor_and_links_predecessor() {
1007        let (store, dir) = make_store();
1008        let pred = store.generate(true).unwrap();
1009        assert!(pred.valid_until.is_none(), "fresh key has no expiry");
1010        assert!(pred.successor_key_id.is_none(), "fresh key has no successor");
1011
1012        let result = store
1013            .rotate(None, std::time::Duration::from_secs(3600), true)
1014            .unwrap();
1015
1016        // Predecessor metadata is updated.
1017        assert_eq!(result.predecessor.id, pred.id);
1018        assert!(result.predecessor.valid_until.is_some(),
1019                "predecessor must get valid_until after rotation");
1020        assert_eq!(result.predecessor.successor_key_id.as_deref(),
1021                   Some(result.successor.id.as_str()),
1022                   "predecessor must link forward to successor");
1023        assert!(!result.predecessor.is_default,
1024                "after rotation with set_default=true, predecessor is no longer default");
1025
1026        // Successor is fresh.
1027        assert_ne!(result.successor.id, pred.id);
1028        assert!(result.successor.valid_until.is_none(), "successor has no expiry yet");
1029        assert!(result.successor.successor_key_id.is_none(), "successor is chain head");
1030        assert!(result.successor.is_default, "successor is the new default");
1031
1032        // Same metadata visible via list().
1033        let listed = store.list().unwrap();
1034        assert_eq!(listed.len(), 2);
1035        let pred_listed = listed.iter().find(|k| k.id == pred.id).unwrap();
1036        assert!(pred_listed.valid_until.is_some());
1037        assert_eq!(pred_listed.successor_key_id.as_deref(),
1038                   Some(result.successor.id.as_str()));
1039
1040        cleanup(dir);
1041    }
1042
1043    #[test]
1044    fn rotate_with_set_default_false_keeps_predecessor_active() {
1045        let (store, dir) = make_store();
1046        let pred = store.generate(true).unwrap();
1047
1048        let result = store
1049            .rotate(None, std::time::Duration::from_secs(3600), false)
1050            .unwrap();
1051
1052        // Predecessor is still default. Successor exists but is not default.
1053        assert!(result.predecessor.is_default);
1054        assert!(!result.successor.is_default);
1055        assert_eq!(store.default_key_id().unwrap(), pred.id);
1056
1057        cleanup(dir);
1058    }
1059
1060    #[test]
1061    fn rotate_predecessor_signing_still_works_during_grace_window() {
1062        let (store, dir) = make_store();
1063        let pred = store.generate(true).unwrap();
1064        let _ = store
1065            .rotate(None, std::time::Duration::from_secs(3600), true)
1066            .unwrap();
1067
1068        // Predecessor key must still be loadable and capable of signing
1069        // during its grace window. Verifiers can refuse on lifecycle, but
1070        // the keystore must not preemptively destroy material.
1071        let signer = store.signer(&pred.id).unwrap();
1072        let pae = crate::attestation::pae("text/plain", b"grace-window-payload");
1073        let sig = signer.sign(&pae).unwrap();
1074        assert_eq!(sig.len(), 64);
1075
1076        cleanup(dir);
1077    }
1078
1079    #[test]
1080    fn rotate_refuses_to_rotate_already_rotated_key() {
1081        let (store, dir) = make_store();
1082        store.generate(true).unwrap();
1083        let r1 = store
1084            .rotate(None, std::time::Duration::from_secs(60), true)
1085            .unwrap();
1086
1087        // Rotating the predecessor again must be refused -- it already
1088        // points at r1.successor. Caller should rotate the chain head.
1089        let err = store
1090            .rotate(Some(&r1.predecessor.id),
1091                    std::time::Duration::from_secs(60),
1092                    true)
1093            .unwrap_err();
1094        match err {
1095            KeyError::Crypto(msg) => assert!(
1096                msg.contains("already been rotated"),
1097                "error must explain why: {msg}"
1098            ),
1099            other => panic!("expected Crypto error, got {other:?}"),
1100        }
1101        cleanup(dir);
1102    }
1103
1104    #[test]
1105    fn successor_chain_walks_forward() {
1106        let (store, dir) = make_store();
1107        let k0 = store.generate(true).unwrap();
1108        let r1 = store
1109            .rotate(None, std::time::Duration::from_secs(60), true)
1110            .unwrap();
1111        let r2 = store
1112            .rotate(None, std::time::Duration::from_secs(60), true)
1113            .unwrap();
1114
1115        let chain = store.successor_chain(&k0.id).unwrap();
1116        assert_eq!(chain, vec![k0.id.clone(), r1.successor.id.clone(), r2.successor.id.clone()],
1117                   "chain must be ordered head -> tail");
1118
1119        // Mid-chain start: chain from r1.successor should drop k0.
1120        let mid = store.successor_chain(&r1.successor.id).unwrap();
1121        assert_eq!(mid, vec![r1.successor.id.clone(), r2.successor.id.clone()]);
1122
1123        // Tail: just itself.
1124        let tail = store.successor_chain(&r2.successor.id).unwrap();
1125        assert_eq!(tail, vec![r2.successor.id.clone()]);
1126
1127        cleanup(dir);
1128    }
1129
1130    #[test]
1131    fn valid_keys_at_filters_by_grace_window() {
1132        let (store, dir) = make_store();
1133        let _ = store.generate(true).unwrap();
1134        let result = store
1135            .rotate(None, std::time::Duration::from_secs(3600), true)
1136            .unwrap();
1137
1138        // At time-of-rotation, both keys must be valid -- predecessor is
1139        // mid-grace, successor is freshly minted.
1140        let now = unix_now();
1141        let valid_now = store.valid_keys_at(now).unwrap();
1142        assert_eq!(valid_now.len(), 2, "both predecessor (in grace) and successor should be valid");
1143
1144        // After the grace window expires, only the successor remains.
1145        let after_grace = unix_now() + 7200;
1146        let valid_after = store.valid_keys_at(after_grace).unwrap();
1147        assert_eq!(valid_after.len(), 1,
1148                   "after grace window only successor remains valid");
1149        assert_eq!(valid_after[0].id, result.successor.id);
1150
1151        cleanup(dir);
1152    }
1153
1154    /// Regression: if the successor key file is missing on disk (because a
1155    /// prior rotate() crashed AFTER stamping the predecessor but BEFORE
1156    /// writing the successor), retrying must NOT be wedged. With the
1157    /// successor-first write order this scenario can't be reached by a
1158    /// single-process crash, but we still need to defend against an operator
1159    /// who manually deletes a successor file mid-life. The recovery path
1160    /// is: clear the predecessor's successor pointer (or restore the file
1161    /// from backup) and try again.
1162    /// Regression: even if the manifest write FAILED (say, disk full at
1163    /// the worst possible moment), the in-memory cache must reflect the
1164    /// stamped predecessor that already landed on disk -- otherwise a
1165    /// same-process retry would skip the already-rotated guard and mint
1166    /// a duplicate successor.
1167    ///
1168    /// We can't easily inject a manifest-write failure mid-test, but we
1169    /// can verify the precondition that makes the recovery work: after a
1170    /// successful rotate(), the cache holds the stamped predecessor (so
1171    /// any subsequent rotate would correctly refuse). Combined with the
1172    /// write order (cache update BEFORE manifest write in rotate()),
1173    /// this proves a manifest-write crash leaves the cache aligned with
1174    /// disk, not behind it.
1175    #[test]
1176    fn rotate_cache_reflects_stamped_predecessor_for_retry_safety() {
1177        let (store, dir) = make_store();
1178        let pred = store.generate(true).unwrap();
1179        let _ = store
1180            .rotate(None, std::time::Duration::from_secs(60), true)
1181            .unwrap();
1182
1183        // The cache must have the stamped predecessor; a same-process
1184        // retry of rotate(predecessor) MUST be refused. If the cache
1185        // were stale (still showing the unstamped predecessor), this
1186        // call would proceed and mint a duplicate successor.
1187        let err = store
1188            .rotate(Some(&pred.id),
1189                    std::time::Duration::from_secs(60),
1190                    true)
1191            .unwrap_err();
1192        match err {
1193            KeyError::Crypto(msg) => assert!(
1194                msg.contains("already been rotated"),
1195                "cache should reflect stamped predecessor; got: {msg}"
1196            ),
1197            other => panic!("expected Crypto error, got {other:?}"),
1198        }
1199
1200        cleanup(dir);
1201    }
1202
1203    #[test]
1204    fn rotated_predecessor_pointing_at_missing_successor_surfaces_clear_error() {
1205        let (store, dir) = make_store();
1206        store.generate(true).unwrap();
1207        let result = store
1208            .rotate(None, std::time::Duration::from_secs(60), true)
1209            .unwrap();
1210
1211        // Simulate operator-deleted successor file. The manifest still
1212        // references it, so a cold-cache reader trying to walk the chain
1213        // hits a clear NotFound for the missing key.
1214        let succ_path = store.entry_path(&result.successor.id);
1215        fs::remove_file(&succ_path).unwrap();
1216
1217        // Open a fresh Store instance so the cache doesn't paper over the
1218        // missing on-disk entry. successor_chain() walks via load_entry;
1219        // the missing file must produce KeyError::NotFound, not a panic
1220        // and not an infinite loop.
1221        let store2 = Store::open(&dir).unwrap();
1222        let err = store2.successor_chain(&result.predecessor.id).unwrap_err();
1223        match err {
1224            KeyError::NotFound(id) => assert_eq!(id, result.successor.id),
1225            other => panic!("expected NotFound error, got {other:?}"),
1226        }
1227
1228        cleanup(dir);
1229    }
1230
1231    /// Pre-0.9.5 entry files lack `valid_until` and `successor_key_id`.
1232    /// They must still deserialize cleanly and be visible via `list()` /
1233    /// `default_signer()` etc.
1234    #[test]
1235    fn legacy_entry_without_lifecycle_fields_loads() {
1236        let (store, dir) = make_store();
1237        let info = store.generate(true).unwrap();
1238
1239        // Re-serialize the on-disk entry without the new fields, simulating
1240        // a file created by a 0.9.4 or earlier CLI.
1241        let path = store.entry_path(&info.id);
1242        let raw  = fs::read(&path).unwrap();
1243        let mut json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1244        let obj = json.as_object_mut().unwrap();
1245        obj.remove("valid_until");
1246        obj.remove("successor_key_id");
1247        fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap();
1248
1249        // A fresh Store (cold cache) must still load the entry and treat
1250        // the missing fields as None.
1251        let store2 = Store::open(&dir).unwrap();
1252        let listed = store2.list().unwrap();
1253        assert_eq!(listed.len(), 1);
1254        assert!(listed[0].valid_until.is_none(),
1255                "missing valid_until must default to None on legacy entry");
1256        assert!(listed[0].successor_key_id.is_none(),
1257                "missing successor_key_id must default to None on legacy entry");
1258        let signer = store2.default_signer().unwrap();
1259        assert_eq!(signer.key_id(), info.id);
1260
1261        cleanup(dir);
1262    }
1263}