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 aes_gcm::{
10    aead::{Aead, KeyInit, OsRng as AeadOsRng, Payload},
11    AeadCore, Aes256Gcm, Key as AesKey, Nonce,
12};
13use rand::{rngs::OsRng, RngCore};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest as Sha2Digest, Sha256};
16use zeroize::Zeroizing;
17
18use crate::attestation::{Ed25519Signer, Signer};
19
20// --- Public types ---
21
22pub type KeyId = String;
23
24/// Public information about a stored key. Never contains private material.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct KeyInfo {
27    pub id:          KeyId,
28    pub algorithm:   String,   // "ed25519"
29    pub is_default:  bool,
30    pub created_at:  String,   // RFC 3339
31    /// First 8 bytes of sha256(public_key), hex-encoded.
32    pub fingerprint: String,
33    pub public_key:  Vec<u8>,  // raw 32-byte Ed25519 public key
34    /// RFC 3339 timestamp after which signatures by this key should be
35    /// considered stale. `None` means the key has not been rotated and is
36    /// indefinitely valid. Set automatically by `Store::rotate` to
37    /// `now + grace_period` on the predecessor key.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub valid_until: Option<String>,
40    /// If this key was rotated to a successor, the successor's key id.
41    /// Lets verifiers walk a rotation chain forward when validating an old
42    /// receipt against the current keystore. `None` means this is the head
43    /// of its chain.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub successor_key_id: Option<KeyId>,
46}
47
48/// Outcome of a `Store::rotate` call.
49#[derive(Debug, Clone)]
50pub struct RotationResult {
51    /// The key that was rotated. Its `valid_until` is now set.
52    pub predecessor: KeyInfo,
53    /// The freshly minted successor key.
54    pub successor: KeyInfo,
55    /// RFC 3339 timestamp until which the predecessor remains valid for
56    /// signature verification under the grace period. Equal to
57    /// `predecessor.valid_until.unwrap()`.
58    pub grace_period_until: String,
59}
60
61/// Errors from keystore operations.
62#[derive(Debug)]
63pub enum KeyError {
64    Io(io::Error),
65    Json(serde_json::Error),
66    Crypto(String),
67    NotFound(KeyId),
68    EmptyKeyId,
69    NoDefaultKey,
70    /// Private key file has insecure permissions (group- or world-readable).
71    /// Carries the path and the observed octal mode so the caller can show
72    /// an actionable error. Set `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` to
73    /// bypass during testing or controlled environments.
74    InsecureKeyPerms { path: PathBuf, mode: u32 },
75}
76
77impl std::fmt::Display for KeyError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::Io(e)       => write!(f, "keys io: {}", e),
81            Self::Json(e)     => write!(f, "keys json: {}", e),
82            Self::Crypto(e)   => write!(f, "keys crypto: {}", e),
83            Self::NotFound(k) => write!(f, "key not found: {}", k),
84            Self::EmptyKeyId  => write!(f, "key id must not be empty"),
85            Self::NoDefaultKey => write!(f, "no default key — run treeship init"),
86            Self::InsecureKeyPerms { path, mode } => write!(
87                f,
88                "private key {} has insecure permissions (mode {:o}); \
89                 run `treeship doctor --fix` or chmod 600 the file. \
90                 Set TREESHIP_ALLOW_INSECURE_KEY_PERMS=1 to bypass.",
91                path.display(),
92                mode & 0o777,
93            ),
94        }
95    }
96}
97
98impl std::error::Error for KeyError {}
99impl From<io::Error>          for KeyError { fn from(e: io::Error)          -> Self { Self::Io(e) } }
100impl From<serde_json::Error>  for KeyError { fn from(e: serde_json::Error)  -> Self { Self::Json(e) } }
101
102// --- On-disk formats ---
103
104/// The encrypted representation of one keypair on disk.
105#[derive(Serialize, Deserialize, Clone)]
106struct EncryptedEntry {
107    id:           KeyId,
108    algorithm:    String,
109    created_at:   String,
110    public_key:   Vec<u8>,
111    /// AES-256-GCM ciphertext of the 32-byte Ed25519 secret scalar.
112    enc_priv_key: Vec<u8>,
113    /// 12-byte GCM nonce used when encrypting.
114    nonce:        Vec<u8>,
115    /// RFC 3339 timestamp after which signatures by this key should be
116    /// considered stale. `None` means the key is indefinitely valid.
117    /// Defaulted on deserialization so pre-0.9.5 entry files still load.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    valid_until: Option<String>,
120    /// Successor key id if this key was rotated. Defaulted on
121    /// deserialization for pre-0.9.5 entry files.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    successor_key_id: Option<KeyId>,
124}
125
126/// The manifest file: which keys exist and which is the default.
127#[derive(Serialize, Deserialize, Default)]
128struct Manifest {
129    default_key_id: Option<KeyId>,
130    key_ids:        Vec<KeyId>,
131}
132
133// --- Store ---
134
135/// Local encrypted keystore.
136///
137/// Private keys are encrypted with AES-256-GCM (RustCrypto `aes-gcm`
138/// 0.10) before writing to disk. The encryption key is derived from a
139/// machine-specific secret so key files are useless if copied to
140/// another machine.
141///
142/// Pre-v0.10.3 keystores used a homemade SHA-256-CTR + HMAC-SHA-256
143/// construction (TS-2026-001) and are transparently migrated to the
144/// new AEAD format on first decrypt; see `encrypt_for_disk_v2` /
145/// `decrypt_from_disk` for the format dispatcher.
146///
147/// A future version will delegate to OS credential stores (Secure
148/// Enclave / TPM 2.0).
149pub struct Store {
150    dir:         PathBuf,
151    machine_key: [u8; 32],
152    /// Decrypt-only fallback machine keys, tried in order when the primary
153    /// fails. These cover every wrapping an existing keystore may carry:
154    /// the v1 hostname+username key under the current hostname, the same
155    /// under the raw (non-canonicalized) path when the path contains a
156    /// symlink, and — on macOS — the v1 key under `scutil LocalHostName`
157    /// variants, because macOS renames `kern.hostname` out from under a
158    /// running machine (network collisions, DHCP) while `LocalHostName`
159    /// keeps the name the keystore was written under. Never used to
160    /// encrypt: any entry that decrypts via a fallback is transparently
161    /// rewrapped under the primary. See `open` and `signer`.
162    fallback_machine_keys: Vec<[u8; 32]>,
163    /// In-memory cache — avoids disk reads on hot paths.
164    cache:       Arc<RwLock<HashMap<KeyId, EncryptedEntry>>>,
165}
166
167impl Store {
168    /// Opens or creates a keystore at `dir`.
169    pub fn open(dir: impl AsRef<Path>) -> Result<Self, KeyError> {
170        let dir = dir.as_ref().to_path_buf();
171        fs::create_dir_all(&dir)?;
172
173        // Canonicalize the keystore path before deriving the machine key. The
174        // derivation hashes the store path into the key, so the SAME logical
175        // directory must produce the SAME path string every time -- otherwise
176        // `init` and a later command can hash different strings for one
177        // directory (e.g. macOS `/var` -> `/private/var`, or a symlinked
178        // `$HOME`) and decryption fails with a misleading "wrong machine" MAC
179        // error. canonicalize resolves symlinks to a stable absolute path;
180        // create_dir_all above guarantees it exists.
181        //
182        // The raw-path key is retained as a DECRYPT-ONLY fallback so any
183        // keystore written before this change (encrypted under the raw path)
184        // still opens -- this hardening must never lock an existing user out.
185        // Encryption always uses the canonical key, so entries migrate to it
186        // as they are rewritten.
187        let canonical = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
188
189        // Primary (encrypt) key: hardware-stable when the machine offers a
190        // stable identifier (/etc/machine-id, IOPlatformSerialNumber), so a
191        // hostname rename can never invalidate the keystore again. Machines
192        // with neither identifier keep the v1 derivation, whose seed-file
193        // fallback is CO-LOCATED with the keystore -- switching those to the
194        // stable derivation would move their seed to the global
195        // ~/.treeship/.internal/ and silently break project-local keystore
196        // isolation (the v0.9.6 property).
197        // AUD-19: the PRIMARY (encrypt) key derives from a SECRET OsRng seed,
198        // not from guessable machine identifiers. Before this the wrapping key
199        // was `SHA256(machine-id/serial/hostname ‖ store_path)` — all guessable
200        // — so an exfiltrated `keys/*.json` was forgeable by anyone who knew
201        // the victim's hostname or machine-id. The old guessable derivations
202        // are now DECRYPT-ONLY fallbacks below; the first successful fallback
203        // decrypt rewraps the entry under this seed key (see `signer`), so an
204        // existing keystore migrates transparently on first open.
205        //
206        // Durability note: the machine_seed file is now load-bearing — losing
207        // it makes an already-migrated keystore unrecoverable (the price of
208        // real at-rest confidentiality; the old guessable key was recoverable
209        // precisely because it was guessable).
210        let machine_key = derive_seed_primary_key(&canonical)?;
211
212        // Decrypt-only fallbacks, most-likely first. Existing keystores are
213        // wrapped under one of these; the first successful decrypt rewraps
214        // the entry under the primary (see `signer`), so the fallbacks are
215        // a migration path, not a permanent second key.
216        let mut fallback_machine_keys: Vec<[u8; 32]> = Vec::new();
217        // The former PRIMARY: hardware-stable key (machine-id / serial).
218        if let Some(k) = stable_hardware_key(&canonical) {
219            fallback_machine_keys.push(k);
220        }
221        // v1 under the current hostname (every pre-migration keystore).
222        if let Ok(k) = derive_machine_key(&canonical) {
223            fallback_machine_keys.push(k);
224        }
225        // v1 under the raw path, for keystores written before path
226        // canonicalization through a symlink.
227        if canonical != dir {
228            if let Ok(k) = derive_machine_key(&dir) {
229                fallback_machine_keys.push(k);
230            }
231        }
232        // macOS: v1 under the mDNS LocalHostName variants. When macOS
233        // renames kern.hostname (the usual keystore-bricking event),
234        // LocalHostName typically still holds the name the store was
235        // written under, so these recover a drifted keystore with no
236        // user action.
237        if let Ok(user) = std::env::var("USER") {
238            for h in local_hostname_variants() {
239                fallback_machine_keys
240                    .push(derive_machine_key_v1_from_parts(&h, &user, &canonical));
241            }
242        }
243        // Order-preserving dedupe (Vec::dedup only folds adjacent repeats),
244        // and drop any candidate equal to the primary -- retrying the same
245        // key can only re-fail.
246        let mut seen: Vec<[u8; 32]> = vec![machine_key];
247        fallback_machine_keys.retain(|k| {
248            if seen.contains(k) {
249                false
250            } else {
251                seen.push(*k);
252                true
253            }
254        });
255
256        Ok(Self {
257            dir,
258            machine_key,
259            fallback_machine_keys,
260            cache: Arc::new(RwLock::new(HashMap::new())),
261        })
262    }
263
264    /// Generates a new Ed25519 keypair, encrypts and stores it.
265    /// If `set_default` is true (or there is no current default), makes
266    /// this key the default signing key.
267    pub fn generate(&self, set_default: bool) -> Result<KeyInfo, KeyError> {
268        let key_id = new_key_id();
269
270        let signer = Ed25519Signer::generate(&key_id)
271            .map_err(|e| KeyError::Crypto(e.to_string()))?;
272
273        // `secret` is a Zeroizing<[u8; 32]> -- the caller-side copy of the
274        // signer's secret scalar is wiped on scope exit. `signer` is dropped
275        // at end of fn, which wipes its own copy via the Drop impl in
276        // attestation::signer.
277        let secret  = signer.secret_bytes();
278        let pub_key = signer.public_key_bytes();
279
280        let enc = encrypt_for_disk_v2(&self.machine_key, key_id.as_str(), &pub_key, secret.as_slice())
281            .map_err(KeyError::Crypto)?;
282
283        let entry = EncryptedEntry {
284            id:               key_id.clone(),
285            algorithm:        "ed25519".into(),
286            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
287            public_key:       pub_key.clone(),
288            enc_priv_key:     enc,
289            // v2 ciphertexts carry their nonce inline (bytes [2..14]).
290            // The separate `nonce` field is retained for v1 legacy
291            // compatibility; for fresh v2 entries we serialize an empty
292            // vec so the JSON stays well-formed.
293            nonce:            Vec::new(),
294            valid_until:      None,
295            successor_key_id: None,
296        };
297
298        self.write_entry(&entry)?;
299
300        // Update manifest.
301        let mut manifest = self.read_manifest()?;
302        manifest.key_ids.push(key_id.clone());
303        if set_default || manifest.default_key_id.is_none() {
304            manifest.default_key_id = Some(key_id.clone());
305        }
306        self.write_manifest(&manifest)?;
307
308        // Populate cache.
309        self.cache.write().unwrap().insert(key_id.clone(), entry);
310
311        Ok(KeyInfo {
312            id:               key_id.clone(),
313            algorithm:        "ed25519".into(),
314            is_default:       manifest.default_key_id.as_deref() == Some(key_id.as_str()),
315            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
316            fingerprint:      fingerprint(&pub_key),
317            public_key:       pub_key,
318            valid_until:      None,
319            successor_key_id: None,
320        })
321    }
322
323    /// Rotate the current default key (or a specific key) to a freshly
324    /// generated successor.
325    ///
326    /// Mints a new Ed25519 keypair, links the predecessor to it via
327    /// `successor_key_id`, and stamps the predecessor with a `valid_until`
328    /// of `now + grace_period`. The grace window lets verifiers continue to
329    /// accept signatures from the predecessor while clients catch up to
330    /// the new public key.
331    ///
332    /// If `set_default` is true (the typical case -- you rotate because you
333    /// want to start signing with the new key immediately), the successor
334    /// becomes the default. Pass `false` to stage a rotation for review
335    /// without flipping the active signer.
336    ///
337    /// `predecessor_id` may be `None` to rotate the current default. Pass
338    /// an explicit id to rotate a non-default key (e.g. a per-environment
339    /// secondary).
340    ///
341    /// Note on threat model: this is a graceful rotation primitive, not a
342    /// revocation primitive. If the predecessor key is suspected compromised
343    /// the grace_period should be `Duration::ZERO` (or use a future
344    /// `revoke()` call once that lands) so the predecessor's `valid_until`
345    /// is in the past and any verifier honoring the metadata refuses
346    /// further signatures from it.
347    pub fn rotate(
348        &self,
349        predecessor_id: Option<&str>,
350        grace_period: std::time::Duration,
351        set_default: bool,
352    ) -> Result<RotationResult, KeyError> {
353        // Resolve predecessor: explicit id, else the current default.
354        let pred_id = match predecessor_id {
355            Some(id) => id.to_string(),
356            None => self.default_key_id()?,
357        };
358
359        // Refuse to rotate a key that has already been rotated -- the
360        // chain head is the only valid rotation source. This makes the
361        // operation idempotent in the face of accidental re-runs.
362        let pred_entry_existing = self.load_entry(&pred_id)?;
363        if let Some(existing) = &pred_entry_existing.successor_key_id {
364            return Err(KeyError::Crypto(format!(
365                "key {pred_id} has already been rotated to {existing}; \
366                 rotate the chain head instead"
367            )));
368        }
369
370        // Mint the successor. We deliberately do NOT call `self.generate()`
371        // because that path also updates the manifest's default. We need a
372        // single transactional update that sets both predecessor metadata
373        // AND (optionally) the new default in one manifest write.
374        let succ_id = new_key_id();
375        let signer = Ed25519Signer::generate(&succ_id)
376            .map_err(|e| KeyError::Crypto(e.to_string()))?;
377        // `succ_secret` is a Zeroizing<[u8; 32]>; the caller-side copy is
378        // wiped on scope exit, and `signer` is dropped at end of fn (which
379        // wipes its own copy via the attestation::signer Drop impl).
380        let succ_secret  = signer.secret_bytes();
381        let succ_pub_key = signer.public_key_bytes();
382        let succ_enc =
383            encrypt_for_disk_v2(&self.machine_key, succ_id.as_str(), &succ_pub_key, succ_secret.as_slice())
384                .map_err(KeyError::Crypto)?;
385
386        let succ_created = crate::statements::unix_to_rfc3339(unix_now());
387        let succ_entry = EncryptedEntry {
388            id:               succ_id.clone(),
389            algorithm:        "ed25519".into(),
390            created_at:       succ_created.clone(),
391            public_key:       succ_pub_key.clone(),
392            enc_priv_key:     succ_enc,
393            // v2 ciphertexts carry their nonce inline; the legacy
394            // `nonce` field is left empty for fresh writes.
395            nonce:            Vec::new(),
396            valid_until:      None,
397            successor_key_id: None,
398        };
399
400        // Stamp the predecessor with the grace deadline and link forward.
401        let valid_until = crate::statements::unix_to_rfc3339(
402            unix_now() + grace_period.as_secs(),
403        );
404        let mut pred_entry = pred_entry_existing;
405        pred_entry.valid_until      = Some(valid_until.clone());
406        pred_entry.successor_key_id = Some(succ_id.clone());
407
408        // Write order matters for partial-failure recovery. Persist the
409        // successor entry FIRST, then stamp the predecessor pointing at
410        // it. If we wrote the predecessor first and then the successor
411        // write failed, the predecessor's successor_key_id would dangle
412        // at a key that doesn't exist on disk -- and the
413        // already-been-rotated guard would refuse to retry. With this
414        // order:
415        //   - successor write fails: nothing observable changed; retry clean.
416        //   - predecessor write fails: orphan successor key file on disk
417        //     (not yet referenced by manifest or by any other key); retry
418        //     generates a new successor and the orphan is harmless.
419        //   - manifest write fails: predecessor + successor both on disk,
420        //     manifest stale; retry's already-rotated guard catches the
421        //     half-finished state and surfaces a clear error.
422        self.write_entry(&succ_entry)?;
423        self.write_entry(&pred_entry)?;
424
425        // Refresh the cache to mirror the on-disk state we just wrote --
426        // BEFORE the manifest update. If the manifest write fails, the
427        // cache must still match disk so a same-process retry sees the
428        // half-rotated state and the already-rotated guard fires
429        // correctly. Doing this AFTER write_manifest would leave a
430        // window where disk reflects the rotation but the in-memory
431        // cache still serves the unstamped predecessor, and a retry
432        // from the same Store instance would generate a duplicate
433        // successor -- defeating the whole point of the guard.
434        {
435            let mut cache = self.cache.write().unwrap();
436            cache.insert(pred_entry.id.clone(), pred_entry.clone());
437            cache.insert(succ_id.clone(),       succ_entry.clone());
438        }
439
440        // Update the manifest: register the new key, optionally promote it.
441        let mut manifest = self.read_manifest()?;
442        manifest.key_ids.push(succ_id.clone());
443        if set_default {
444            manifest.default_key_id = Some(succ_id.clone());
445        }
446        self.write_manifest(&manifest)?;
447
448        let default_id = manifest.default_key_id.clone();
449        let predecessor = KeyInfo {
450            id:               pred_entry.id.clone(),
451            algorithm:        pred_entry.algorithm.clone(),
452            is_default:       default_id.as_deref() == Some(pred_entry.id.as_str()),
453            created_at:       pred_entry.created_at.clone(),
454            fingerprint:      fingerprint(&pred_entry.public_key),
455            public_key:       pred_entry.public_key.clone(),
456            valid_until:      pred_entry.valid_until.clone(),
457            successor_key_id: pred_entry.successor_key_id.clone(),
458        };
459        let successor = KeyInfo {
460            id:               succ_id.clone(),
461            algorithm:        "ed25519".into(),
462            is_default:       default_id.as_deref() == Some(succ_id.as_str()),
463            created_at:       succ_created,
464            fingerprint:      fingerprint(&succ_pub_key),
465            public_key:       succ_pub_key,
466            valid_until:      None,
467            successor_key_id: None,
468        };
469
470        Ok(RotationResult {
471            predecessor,
472            successor,
473            grace_period_until: valid_until,
474        })
475    }
476
477    /// Walk the rotation chain forward from `id`, returning the ordered
478    /// list of key ids: `[id, successor_of_id, ...]`. The first element is
479    /// always `id` itself. Stops at a key with no `successor_key_id`.
480    pub fn successor_chain(&self, id: &str) -> Result<Vec<KeyId>, KeyError> {
481        let mut chain = Vec::new();
482        let mut cursor = id.to_string();
483        // Cap iterations at the manifest size to defend against a corrupt
484        // chain that loops back on itself. A well-formed chain is bounded
485        // by the number of keys in the keystore.
486        let max_steps = self.read_manifest()?.key_ids.len() + 1;
487        for _ in 0..max_steps {
488            chain.push(cursor.clone());
489            let entry = self.load_entry(&cursor)?;
490            match entry.successor_key_id {
491                Some(next) => cursor = next,
492                None => return Ok(chain),
493            }
494        }
495        Err(KeyError::Crypto(format!(
496            "rotation chain starting at {id} exceeds keystore size; suspected loop"
497        )))
498    }
499
500    /// Returns the `KeyInfo` for every key whose `valid_until` is either
501    /// unset or strictly after `at_unix_secs`. The result includes both
502    /// rotated-but-still-in-grace predecessors and never-rotated keys.
503    /// Useful for building a verifier's accept-set as of a given time.
504    pub fn valid_keys_at(&self, at_unix_secs: u64) -> Result<Vec<KeyInfo>, KeyError> {
505        let cutoff_rfc = crate::statements::unix_to_rfc3339(at_unix_secs);
506        Ok(self.list()?
507            .into_iter()
508            .filter(|k| match &k.valid_until {
509                None => true,
510                Some(until) => until.as_str() > cutoff_rfc.as_str(),
511            })
512            .collect())
513    }
514
515    /// Returns a boxed `Signer` for the current default key.
516    pub fn default_signer(&self) -> Result<Box<dyn Signer>, KeyError> {
517        let manifest = self.read_manifest()?;
518        let id = manifest.default_key_id.ok_or(KeyError::NoDefaultKey)?;
519        self.signer(&id)
520    }
521
522    /// Returns a boxed `Signer` for a specific key ID.
523    ///
524    /// Refuses to load if the on-disk key file has insecure permissions
525    /// (any group or world bits). This is the choke point for *all*
526    /// signing — public-key reads and successor lookups go through
527    /// `read_entry` / `public_key` and are not affected.
528    ///
529    /// Bypass with `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` for controlled
530    /// environments (CI sandboxes, recovery flows). The bypass should
531    /// not be set in normal operation.
532    ///
533    /// TOCTOU note: the perm-check and the ciphertext read run against
534    /// the SAME file descriptor (open once, fstat, then read from that
535    /// fd). The previous shape — `check_key_file_perms(path)` followed
536    /// by `load_entry(id)` (which called `fs::read(path)`) — opened the
537    /// file twice. An attacker with write access to `~/.treeship/keys/`
538    /// could swap the file between the two opens: first present an
539    /// owner-only file to pass the perm gate, then replace it with a
540    /// different (loose-perm) file containing an attacker-controlled
541    /// scalar before the second `open`. The single-fd shape closes that
542    /// window because the inode is pinned by the open file descriptor;
543    /// path-level swaps after the open don't affect what we read. This
544    /// matches the pattern in `session/event_log.rs::open_lock_file`.
545    pub fn signer(&self, id: &str) -> Result<Box<dyn Signer>, KeyError> {
546        let entry = self.read_entry_with_perm_check(id)?;
547
548        // Dispatcher: v2 ciphertexts start with magic 0x54, version 0x02
549        // and use real AES-256-GCM. Older entries fall through to the
550        // legacy SHA-256-CTR+HMAC path (`decrypt_legacy_v1`) and are
551        // transparently re-encrypted in the new format below.
552        let was_legacy = is_legacy_v1(&entry.enc_priv_key);
553        let mut used_fallback = false;
554        let secret = match decrypt_from_disk(
555            &self.machine_key,
556            &entry.id,
557            &entry.public_key,
558            &entry.enc_priv_key,
559            &entry.nonce,
560        ) {
561            Ok(secret) => secret,
562            Err(primary_err) => {
563                // The entry may be wrapped under an older machine-key
564                // derivation: the v1 hostname key (any pre-stable keystore),
565                // the raw-path key (pre-canonicalization through a symlink),
566                // or a v1 key under a hostname macOS has since renamed away
567                // (the LocalHostName candidates). Try each in order; the
568                // first hit marks the entry for rewrapping under the
569                // primary. All misses surface the PRIMARY error, enriched,
570                // so the diagnosis is unchanged for normal failures.
571                let mut recovered = None;
572                for candidate in &self.fallback_machine_keys {
573                    if let Ok(secret) = decrypt_from_disk(
574                        candidate,
575                        &entry.id,
576                        &entry.public_key,
577                        &entry.enc_priv_key,
578                        &entry.nonce,
579                    ) {
580                        recovered = Some(secret);
581                        used_fallback = true;
582                        break;
583                    }
584                }
585                match recovered {
586                    Some(secret) => secret,
587                    None => return Err(self.enrich_crypto_error(primary_err)),
588                }
589            }
590        };
591
592        // L3: wrap the on-stack copy of the decrypted secret in a
593        // `Zeroizing` so the byte buffer is wiped on drop. `secret`
594        // itself is already a `Zeroizing<Vec<u8>>` returned by
595        // `decrypt_from_disk`, but `try_into::<[u8; 32]>` produces an
596        // independent stack-allocated array that the Vec's Drop will
597        // not cover. Without this wrapper, returning from `signer()`
598        // would leave the secret scalar in stale stack memory until
599        // a future stack frame happens to overwrite it.
600        let secret_arr: Zeroizing<[u8; 32]> = Zeroizing::new(
601            secret.as_slice().try_into()
602                .map_err(|_| KeyError::Crypto("decrypted key is wrong length".into()))?
603        );
604
605        // Transparent migration: if this entry was still in the legacy
606        // v1 format (the broken SHA-256-CTR construction from
607        // TS-2026-001), re-encrypt it with v2 AES-256-GCM and rewrite
608        // the file. We do this best-effort -- a migration failure here
609        // must NOT block signing for the current call, since the
610        // in-memory secret is already valid. The next decrypt on a
611        // fresh process will retry.
612        if was_legacy || used_fallback {
613            if let Err(e) = self.migrate_entry_to_primary(&entry, &secret_arr) {
614                // Surface the failure as a tracing-style stderr note
615                // rather than an error -- the user's signing flow is
616                // unaffected, and we'd rather them know about it than
617                // wedge the call.
618                eprintln!(
619                    "treeship: keystore entry {} could not be rewrapped \
620                     under the current machine key ({}); will retry next \
621                     load",
622                    entry.id, e
623                );
624            }
625        }
626
627        let signer = Ed25519Signer::from_bytes(&entry.id, &secret_arr)
628            .map_err(|e| KeyError::Crypto(e.to_string()))?;
629
630        Ok(Box::new(signer))
631    }
632
633    /// Re-encrypt a legacy v1 entry with the new v2 AEAD and persist
634    /// it. Updates the in-memory cache so subsequent loads in the same
635    /// process see the migrated entry. Idempotent; safe to invoke
636    /// concurrently because the migration is serialized by a per-entry
637    /// advisory lock on `<entry>.migrate.lock` (TS-2026-001 H3).
638    ///
639    /// We lock a *sentinel* file rather than the entry file itself,
640    /// because the entry file is renamed-into-place during the atomic
641    /// write inside `write_entry`. Holding a flock on the entry's inode
642    /// while a sibling process renames a new inode into its path is
643    /// nonsensical (the lock would survive on the now-orphaned inode);
644    /// the sentinel sidecar has a stable identity for the whole
645    /// migration window.
646    ///
647    /// Same blocking-flock pattern as `packages/core/src/session/event_log.rs`
648    /// (Lane F): exclusive lock, then a same-thread re-read to settle
649    /// "did a peer already migrate while I was waiting?" cleanly.
650    fn migrate_entry_to_primary(
651        &self,
652        old_entry: &EncryptedEntry,
653        secret: &[u8; 32],
654    ) -> Result<(), KeyError> {
655        let entry_path = self.entry_path(&old_entry.id);
656        let lock_path = entry_path.with_extension("migrate.lock");
657
658        // Open (or create) the sentinel lock file with restrictive perms
659        // and take an exclusive flock. We intentionally use the blocking
660        // `lock_exclusive` -- not `try_lock_exclusive` -- because the
661        // migration window is short (a single AEAD encrypt + atomic
662        // rename) and the worst case under contention is one writer
663        // serialized behind another. Pulling the
664        // try-with-bounded-retry pattern in here would buy us nothing:
665        // the second writer's re-read after the lock releases would
666        // observe the now-v2 entry and short-circuit.
667        let lock_file = open_migration_lock_file(&lock_path)
668            .map_err(KeyError::Io)?;
669
670        #[cfg(not(target_family = "wasm"))]
671        {
672            use fs2::FileExt;
673            lock_file.lock_exclusive().map_err(KeyError::Io)?;
674        }
675
676        // Under the lock: did a peer already complete the migration
677        // while we were waiting? If so, our work is done -- we must
678        // NOT rewrite, because we'd overwrite a peer's freshly-rotated
679        // v2 ciphertext with our own (semantically equivalent, but
680        // unnecessary I/O and an unnecessary cache update).
681        if let Ok(current) = self.read_entry(&old_entry.id) {
682            // "Already migrated" now means: v2 format AND decryptable under
683            // the PRIMARY machine key. The format check alone is not enough
684            // since this path also rewraps v2 entries that a fallback
685            // machine key decrypted (hostname drift, raw-path legacy); the
686            // primary-decrypt probe is what proves a peer finished the job.
687            let already_primary = !is_legacy_v1(&current.enc_priv_key)
688                && decrypt_from_disk(
689                    &self.machine_key,
690                    &current.id,
691                    &current.public_key,
692                    &current.enc_priv_key,
693                    &current.nonce,
694                )
695                .is_ok();
696            if already_primary {
697                // Peer already migrated. Refresh the cache so subsequent
698                // loads in this process see the rewrapped entry rather
699                // than the stale copy our caller passed in.
700                if let Ok(mut cache) = self.cache.write() {
701                    cache.insert(current.id.clone(), current);
702                }
703                // Lock drops at function exit; sentinel file remains on
704                // disk as a harmless inode (no migration data, idempotent
705                // for future invocations).
706                return Ok(());
707            }
708        }
709
710        let new_ciphertext = encrypt_for_disk_v2(
711            &self.machine_key,
712            &old_entry.id,
713            &old_entry.public_key,
714            secret,
715        )
716        .map_err(KeyError::Crypto)?;
717
718        let migrated = EncryptedEntry {
719            id:               old_entry.id.clone(),
720            algorithm:        old_entry.algorithm.clone(),
721            created_at:       old_entry.created_at.clone(),
722            public_key:       old_entry.public_key.clone(),
723            enc_priv_key:     new_ciphertext,
724            // v2 carries the nonce inline; clear the legacy field.
725            nonce:            Vec::new(),
726            valid_until:      old_entry.valid_until.clone(),
727            successor_key_id: old_entry.successor_key_id.clone(),
728        };
729
730        self.write_entry(&migrated)?;
731        if let Ok(mut cache) = self.cache.write() {
732            cache.insert(migrated.id.clone(), migrated);
733        }
734
735        // Best-effort cleanup of the sentinel lock file. We hold the
736        // lock until function exit (drop), so by the time we reach
737        // here it is safe to unlink the inode -- future migrations
738        // for this entry will succeed via the early-return path
739        // because the entry is now v2. Leaving the sentinel behind is
740        // also harmless; on Unix removing a flocked file is allowed
741        // and the lock is released on fd drop regardless.
742        let _ = std::fs::remove_file(&lock_path);
743
744        // Keep the lock_file binding alive to function exit so the
745        // flock is held across write_entry + remove_file. Explicit
746        // drop makes the intent obvious to readers.
747        drop(lock_file);
748        Ok(())
749    }
750
751    /// Wrap a bare crypto error (typically "MAC verification failed ..." from
752    /// the AES-GCM decrypt path) with a diagnostic and an actionable recovery
753    /// path.
754    ///
755    /// The common failure mode in the wild is a pre-0.9.x keystore whose
756    /// machine-key derivation was seed-file-based. Later versions derive
757    /// the machine key from hostname+username (macOS) or /etc/machine-id
758    /// (Linux), so old ciphertexts can't be MAC-verified with the new key.
759    /// Detecting that case is best-effort: the presence of a legacy seed
760    /// file (`.machineseed` or `machine_seed` inside the keys dir) is a
761    /// strong hint. If we see one, call it out explicitly.
762    fn enrich_crypto_error(&self, raw: String) -> KeyError {
763        // Only enrich on MAC failures -- other errors (I/O, wrong length) are
764        // surfaced as-is because their remediation differs.
765        if !raw.contains("MAC verification failed") {
766            return KeyError::Crypto(raw);
767        }
768
769        let legacy_seed_dot = self.dir.join(".machineseed");
770        let legacy_seed     = self.dir.join("machine_seed");
771        let has_legacy_seed = legacy_seed_dot.exists() || legacy_seed.exists();
772
773        let diagnosis = if has_legacy_seed {
774            "your keystore was created by an older Treeship version whose \
775             machine-key derivation has since changed. The ciphertext is \
776             intact but cannot be decrypted under the current derivation."
777        } else {
778            "the keystore cannot be decrypted under any known machine-key \
779             derivation (hardware id, current hostname, mDNS LocalHostName, \
780             raw path). Usual causes: the key file was copied from a \
781             different machine, the username changed, or the file was \
782             corrupted."
783        };
784
785        // Resolve the user's ~/.treeship path for the recovery command, so
786        // we give a copy-pasteable command rather than a generic instruction.
787        let ts_dir = std::env::var("HOME")
788            .map(|h| format!("{h}/.treeship"))
789            .unwrap_or_else(|_| "~/.treeship".into());
790
791        // The outer KeyError::Crypto Display impl already prepends
792        // "keys crypto: "; don't double it. Start with the raw MAC error
793        // so the user still sees the underlying cryptographic reason,
794        // then follow with the human-readable diagnosis and recovery.
795        let msg = format!(
796            "{raw}\n\n  \
797             Diagnosis: {diagnosis}\n\n  \
798             Recovery (nondestructive -- the old keystore is moved aside, \
799             not deleted; any sealed .treeship packages you produced remain \
800             verifiable since their receipts embed the old public key):\n\n    \
801             mv {ts_dir} {ts_dir}.bak.$(date +%s)\n    \
802             treeship init\n"
803        );
804
805        KeyError::Crypto(msg)
806    }
807
808    /// Returns the default key ID.
809    pub fn default_key_id(&self) -> Result<KeyId, KeyError> {
810        self.read_manifest()?
811            .default_key_id
812            .ok_or(KeyError::NoDefaultKey)
813    }
814
815    /// Lists all keys.
816    pub fn list(&self) -> Result<Vec<KeyInfo>, KeyError> {
817        let manifest = self.read_manifest()?;
818        let default  = manifest.default_key_id.as_deref().unwrap_or("");
819
820        manifest.key_ids.iter().map(|id| {
821            let entry = self.load_entry(id)?;
822            Ok(KeyInfo {
823                id:               entry.id.clone(),
824                algorithm:        entry.algorithm.clone(),
825                is_default:       entry.id == default,
826                created_at:       entry.created_at.clone(),
827                fingerprint:      fingerprint(&entry.public_key),
828                public_key:       entry.public_key.clone(),
829                valid_until:      entry.valid_until.clone(),
830                successor_key_id: entry.successor_key_id.clone(),
831            })
832        }).collect()
833    }
834
835    /// Sets the default signing key.
836    pub fn set_default(&self, id: &str) -> Result<(), KeyError> {
837        // Verify the key exists before updating the manifest.
838        self.load_entry(id)?;
839        let mut manifest = self.read_manifest()?;
840        manifest.default_key_id = Some(id.to_string());
841        self.write_manifest(&manifest)
842    }
843
844    /// Returns the public key bytes for a key ID.
845    pub fn public_key(&self, id: &str) -> Result<Vec<u8>, KeyError> {
846        Ok(self.load_entry(id)?.public_key)
847    }
848
849    /// Encrypt an arbitrary secret for at-rest storage OUTSIDE the keystore
850    /// (for example, the hub DPoP signing key that lives in `config.json`).
851    ///
852    /// The secret is sealed under this machine's key with the same
853    /// AES-256-GCM v2 framing the keystore uses for private keys, so a
854    /// stolen `config.json` is useless on another machine — the same
855    /// guarantee AGENTS.md §7 already makes for the ship key. `context` is
856    /// bound as AEAD associated data: a blob sealed for one context (e.g.
857    /// `"hub-dpop:v1:<hub_id>"`) will not decrypt under another, so a local
858    /// attacker cannot swap a ciphertext between two hub connections in the
859    /// same file. Store the returned bytes base64-encoded; recover the
860    /// plaintext with [`KeyStore::decrypt_secret`] using the same `context`.
861    pub fn encrypt_secret(&self, context: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyError> {
862        // public_key is empty: for a non-keystore secret there is no
863        // associated pubkey to bind, but `context` (carried as the AAD
864        // entry_id) plus the framing prefix still bind machine + purpose.
865        encrypt_for_disk_v2(&self.machine_key, context, &[], plaintext)
866            .map_err(KeyError::Crypto)
867    }
868
869    /// Decrypt a blob produced by [`KeyStore::encrypt_secret`] with the same
870    /// `context`. Tries the primary machine key first, then the same
871    /// migration fallbacks used for keystore entries, so a machine whose
872    /// hostname/username drifted still recovers the secret. A wrong
873    /// `context`, a tampered blob, or a different machine each fail closed
874    /// with a MAC error rather than returning wrong bytes.
875    pub fn decrypt_secret(&self, context: &str, blob: &[u8]) -> Result<Vec<u8>, KeyError> {
876        match decrypt_v2(&self.machine_key, context, &[], blob) {
877            Ok(pt) => Ok(pt),
878            Err(primary_err) => {
879                for candidate in &self.fallback_machine_keys {
880                    if let Ok(pt) = decrypt_v2(candidate, context, &[], blob) {
881                        return Ok(pt);
882                    }
883                }
884                Err(KeyError::Crypto(primary_err))
885            }
886        }
887    }
888
889    // --- private ---
890
891    fn load_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
892        // Check cache first.
893        if let Ok(cache) = self.cache.read() {
894            if let Some(entry) = cache.get(id) {
895                return Ok(entry.clone());
896            }
897        }
898        self.read_entry(id)
899    }
900
901    fn entry_path(&self, id: &str) -> PathBuf {
902        self.dir.join(format!("{}.json", id))
903    }
904
905    fn write_entry(&self, entry: &EncryptedEntry) -> Result<(), KeyError> {
906        let path = self.entry_path(&entry.id);
907        let json = serde_json::to_vec_pretty(entry)?;
908        write_file_600(&path, &json)?;
909        Ok(())
910    }
911
912    fn read_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
913        let path = self.entry_path(id);
914        if !path.exists() {
915            return Err(KeyError::NotFound(id.to_string()));
916        }
917        let bytes = fs::read(&path)?;
918        let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
919        Ok(entry)
920    }
921
922    /// Single-open, race-free counterpart to `read_entry` for the
923    /// signing path. Opens the key file ONCE, fstat's the file
924    /// descriptor to check perms, then reads the JSON from the SAME
925    /// descriptor. The path is never re-resolved after the open, so an
926    /// attacker who swaps `<id>.json` on disk between the perm check
927    /// and the ciphertext read cannot influence the bytes we decrypt.
928    ///
929    /// Cache: this path intentionally skips the in-memory entry cache.
930    /// The cache is read-mostly and seeded by `load_entry`, which is
931    /// fine for public-key lookups but defeats the perm gate (a cached
932    /// entry would let `signer()` return without ever consulting the
933    /// on-disk perms). The signing path is rare enough that the extra
934    /// disk read is not a hot spot.
935    fn read_entry_with_perm_check(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
936        let path = self.entry_path(id);
937
938        // Open once. NotFound surfaces as `KeyError::NotFound` to
939        // match the legacy `read_entry` shape; any other I/O error
940        // (permission denied at the *open* layer, EIO, etc.)
941        // propagates via the `From<io::Error>` impl.
942        let mut file = match fs::File::open(&path) {
943            Ok(f) => f,
944            Err(e) if e.kind() == io::ErrorKind::NotFound => {
945                return Err(KeyError::NotFound(id.to_string()));
946            }
947            Err(e) => return Err(KeyError::Io(e)),
948        };
949
950        // Perm check on the open fd. On Unix `File::metadata` is
951        // documented to call `fstat` on the underlying fd, which pins
952        // the inode -- a subsequent path swap on disk cannot change
953        // what we see. The bypass env var continues to short-circuit.
954        check_open_key_file_perms(&path, &file)?;
955
956        // Read the full ciphertext envelope from the same fd.
957        let mut bytes = Vec::new();
958        file.read_to_end(&mut bytes)?;
959
960        let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
961        Ok(entry)
962    }
963
964    fn manifest_path(&self) -> PathBuf {
965        self.dir.join("manifest.json")
966    }
967
968    fn read_manifest(&self) -> Result<Manifest, KeyError> {
969        let path = self.manifest_path();
970        if !path.exists() {
971            return Ok(Manifest::default());
972        }
973        let bytes = fs::read(&path)?;
974        Ok(serde_json::from_slice(&bytes)?)
975    }
976
977    fn write_manifest(&self, m: &Manifest) -> Result<(), KeyError> {
978        let json = serde_json::to_vec_pretty(m)?;
979        write_file_600(&self.manifest_path(), &json)?;
980        Ok(())
981    }
982}
983
984// --- Crypto helpers ---
985//
986// AEAD choice: AES-256-GCM via the RustCrypto `aes-gcm` 0.10 crate.
987// Reasons:
988//   - Matches the original (documented but never implemented) intent of
989//     the keystore, so audit reports and SECURITY.md don't need to be
990//     re-anchored on a different primitive.
991//   - Well-audited, widely deployed, no platform gotchas.
992//   - `chacha20poly1305` would have been a defensible alternative
993//     (slightly better software performance), but the migration cost of
994//     changing the documented primitive while we already have to ship a
995//     migration for the broken construction is not worth it.
996//
997// On-disk v2 format (`encrypt_for_disk_v2`):
998//   [ magic = 0x54 ('T') ]   1 byte
999//   [ version = 0x02     ]   1 byte
1000//   [ nonce              ]  12 bytes (random per encryption)
1001//   [ ciphertext || tag  ]  N + 16 bytes (tag appended by aead crate)
1002//
1003// The first byte (0x54) is a structural sentinel so we can dispatch on
1004// the format without relying on length heuristics. v1 ciphertexts start
1005// with the first byte of their random nonce, so the chance of an
1006// accidental v1 entry that looks like v2 is ~1/2^16 (matching both magic
1007// AND version byte) and we still re-validate by AEAD-decrypting; if the
1008// AEAD fails on something that looks like v2, we fall back to v1.
1009
1010const KEYSTORE_MAGIC: u8 = 0x54; // 'T'
1011const KEYSTORE_VERSION_V2: u8 = 0x02;
1012
1013/// Build the v2 keystore AEAD AAD.
1014///
1015/// The AAD binds two things into the GCM tag beyond ciphertext+nonce:
1016///
1017/// 1. **Framing prefix** (`[KEYSTORE_MAGIC, KEYSTORE_VERSION_V2]`) so
1018///    flipping the magic or version byte on disk surfaces as a MAC
1019///    failure rather than dispatcher confusion (the M2 audit finding).
1020/// 2. **Entry identity** (`entry_id` and `public_key`) so an attacker
1021///    with write access to `~/.treeship/keys/` cannot copy entry A's
1022///    `enc_priv_key` ciphertext into entry B's JSON envelope. Without
1023///    this binding, the swap would decrypt cleanly (same machine key,
1024///    same framing-only AAD) and the signer for advertised key id A
1025///    would silently sign with key B's secret scalar — un-binding
1026///    `KeyInfo.public_key` from the actual scalar in use. This closes
1027///    the "intra-keystore swap" class flagged in the post-merge audit
1028///    of TS-2026-001.
1029///
1030/// Every variable-length field is length-prefixed with a big-endian
1031/// u32 before its bytes. Concatenating variable-length fields without
1032/// length prefixes is a forgery class (an attacker who controls field
1033/// boundaries can shift bytes between fields and present a different
1034/// `(entry_id, public_key)` pair whose AAD-bytes serialize identically).
1035/// `entry_id` is a fixed-prefix `key_<hex>` string in practice, but we
1036/// length-prefix it anyway to defend against future id schemes.
1037///
1038/// The AAD must be byte-identical on encrypt and decrypt. Future
1039/// versions (V3+) get their own builder; the dispatcher picks which
1040/// to use based on the framing prefix.
1041fn build_aad_v2(entry_id: &str, public_key: &[u8]) -> Vec<u8> {
1042    let mut aad = Vec::with_capacity(2 + 4 + entry_id.len() + 4 + public_key.len());
1043    aad.push(KEYSTORE_MAGIC);
1044    aad.push(KEYSTORE_VERSION_V2);
1045    aad.extend_from_slice(&(entry_id.len() as u32).to_be_bytes());
1046    aad.extend_from_slice(entry_id.as_bytes());
1047    aad.extend_from_slice(&(public_key.len() as u32).to_be_bytes());
1048    aad.extend_from_slice(public_key);
1049    aad
1050}
1051
1052/// AES-256-GCM (the real one) encrypt for at-rest keystore storage.
1053/// Returns the framed v2 blob ready to drop into `EncryptedEntry::enc_priv_key`.
1054///
1055/// Output: `[magic, version, nonce(12), ciphertext || tag(16)]`.
1056///
1057/// The AEAD's Associated Authenticated Data binds:
1058/// - the framing prefix (M2 — flipping magic/version surfaces as MAC failure)
1059/// - the entry id and public key (post-merge audit fix-up — closes the
1060///   intra-keystore swap class where a local attacker copies entry A's
1061///   `enc_priv_key` into entry B's JSON envelope).
1062///
1063/// See `build_aad_v2` for the exact layout. `entry_id` and `public_key`
1064/// must match what gets serialized into the `EncryptedEntry` JSON;
1065/// `decrypt_for_disk_v2` reads them back from the deserialized entry
1066/// to recompute the AAD.
1067fn encrypt_for_disk_v2(
1068    key: &[u8; 32],
1069    entry_id: &str,
1070    public_key: &[u8],
1071    plaintext: &[u8],
1072) -> Result<Vec<u8>, String> {
1073    // Wrap the in-memory AEAD key in Zeroizing so the local stack copy
1074    // is wiped on drop. The aes-gcm cipher object owns its own internal
1075    // expanded key schedule; that's outside our control, but the raw
1076    // 32-byte buffer at this scope is ours to clear.
1077    let key_buf: Zeroizing<[u8; 32]> = Zeroizing::new(*key);
1078    let aead_key: &AesKey<Aes256Gcm> = AesKey::<Aes256Gcm>::from_slice(key_buf.as_slice());
1079    let cipher = Aes256Gcm::new(aead_key);
1080
1081    // 96-bit random nonce from the OS CSPRNG.
1082    let nonce = Aes256Gcm::generate_nonce(&mut AeadOsRng);
1083
1084    let aad = build_aad_v2(entry_id, public_key);
1085    let ciphertext = cipher
1086        .encrypt(
1087            &nonce,
1088            Payload {
1089                msg: plaintext,
1090                aad: aad.as_slice(),
1091            },
1092        )
1093        .map_err(|e| format!("aead encrypt failed: {e}"))?;
1094
1095    let mut out = Vec::with_capacity(2 + 12 + ciphertext.len());
1096    out.push(KEYSTORE_MAGIC);
1097    out.push(KEYSTORE_VERSION_V2);
1098    out.extend_from_slice(nonce.as_slice());
1099    out.extend_from_slice(&ciphertext);
1100    Ok(out)
1101}
1102
1103/// AES-256-GCM decrypt of a v2 framed blob. Uses the same AAD binding
1104/// as `encrypt_for_disk_v2`:
1105///   - framing prefix (so a tampered magic/version surfaces as MAC failure)
1106///   - entry id + public key (so swapping `enc_priv_key` between entries
1107///     in the same keystore surfaces as MAC failure).
1108///
1109/// `entry_id` and `public_key` come from the `EncryptedEntry` JSON
1110/// envelope that holds `blob`. The caller is responsible for passing the
1111/// *envelope's* id and pubkey, not values from some other source — that
1112/// is precisely what binds the ciphertext to its envelope.
1113fn decrypt_v2(
1114    key: &[u8; 32],
1115    entry_id: &str,
1116    public_key: &[u8],
1117    blob: &[u8],
1118) -> Result<Vec<u8>, String> {
1119    // Minimum: magic(1) + version(1) + nonce(12) + tag(16) = 30 bytes.
1120    if blob.len() < 30 {
1121        return Err("v2 ciphertext too short".into());
1122    }
1123    if blob[0] != KEYSTORE_MAGIC || blob[1] != KEYSTORE_VERSION_V2 {
1124        return Err("v2 ciphertext has wrong magic/version".into());
1125    }
1126    let nonce_bytes = &blob[2..14];
1127    let ct = &blob[14..];
1128
1129    let key_buf: Zeroizing<[u8; 32]> = Zeroizing::new(*key);
1130    let aead_key: &AesKey<Aes256Gcm> = AesKey::<Aes256Gcm>::from_slice(key_buf.as_slice());
1131    let cipher = Aes256Gcm::new(aead_key);
1132    let nonce = Nonce::from_slice(nonce_bytes);
1133
1134    let aad = build_aad_v2(entry_id, public_key);
1135    cipher
1136        .decrypt(
1137            nonce,
1138            Payload {
1139                msg: ct,
1140                aad: aad.as_slice(),
1141            },
1142        )
1143        .map_err(|_| "MAC verification failed — key file may be corrupt or wrong machine".into())
1144}
1145
1146/// Returns true iff `blob` is shaped like a v1 (legacy) ciphertext.
1147/// Used by the dispatcher to decide whether a successful decrypt should
1148/// trigger a transparent re-encrypt to v2.
1149fn is_legacy_v1(blob: &[u8]) -> bool {
1150    // A v2 blob always starts with [magic, version]. Anything else
1151    // (including the empty enc_priv_key case during partial writes) is
1152    // treated as legacy and routed through the v1 path, which will fail
1153    // cleanly on garbage.
1154    !(blob.len() >= 2 && blob[0] == KEYSTORE_MAGIC && blob[1] == KEYSTORE_VERSION_V2)
1155}
1156
1157/// Top-level decrypt dispatcher used by the keystore. Tries v2 if the
1158/// blob carries the magic+version prefix, otherwise falls through to the
1159/// legacy v1 path. If a blob looks like v2 but AEAD verification fails,
1160/// we also try v1 — this defends against the (negligible) probability
1161/// that a legacy ciphertext's random first two bytes happen to collide
1162/// with our magic+version.
1163///
1164/// M1 (TS-2026-001 audit): when the blob is v2-shaped and BOTH the v2
1165/// AEAD and the v1 fallback fail, surface the v2 error rather than the
1166/// v1 error. v1's failure on a v2-shaped blob is mechanical (wrong
1167/// MAC computed under the wrong construction) and tells the user
1168/// nothing useful; v2's failure is the actually-relevant signal
1169/// (MAC verification under the documented AEAD). The previous code
1170/// would mask the meaningful error with a confused legacy error
1171/// message that pointed at the wrong remediation.
1172fn decrypt_from_disk(
1173    key: &[u8; 32],
1174    entry_id: &str,
1175    public_key: &[u8],
1176    enc_data: &[u8],
1177    legacy_nonce_field: &[u8],
1178) -> Result<Zeroizing<Vec<u8>>, String> {
1179    if !is_legacy_v1(enc_data) {
1180        match decrypt_v2(key, entry_id, public_key, enc_data) {
1181            Ok(pt) => return Ok(Zeroizing::new(pt)),
1182            Err(v2_err) => {
1183                // Collision fallback. v1 entries had random first bytes;
1184                // there's a vanishing chance one looks like v2 framing.
1185                // Try v1 first; if it succeeds we have a legitimate
1186                // legacy entry whose framing happens to look v2-shaped.
1187                // If v1 also fails, surface the v2 error (the
1188                // semantically meaningful one) rather than v1's
1189                // mechanical-junk failure.
1190                return match decrypt_legacy_v1(key, enc_data, legacy_nonce_field) {
1191                    Ok(pt) => Ok(Zeroizing::new(pt)),
1192                    Err(_) => Err(v2_err),
1193                };
1194            }
1195        }
1196    }
1197    decrypt_legacy_v1(key, enc_data, legacy_nonce_field).map(Zeroizing::new)
1198}
1199
1200/// DEPRECATED: legacy at-rest decryption for keystores written before
1201/// v0.10.3. This is the SHA-256-CTR + HMAC-SHA-256 construction that
1202/// was mis-labelled as AES-256-GCM (TS-2026-001). The CTR keystream is
1203/// also degenerate (the same `enc_key` byte is reused once per
1204/// plaintext byte, since `block[i % 32]` indexes the same SHA-256 output
1205/// modulo 32), so the construction is NOT a real stream cipher even
1206/// ignoring the AEAD mislabelling.
1207///
1208/// Kept ONLY to migrate existing on-disk keystores forward to the v2
1209/// AEAD format. Never call this for new writes. The encrypt counterpart
1210/// has been removed from the v2 codepath — the only place v1
1211/// ciphertexts come from is files written by older Treeship versions.
1212pub fn aes_gcm_decrypt(
1213    key: &[u8; 32],
1214    enc_data: &[u8],
1215    _nonce_unused: &[u8],
1216) -> Result<Vec<u8>, String> {
1217    // Preserved as a public symbol because the `treeship-vi` sibling
1218    // crate calls it directly. vi only ever produces v1 ciphertexts
1219    // (its `aes_gcm_encrypt` shim calls `legacy_v1_encrypt`) and has
1220    // no concept of the `EncryptedEntry` envelope that carries the
1221    // entry id + public key the v2 AAD now requires. Route this shim
1222    // directly through the legacy v1 path so vi's call site keeps
1223    // working byte-for-byte; vi's eventual migration release will
1224    // adopt its own AEAD path with its own envelope binding.
1225    decrypt_legacy_v1(key, enc_data, _nonce_unused)
1226}
1227
1228/// DEPRECATED: legacy at-rest encryption. Same caveats as
1229/// `aes_gcm_decrypt`. Kept ONLY as a public symbol for compatibility
1230/// with the `treeship-vi` sibling crate; the core keystore no longer
1231/// produces v1 ciphertexts.
1232///
1233/// New code MUST use `encrypt_for_disk_v2`. This function still
1234/// produces v1-format output so the vi crate's on-disk format remains
1235/// byte-stable until it migrates on its own cadence.
1236pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
1237    legacy_v1_encrypt(key, plaintext)
1238}
1239
1240/// Legacy v1 encrypt. SHA-256-CTR + HMAC-SHA-256. DO NOT USE for new
1241/// writes — present only so vi-keystore callers keep working until
1242/// they migrate. See `aes_gcm_encrypt` doc-comment for the security
1243/// caveats.
1244fn legacy_v1_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
1245    use sha2::Sha256;
1246
1247    let mut nonce = [0u8; 12];
1248    // v0.10.4 P1 audit: nonce reuse breaks AEAD. Read directly from the OS
1249    // CSPRNG via OsRng rather than the userland thread_rng, which can mis-seed
1250    // across forks / on some WASM targets. Legacy v1 write path is kept for
1251    // treeship-vi byte-stability but still needs sound nonces.
1252    OsRng.fill_bytes(&mut nonce);
1253
1254    let mut enc_key_input = key.to_vec();
1255    enc_key_input.extend_from_slice(&nonce);
1256    enc_key_input.extend_from_slice(b"enc");
1257    let enc_key = Sha256::digest(&enc_key_input);
1258
1259    let mut mac_key_input = key.to_vec();
1260    mac_key_input.extend_from_slice(&nonce);
1261    mac_key_input.extend_from_slice(b"mac");
1262    let mac_key = Sha256::digest(&mac_key_input);
1263
1264    let ciphertext: Vec<u8> = plaintext.iter().enumerate().map(|(i, &b)| {
1265        let mut block_input = enc_key.to_vec();
1266        block_input.extend_from_slice(&(i as u64).to_le_bytes());
1267        let block = Sha256::digest(&block_input);
1268        b ^ block[i % 32]
1269    }).collect();
1270
1271    let mut mac_input = mac_key.to_vec();
1272    mac_input.extend_from_slice(&nonce);
1273    mac_input.extend_from_slice(&ciphertext);
1274    let mac = Sha256::digest(&mac_input);
1275
1276    let mut out = Vec::with_capacity(12 + 32 + ciphertext.len());
1277    out.extend_from_slice(&nonce);
1278    out.extend_from_slice(&mac);
1279    out.extend_from_slice(&ciphertext);
1280
1281    Ok((out, nonce.to_vec()))
1282}
1283
1284/// Legacy v1 decrypt. SHA-256-CTR + HMAC-SHA-256. See the module-level
1285/// notes on TS-2026-001 for why this is broken; kept only to migrate
1286/// existing keystores forward.
1287fn decrypt_legacy_v1(
1288    key: &[u8; 32],
1289    enc_data: &[u8],
1290    _nonce_unused: &[u8],
1291) -> Result<Vec<u8>, String> {
1292    if enc_data.len() < 44 {
1293        return Err("ciphertext too short".into());
1294    }
1295    use sha2::Sha256;
1296
1297    let nonce      = &enc_data[..12];
1298    let stored_mac = &enc_data[12..44];
1299    let ciphertext = &enc_data[44..];
1300
1301    let nonce_arr: [u8; 12] = nonce.try_into().unwrap();
1302
1303    let mut enc_key_input = key.to_vec();
1304    enc_key_input.extend_from_slice(&nonce_arr);
1305    enc_key_input.extend_from_slice(b"enc");
1306    let enc_key = Sha256::digest(&enc_key_input);
1307
1308    let mut mac_key_input = key.to_vec();
1309    mac_key_input.extend_from_slice(&nonce_arr);
1310    mac_key_input.extend_from_slice(b"mac");
1311    let mac_key = Sha256::digest(&mac_key_input);
1312
1313    let mut mac_input = mac_key.to_vec();
1314    mac_input.extend_from_slice(&nonce_arr);
1315    mac_input.extend_from_slice(ciphertext);
1316    let computed_mac = Sha256::digest(&mac_input);
1317
1318    let mac_ok = stored_mac.iter().zip(computed_mac.iter())
1319        .fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0;
1320
1321    if !mac_ok {
1322        return Err("MAC verification failed — key file may be corrupt or wrong machine".into());
1323    }
1324
1325    let plaintext: Vec<u8> = ciphertext.iter().enumerate().map(|(i, &b)| {
1326        let mut block_input = enc_key.to_vec();
1327        block_input.extend_from_slice(&(i as u64).to_le_bytes());
1328        let block = Sha256::digest(&block_input);
1329        b ^ block[i % 32]
1330    }).collect();
1331
1332    Ok(plaintext)
1333}
1334
1335// --- Machine key derivation ---
1336
1337pub fn derive_machine_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
1338    // 1. Linux: /etc/machine-id (stable across reboots)
1339    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
1340        let trimmed = id.trim();
1341        if !trimmed.is_empty() {
1342            let mut h = Sha256::new();
1343            h.update(trimmed.as_bytes());
1344            h.update(store_dir.to_string_lossy().as_bytes());
1345            return Ok(h.finalize().into());
1346        }
1347    }
1348
1349    // 2. macOS: hostname + username derivation (v1, backward compatible).
1350    //
1351    // TODO(v0.7.0): Migrate to IOPlatformSerialNumber-based derivation.
1352    // The serial number is more stable (survives hostname and username
1353    // changes), but switching now would silently invalidate all existing
1354    // keys on macOS. A proper migration needs to:
1355    //   1. Try the new derivation first.
1356    //   2. On decryption failure, fall back to hostname+username.
1357    //   3. If legacy succeeds, re-encrypt with the new key and save.
1358    // Until that migration tooling is in place, keep hostname+username
1359    // as the primary derivation so existing users are not locked out.
1360    #[cfg(target_os = "macos")]
1361    {
1362        let hostname = std::process::Command::new("hostname")
1363            .output()
1364            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1365            .unwrap_or_default();
1366        let username = std::env::var("USER").unwrap_or_default();
1367        if !hostname.is_empty() && !username.is_empty() {
1368            return Ok(derive_machine_key_v1_from_parts(
1369                &hostname, &username, store_dir,
1370            ));
1371        }
1372    }
1373
1374    // 3. Fallback: random seed file. Co-located with the keystore so a
1375    //    project-local keystore (/proj/.treeship/keys/) keeps its seed at
1376    //    /proj/.treeship/machine_seed -- never reaching for ~/.treeship.
1377    //    A global keystore (~/.treeship/keys/) co-locates to
1378    //    ~/.treeship/machine_seed, which is byte-identical to the
1379    //    pre-v0.9.6 location, so existing global keystores keep working.
1380    //
1381    //    Backward-compat read order:
1382    //      1. <store_dir>/../machine_seed  (the new co-located path)
1383    //      2. ~/.treeship/machine_seed     (the old hardcoded path)
1384    //    Write order on first creation:
1385    //      1. <store_dir>/../machine_seed  if the parent exists/is writable
1386    //      2. ~/.treeship/machine_seed     as a last resort
1387    //
1388    //    This makes project-local config truly self-contained: an
1389    //    isolated /proj keystore can decrypt its own keys even when
1390    //    the user's ~/.treeship is corrupt or on a different machine,
1391    //    closing the trust-fabric isolation gap that blocked
1392    //    project-local smoke tests.
1393    let seed = read_or_create_machine_seed(store_dir)?;
1394
1395    let mut h = Sha256::new();
1396    h.update(b"treeship-machine-key-fallback:");
1397    h.update(seed.trim().as_bytes());
1398    h.update(b":");
1399    h.update(store_dir.to_string_lossy().as_bytes());
1400    Ok(h.finalize().into())
1401}
1402
1403/// Read the secret machine seed, creating it (OsRng, mode 0600) on first use.
1404/// Returns the hex-encoded seed string.
1405///
1406/// The seed is co-located with the keystore so a project-local keystore
1407/// (`/proj/.treeship/keys/`) keeps its seed at `/proj/.treeship/machine_seed`
1408/// and a global keystore at `~/.treeship/machine_seed` (byte-identical to the
1409/// pre-v0.9.6 location, so existing global keystores keep working).
1410///
1411/// AUD-19: as of the seed-primary wrapping change this is the PRIMARY entropy
1412/// for every at-rest signing-key wrap on every platform — not just a container
1413/// fallback — so it is security-critical. AUD-25: an existing seed file must be
1414/// a regular file the current user owns, with no group/world access; anything
1415/// else (a planted seed, loose perms, a symlink) is refused fail-closed rather
1416/// than trusted as key material.
1417fn read_or_create_machine_seed(store_dir: &Path) -> Result<String, KeyError> {
1418    let local_seed_path = store_dir.parent().map(|p| p.join("machine_seed"));
1419    let home = std::env::var("HOME")
1420        .map(std::path::PathBuf::from)
1421        .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
1422    let global_seed_path = home.join(".treeship").join("machine_seed");
1423
1424    // Read order: co-located seed first, then the legacy global path.
1425    if let Some(local) = local_seed_path.as_ref().filter(|p| p.exists()) {
1426        check_seed_file_secure(local)?;
1427        return fs::read_to_string(local).map_err(KeyError::Io);
1428    }
1429    if global_seed_path.exists() {
1430        check_seed_file_secure(&global_seed_path)?;
1431        return fs::read_to_string(&global_seed_path).map_err(KeyError::Io);
1432    }
1433
1434    // First use: mint a 32-byte seed straight from the OS CSPRNG.
1435    let mut bytes = [0u8; 32];
1436    OsRng.fill_bytes(&mut bytes);
1437    let seed_hex = hex_encode(&bytes);
1438
1439    // Prefer creating the seed co-located with the keystore; fall back to the
1440    // global path only when the keystore has no usable parent (store_dir is
1441    // "/" or similar pathological input).
1442    let target = match local_seed_path.as_ref() {
1443        Some(p) => {
1444            let _ = fs::create_dir_all(p.parent().unwrap_or(Path::new(".")));
1445            p.clone()
1446        }
1447        None => {
1448            let _ = fs::create_dir_all(global_seed_path.parent().unwrap_or(Path::new(".")));
1449            global_seed_path.clone()
1450        }
1451    };
1452    fs::write(&target, &seed_hex).map_err(KeyError::Io)?;
1453    #[cfg(unix)]
1454    {
1455        use std::os::unix::fs::PermissionsExt;
1456        let _ = fs::set_permissions(&target, fs::Permissions::from_mode(0o600));
1457    }
1458    Ok(seed_hex)
1459}
1460
1461/// AUD-25: refuse to trust a machine_seed that is not a regular file owned by
1462/// the current user with no group/world access. On non-unix this only checks
1463/// that it is a regular file (no ownership model to consult). The
1464/// `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` escape hatch mirrors the keystore's.
1465fn check_seed_file_secure(path: &Path) -> Result<(), KeyError> {
1466    let meta = fs::symlink_metadata(path).map_err(KeyError::Io)?;
1467    if !meta.file_type().is_file() {
1468        // A symlink or special file could redirect the read to attacker bytes.
1469        return Err(KeyError::Crypto(format!(
1470            "machine_seed at {} is not a regular file (refusing to use it as key material)",
1471            path.display()
1472        )));
1473    }
1474    #[cfg(unix)]
1475    {
1476        use std::os::unix::fs::MetadataExt;
1477        use std::os::unix::fs::PermissionsExt;
1478        let bypass = std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
1479            .map(|v| v == "1")
1480            .unwrap_or(false);
1481        if !bypass {
1482            let mode = meta.permissions().mode() & 0o777;
1483            if mode & 0o077 != 0 {
1484                return Err(KeyError::InsecureKeyPerms { path: path.to_path_buf(), mode });
1485            }
1486            if meta.uid() != nix_geteuid() {
1487                return Err(KeyError::Crypto(format!(
1488                    "machine_seed at {} is not owned by the current user (refusing to use a planted seed)",
1489                    path.display()
1490                )));
1491            }
1492        }
1493    }
1494    Ok(())
1495}
1496
1497/// Current effective uid, via libc. Kept tiny and unix-gated so the seed check
1498/// does not pull a new dependency.
1499#[cfg(unix)]
1500fn nix_geteuid() -> u32 {
1501    // SAFETY: geteuid is always successful and has no preconditions.
1502    unsafe { libc_geteuid() }
1503}
1504
1505#[cfg(unix)]
1506extern "C" {
1507    #[link_name = "geteuid"]
1508    fn libc_geteuid() -> u32;
1509}
1510
1511/// AUD-19: the PRIMARY at-rest wrapping key. Derives from the SECRET OsRng
1512/// machine seed (high-entropy, mode 0600), with the machine identifier mixed
1513/// in only as a binding salt — never as the sole entropy. This is what makes
1514/// an exfiltrated `keys/*.json` un-forgeable: before this the wrapping key was
1515/// `SHA256(guessable_machine_id ‖ store_path)`, so anyone who knew the victim's
1516/// hostname / machine-id could recompute it. Now the secret seed is required.
1517/// Two hosts with identical machine-id / hostname / user but different seeds
1518/// cannot decrypt each other's keystore.
1519fn derive_seed_primary_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
1520    let seed = read_or_create_machine_seed(store_dir)?;
1521    let mut h = Sha256::new();
1522    h.update(b"treeship-seed-primary-v1:");
1523    h.update(seed.trim().as_bytes());        // the secret (all the entropy)
1524    h.update(b":");
1525    h.update(machine_id_salt().as_bytes());  // binding salt only (non-secret)
1526    h.update(b":");
1527    h.update(store_dir.to_string_lossy().as_bytes());
1528    Ok(h.finalize().into())
1529}
1530
1531/// A best-effort stable machine identifier used ONLY as a binding salt in
1532/// [`derive_seed_primary_key`] (so a keystore is bound to the machine that
1533/// wrote it, in addition to the secret seed). Non-secret and possibly empty;
1534/// the security comes from the seed, not this.
1535fn machine_id_salt() -> String {
1536    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
1537        let t = id.trim();
1538        if !t.is_empty() {
1539            return t.to_string();
1540        }
1541    }
1542    // Hostname is a weak, drift-prone salt but fine as a last resort — it only
1543    // binds, it does not gate (all the security is in the secret seed).
1544    std::process::Command::new("hostname")
1545        .output()
1546        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1547        .unwrap_or_default()
1548}
1549
1550/// The v1 hostname+username machine-key derivation, as a pure function of
1551/// its inputs. This is the exact construction the macOS branch of
1552/// [`derive_machine_key`] has always used; extracting it lets `Store::open`
1553/// derive decrypt-only fallback candidates for hostnames the machine no
1554/// longer reports (macOS renames `kern.hostname` on network collisions,
1555/// which used to brick the keystore) without shelling `hostname` twice.
1556pub fn derive_machine_key_v1_from_parts(
1557    hostname: &str,
1558    username: &str,
1559    store_dir: &Path,
1560) -> [u8; 32] {
1561    let mut h = Sha256::new();
1562    h.update(b"treeship-machine-key:");
1563    h.update(hostname.as_bytes());
1564    h.update(b":");
1565    h.update(username.as_bytes());
1566    h.update(b":");
1567    h.update(store_dir.to_string_lossy().as_bytes());
1568    h.finalize().into()
1569}
1570
1571/// Hostname candidates a drifted macOS keystore may be wrapped under.
1572///
1573/// `scutil --get LocalHostName` holds the user-visible mDNS name, which
1574/// usually retains the value `hostname` reported when the keystore was
1575/// written even after macOS renames `kern.hostname` (DHCP, name-collision
1576/// auto-renames). `hostname` historically reported it with and without the
1577/// `.local` suffix depending on network state, so both variants are
1578/// candidates. Non-macOS platforms have no such drift (machine-id is
1579/// stable) and return no candidates.
1580#[cfg(target_os = "macos")]
1581fn local_hostname_variants() -> Vec<String> {
1582    let lh = std::process::Command::new("scutil")
1583        .args(["--get", "LocalHostName"])
1584        .output()
1585        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1586        .unwrap_or_default();
1587    if lh.is_empty() {
1588        return Vec::new();
1589    }
1590    vec![format!("{lh}.local"), lh]
1591}
1592
1593#[cfg(not(target_os = "macos"))]
1594fn local_hostname_variants() -> Vec<String> {
1595    Vec::new()
1596}
1597
1598/// The hardware-identifier half of [`derive_machine_key_stable`]: machine-id
1599/// (Linux) or IOPlatformSerialNumber (macOS), `None` when the machine offers
1600/// neither. Split out so `Store::open` can pick a hardware-stable PRIMARY
1601/// key without inheriting the stable derivation's seed-file fallback, whose
1602/// seed lives under the global `~/.treeship/.internal/` and would break
1603/// project-local keystore isolation (the v1 seed is co-located with the
1604/// keystore on purpose).
1605fn stable_hardware_key(store_dir: &Path) -> Option<[u8; 32]> {
1606    if let Ok(id) = fs::read_to_string("/etc/machine-id") {
1607        let trimmed = id.trim();
1608        if !trimmed.is_empty() {
1609            let mut h = Sha256::new();
1610            h.update(b"treeship-machine-key-v2:");
1611            h.update(trimmed.as_bytes());
1612            h.update(b":");
1613            h.update(store_dir.to_string_lossy().as_bytes());
1614            return Some(h.finalize().into());
1615        }
1616    }
1617
1618    #[cfg(target_os = "macos")]
1619    {
1620        if let Ok(output) = std::process::Command::new("ioreg")
1621            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
1622            .output()
1623        {
1624            let stdout = String::from_utf8_lossy(&output.stdout);
1625            for line in stdout.lines() {
1626                if line.contains("IOPlatformSerialNumber") {
1627                    if let Some(serial) = line.split('"').nth(3) {
1628                        if !serial.is_empty() {
1629                            let mut h = Sha256::new();
1630                            h.update(b"treeship-machine-key-v2:");
1631                            h.update(serial.as_bytes());
1632                            h.update(b":");
1633                            h.update(store_dir.to_string_lossy().as_bytes());
1634                            return Some(h.finalize().into());
1635                        }
1636                    }
1637                }
1638            }
1639        }
1640    }
1641
1642    None
1643}
1644
1645/// Stable machine key derivation for NEW keys (VI P-256, etc).
1646/// Uses hardware identifiers that survive hostname/user changes.
1647/// For legacy ship Ed25519 keys, use `derive_machine_key()` instead.
1648pub fn derive_machine_key_stable(store_dir: &Path) -> Result<[u8; 32], KeyError> {
1649    // 1./2. Hardware identifiers: /etc/machine-id (Linux) or
1650    //    IOPlatformSerialNumber (macOS) -- stable across hostname changes,
1651    //    user renames, non-interactive shells. Shared with `Store::open`'s
1652    //    primary-key selection via `stable_hardware_key`.
1653    if let Some(k) = stable_hardware_key(store_dir) {
1654        return Ok(k);
1655    }
1656
1657    // 3. Fallback: persistent random seed in ~/.treeship/.internal/
1658    //    Separate from key material. Mode 0600.
1659    let home = std::env::var("HOME")
1660        .map(std::path::PathBuf::from)
1661        .map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
1662    let seed_dir = home.join(".treeship").join(".internal");
1663    let _ = fs::create_dir_all(&seed_dir);
1664    #[cfg(unix)]
1665    {
1666        use std::os::unix::fs::PermissionsExt;
1667        let _ = fs::set_permissions(&seed_dir, fs::Permissions::from_mode(0o700));
1668    }
1669
1670    let seed_path = seed_dir.join("machine_seed_v2");
1671    let seed = if seed_path.exists() {
1672        fs::read_to_string(&seed_path).map_err(KeyError::Io)?
1673    } else {
1674        let mut bytes = [0u8; 32];
1675        // v0.10.4 P1 audit: machine_seed_v2 backs the v2 machine-key
1676        // fallback. Same OsRng rationale as the v1 seed above.
1677        OsRng.fill_bytes(&mut bytes);
1678        let seed_hex = hex_encode(&bytes);
1679        fs::write(&seed_path, &seed_hex).map_err(KeyError::Io)?;
1680        #[cfg(unix)]
1681        {
1682            use std::os::unix::fs::PermissionsExt;
1683            let _ = fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600));
1684        }
1685        seed_hex
1686    };
1687
1688    let mut h = Sha256::new();
1689    h.update(b"treeship-machine-key-v2-fallback:");
1690    h.update(seed.trim().as_bytes());
1691    h.update(b":");
1692    h.update(store_dir.to_string_lossy().as_bytes());
1693    Ok(h.finalize().into())
1694}
1695
1696// --- Utility ---
1697
1698fn new_key_id() -> KeyId {
1699    let mut b = [0u8; 8];
1700    // v0.10.4 P1 audit: key_id is mixed into AAD by encrypt_for_disk_v2, so
1701    // collisions or low-entropy ids would weaken the AAD binding. Use OsRng
1702    // directly so the id is OS-CSPRNG-quality even under fork or odd targets.
1703    OsRng.fill_bytes(&mut b);
1704    format!("key_{}", hex_encode(&b))
1705}
1706
1707fn fingerprint(pub_key: &[u8]) -> String {
1708    let h = Sha256::digest(pub_key);
1709    hex_encode(&h[..8])
1710}
1711
1712fn hex_encode(b: &[u8]) -> String {
1713    b.iter().fold(String::new(), |mut s, byte| {
1714        s.push_str(&format!("{:02x}", byte));
1715        s
1716    })
1717}
1718
1719/// Verify a private-key file has restrictive permissions before loading
1720/// it for signing. Returns `Ok(())` on non-Unix platforms, when the
1721/// `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` escape hatch is set, or when
1722/// the file is not group/world accessible. Otherwise returns
1723/// `KeyError::InsecureKeyPerms` with the offending path and mode.
1724///
1725/// **TOCTOU caveat:** this path-based check has an unavoidable race
1726/// window between the `stat` and any subsequent `open` of the same
1727/// path. New signing-path callers MUST use
1728/// `check_open_key_file_perms` (fstat on an already-open fd) instead;
1729/// this function is retained only for non-signing callers that
1730/// already accept the race (e.g. `treeship doctor` scanning the
1731/// keystore directory).
1732#[allow(dead_code)]
1733fn check_key_file_perms(path: &Path) -> Result<(), KeyError> {
1734    #[cfg(unix)]
1735    {
1736        use std::os::unix::fs::PermissionsExt;
1737        if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
1738            .map(|v| v == "1")
1739            .unwrap_or(false)
1740        {
1741            return Ok(());
1742        }
1743        // Missing files are reported by the caller as NotFound -- don't
1744        // mask that with a perm error.
1745        let meta = match fs::metadata(path) {
1746            Ok(m) => m,
1747            Err(_) => return Ok(()),
1748        };
1749        let mode = meta.permissions().mode();
1750        if mode & 0o077 != 0 {
1751            return Err(KeyError::InsecureKeyPerms {
1752                path: path.to_path_buf(),
1753                mode,
1754            });
1755        }
1756    }
1757    let _ = path;
1758    Ok(())
1759}
1760
1761/// Race-free perm gate: runs `fstat` on an already-open `File` and
1762/// rejects if the mode has any group or world bits. Use this from the
1763/// signing path: open the key file once, hand the resulting `File` to
1764/// this function, then read from the SAME `File` -- the inode is
1765/// pinned by the open fd, so a path-level swap between perm-check and
1766/// read cannot influence what we end up decrypting.
1767///
1768/// `path` is carried only for error reporting; it is never re-opened.
1769/// The `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` bypass is honored
1770/// identically to `check_key_file_perms` so existing CI workflows keep
1771/// working.
1772#[allow(unused_variables)]
1773fn check_open_key_file_perms(path: &Path, file: &fs::File) -> Result<(), KeyError> {
1774    #[cfg(unix)]
1775    {
1776        use std::os::unix::fs::PermissionsExt;
1777        if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
1778            .map(|v| v == "1")
1779            .unwrap_or(false)
1780        {
1781            return Ok(());
1782        }
1783        // `File::metadata` on Unix calls `fstat(fd)` -- it does NOT
1784        // re-resolve the path, so the result describes the same inode
1785        // we will read from. This is the structural property that
1786        // makes the gate race-free.
1787        let meta = file.metadata()?;
1788        let mode = meta.permissions().mode();
1789        if mode & 0o077 != 0 {
1790            return Err(KeyError::InsecureKeyPerms {
1791                path: path.to_path_buf(),
1792                mode,
1793            });
1794        }
1795    }
1796    Ok(())
1797}
1798
1799impl Store {
1800    /// Repair file permissions on the keystore directory and every file
1801    /// inside it: dir to 0700, key entry files and manifest to 0600.
1802    /// Used by `treeship doctor --fix`. No-op on non-Unix.
1803    ///
1804    /// Returns the list of (path, old_mode, new_mode) tuples for paths
1805    /// that were actually changed, so the caller can report what it did.
1806    pub fn fix_perms(&self) -> Result<Vec<(PathBuf, u32, u32)>, KeyError> {
1807        let mut changed: Vec<(PathBuf, u32, u32)> = Vec::new();
1808        #[cfg(unix)]
1809        {
1810            use std::os::unix::fs::PermissionsExt;
1811
1812            let dir_meta = fs::metadata(&self.dir)?;
1813            let dir_mode = dir_meta.permissions().mode() & 0o777;
1814            if dir_mode != 0o700 {
1815                fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?;
1816                changed.push((self.dir.clone(), dir_mode, 0o700));
1817            }
1818
1819            for entry in fs::read_dir(&self.dir)? {
1820                let entry = entry?;
1821                let path = entry.path();
1822                if !entry.file_type()?.is_file() {
1823                    continue;
1824                }
1825                let mode = entry.metadata()?.permissions().mode() & 0o777;
1826                if mode != 0o600 {
1827                    fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
1828                    changed.push((path, mode, 0o600));
1829                }
1830            }
1831        }
1832        Ok(changed)
1833    }
1834}
1835
1836/// Open (or create) the per-entry migration sentinel lock file with
1837/// owner-only permissions (0o600 on Unix). The handle returned can be
1838/// passed to `fs2::FileExt::lock_exclusive` to serialize concurrent
1839/// v1->v2 migrations of the same entry across processes/threads
1840/// (TS-2026-001 H3).
1841///
1842/// On Unix the mode is set at creation via `OpenOptionsExt::mode` so the
1843/// sentinel never has a moment of looser perms. On non-Unix platforms the
1844/// file inherits parent ACLs (the keystore dir is owner-scoped already).
1845#[cfg(unix)]
1846fn open_migration_lock_file(path: &Path) -> Result<fs::File, io::Error> {
1847    use std::os::unix::fs::OpenOptionsExt;
1848    fs::OpenOptions::new()
1849        .create(true)
1850        .read(true)
1851        .write(true)
1852        .truncate(false)
1853        .mode(0o600)
1854        .open(path)
1855}
1856
1857#[cfg(not(unix))]
1858fn open_migration_lock_file(path: &Path) -> Result<fs::File, io::Error> {
1859    fs::OpenOptions::new()
1860        .create(true)
1861        .read(true)
1862        .write(true)
1863        .truncate(false)
1864        .open(path)
1865}
1866
1867/// Atomically write `data` to `path` with owner-only (0o600) permissions on
1868/// Unix.
1869///
1870/// TS-2026-001 H1 + H2: the prior implementation was truncate-then-write,
1871/// which destroys the original file if the process crashes mid-write. For
1872/// the keystore that's catastrophic -- a crash during transparent v1->v2
1873/// migration would leave a zero-byte (or partial) key entry on disk and
1874/// the private key would be unrecoverable. This implementation writes to
1875/// a sibling tmp file in the same directory, fsyncs the bytes through to
1876/// the platter, then performs a POSIX-atomic same-filesystem `rename(2)`.
1877/// A crash before the rename leaves the original file intact; the tmp
1878/// file is harmless garbage that the next successful write will overwrite.
1879///
1880/// The 0o600 mode is set at file *creation* via `OpenOptionsExt::mode`
1881/// so there is no window in which the file exists with looser perms.
1882/// The prior `set_permissions` post-write call is dropped because it was
1883/// redundant and gave the appearance (but not the substance) of safety.
1884fn write_file_600(path: &Path, data: &[u8]) -> Result<(), KeyError> {
1885    // Place the tmp file in the same directory as the final path so the
1886    // rename stays on the same filesystem (cross-FS renames are not atomic
1887    // and degrade to copy+unlink, defeating the whole point).
1888    let tmp_path = path.with_extension("tmp");
1889
1890    // Best-effort cleanup of any stale tmp from a prior crash before we
1891    // start writing. Ignored on error -- if it doesn't exist that's fine,
1892    // and if it can't be removed the OpenOptions call below will surface
1893    // the underlying error.
1894    let _ = fs::remove_file(&tmp_path);
1895
1896    let write_result: Result<(), KeyError> = (|| {
1897        #[cfg(unix)]
1898        let open = {
1899            use std::os::unix::fs::OpenOptionsExt;
1900            fs::OpenOptions::new()
1901                .write(true)
1902                .create(true)
1903                .truncate(true)
1904                .mode(0o600)
1905                .open(&tmp_path)
1906        };
1907        #[cfg(not(unix))]
1908        let open = fs::OpenOptions::new()
1909            .write(true)
1910            .create(true)
1911            .truncate(true)
1912            .open(&tmp_path);
1913
1914        let mut f = open?;
1915        f.write_all(data)?;
1916        // sync_all flushes both data AND metadata, so on a crash after
1917        // the rename, fsck/journal recovery sees the new bytes -- not a
1918        // ghost inode with stale content.
1919        f.sync_all()?;
1920        Ok(())
1921    })();
1922
1923    if let Err(e) = write_result {
1924        // Best-effort cleanup so the next write isn't surprised by a
1925        // half-written tmp. Errors here are not surfaced: the original
1926        // write error is what the caller needs to see.
1927        let _ = fs::remove_file(&tmp_path);
1928        return Err(e);
1929    }
1930
1931    // Atomic same-filesystem rename. On Unix this is a single
1932    // rename(2) syscall guaranteed by POSIX to be atomic with respect
1933    // to other observers. On Windows std::fs::rename is implemented
1934    // via MoveFileEx with MOVEFILE_REPLACE_EXISTING (atomic on NTFS,
1935    // best-effort elsewhere). After this returns Ok, the new bytes are
1936    // visible at `path` and the tmp file no longer exists.
1937    if let Err(e) = fs::rename(&tmp_path, path) {
1938        let _ = fs::remove_file(&tmp_path);
1939        return Err(KeyError::Io(e));
1940    }
1941
1942    // fsync the parent directory so the rename's directory-entry update
1943    // is itself persisted. The previous code only fsynced the tmp
1944    // file's contents (via sync_all on the file handle) -- on ext4/xfs
1945    // with default mount options, the rename can return to userspace
1946    // before the dirent metadata has been written to the journal. A
1947    // power loss in that window leaves the directory entry pointing at
1948    // the OLD inode (or, worse, missing entirely if both old and new
1949    // were unlinked from the parent), even though both the data bytes
1950    // and the rename syscall ostensibly completed. The H1 doc-comment
1951    // above promised stronger durability than the code delivered;
1952    // fsyncing the parent dir closes that gap.
1953    //
1954    // Best-effort on Unix: a directory open + sync_all is the standard
1955    // pattern (see e.g. SQLite's atomic-commit, leveldb, lmdb). On
1956    // platforms where opening a directory for sync isn't supported, we
1957    // silently skip -- the rename is still atomic-with-respect-to-
1958    // observers, we just don't guarantee crash-durability of the
1959    // dirent update.
1960    #[cfg(unix)]
1961    {
1962        if let Some(parent) = path.parent() {
1963            // Errors here are non-fatal: the rename succeeded and the
1964            // common case (no power loss before the next fs flush) is
1965            // correct. We surface a failure to open/sync the dir only
1966            // if the rename itself succeeded, since otherwise the
1967            // caller would mistake a durability hint for a write
1968            // failure. swallow silently rather than return.
1969            if let Ok(dir) = fs::File::open(parent) {
1970                let _ = dir.sync_all();
1971            }
1972        }
1973    }
1974
1975    Ok(())
1976}
1977
1978fn unix_now() -> u64 {
1979    use std::time::{SystemTime, UNIX_EPOCH};
1980    SystemTime::now()
1981        .duration_since(UNIX_EPOCH)
1982        .unwrap_or_default()
1983        .as_secs()
1984}
1985
1986#[cfg(test)]
1987mod tests {
1988    use super::*;
1989
1990    fn temp_dir_path() -> PathBuf {
1991        let mut p = std::env::temp_dir();
1992        p.push(format!("treeship-test-{}", {
1993            let mut b = [0u8; 4];
1994            // v0.10.4 P1 audit: thread_rng acceptable here. This is a
1995            // test-only temp-dir suffix to avoid collisions between parallel
1996            // test runs. Not a cryptographic input; entropy quality irrelevant.
1997            rand::thread_rng().fill_bytes(&mut b);
1998            hex_encode(&b)
1999        }));
2000        // Nest the store under a per-test parent (mirrors production's
2001        // `~/.treeship/keys`). The machine_seed lives in `store_dir.parent()`;
2002        // without this nesting the parent would be the SHARED system temp dir
2003        // and every test would share (and race on) one seed. `dir` still points
2004        // at the store directory, so test logic that inspects entry files is
2005        // unaffected.
2006        p.push("keys");
2007        p
2008    }
2009
2010    fn make_store() -> (Store, PathBuf) {
2011        let dir = temp_dir_path();
2012        let store = Store::open(&dir).unwrap();
2013        (store, dir)
2014    }
2015
2016    fn cleanup(dir: PathBuf) {
2017        // Remove the store dir AND its per-test parent (which holds machine_seed).
2018        let _ = fs::remove_dir_all(&dir);
2019        if let Some(parent) = dir.parent() {
2020            let _ = fs::remove_dir_all(parent);
2021        }
2022    }
2023
2024    // AUD-19: the wrapping key must derive from the SECRET machine_seed, not
2025    // from guessable machine identifiers. Two "hosts" with identical
2026    // machine-id / hostname / user but a DIFFERENT seed must not be able to
2027    // decrypt each other's keystore. We simulate the second host by swapping
2028    // the seed file under an otherwise-identical machine environment.
2029    #[test]
2030    fn different_seed_cannot_decrypt_even_with_identical_machine_id() {
2031        let (store, dir) = make_store();
2032        store.generate(true).unwrap();
2033        // The default key is now on disk, wrapped under the seed-primary key.
2034        store.default_signer().expect("own seed must decrypt");
2035
2036        // Swap the seed for a different one. machine-id / hostname / user are
2037        // unchanged (same test host) — only the secret seed differs.
2038        let seed_path = dir.parent().unwrap().join("machine_seed");
2039        assert!(seed_path.exists(), "seed must have been created on first open");
2040        fs::write(&seed_path, hex_encode(&[0xABu8; 32])).unwrap();
2041        #[cfg(unix)]
2042        {
2043            use std::os::unix::fs::PermissionsExt;
2044            fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600)).unwrap();
2045        }
2046
2047        // A fresh Store sees the new seed. The guessable machine-id/hostname
2048        // fallbacks are identical to the original host's, but they never
2049        // wrapped this entry (it is seed-wrapped), so decryption MUST fail.
2050        let store2 = Store::open(&dir).unwrap();
2051        assert!(
2052            store2.default_signer().is_err(),
2053            "a different machine_seed (same machine-id/hostname) must NOT decrypt the keystore"
2054        );
2055        cleanup(dir);
2056    }
2057
2058    // AUD-25: a machine_seed that is a symlink (which could redirect the read
2059    // to attacker-controlled bytes) is refused rather than trusted as key
2060    // material.
2061    #[cfg(unix)]
2062    #[test]
2063    fn planted_symlink_seed_is_refused() {
2064        let (_store, dir) = make_store(); // creates a real seed
2065        let seed_path = dir.parent().unwrap().join("machine_seed");
2066        let _ = fs::remove_file(&seed_path);
2067        // Replace the seed with a symlink to some attacker file.
2068        let attacker = dir.parent().unwrap().join("attacker_bytes");
2069        fs::write(&attacker, hex_encode(&[0x11u8; 32])).unwrap();
2070        std::os::unix::fs::symlink(&attacker, &seed_path).unwrap();
2071        // Opening must refuse the symlinked seed (fail closed), not follow it.
2072        assert!(
2073            Store::open(&dir).and_then(|s| s.default_signer()).is_err(),
2074            "a symlinked machine_seed must be refused"
2075        );
2076        cleanup(dir);
2077    }
2078
2079    #[test]
2080    fn generate_key() {
2081        let (store, dir) = make_store();
2082        let info = store.generate(true).unwrap();
2083        assert!(info.id.starts_with("key_"));
2084        assert_eq!(info.algorithm, "ed25519");
2085        assert!(!info.fingerprint.is_empty());
2086        assert_eq!(info.public_key.len(), 32);
2087        cleanup(dir);
2088    }
2089
2090    #[test]
2091    fn default_signer_works() {
2092        let (store, dir) = make_store();
2093        store.generate(true).unwrap();
2094        let signer = store.default_signer().unwrap();
2095        assert!(!signer.key_id().is_empty());
2096        let pae = crate::attestation::pae("text/plain", b"test");
2097        let sig = signer.sign(&pae).unwrap();
2098        assert_eq!(sig.len(), 64);
2099        cleanup(dir);
2100    }
2101
2102    /// Regression: a keystore whose path contains a symlink must decrypt
2103    /// consistently no matter which path string is used to open it. Before
2104    /// path canonicalization in `open`, deriving the machine key from the raw
2105    /// path string produced different keys for the same directory (e.g. open
2106    /// via a symlink vs. the real path), surfacing as a misleading
2107    /// "MAC verification failed -- wrong machine" error on a perfectly good
2108    /// keystore on the same machine.
2109    #[cfg(unix)]
2110    #[test]
2111    fn machine_key_stable_across_symlinked_path() {
2112        let real = temp_dir_path();
2113        fs::create_dir_all(&real).unwrap();
2114        let link = temp_dir_path();
2115        fs::create_dir_all(link.parent().unwrap()).unwrap();
2116        std::os::unix::fs::symlink(&real, &link).unwrap();
2117
2118        // Mint a default key via the SYMLINK path.
2119        {
2120            let store = Store::open(&link).unwrap();
2121            store.generate(true).unwrap();
2122        }
2123
2124        // Re-open via the REAL (canonical) path and decrypt. Pre-fix this
2125        // failed because the raw path strings ("link" vs "real") hashed to
2126        // different machine keys.
2127        let via_real = Store::open(&real).unwrap();
2128        via_real
2129            .default_signer()
2130            .expect("decrypt via the canonical path must succeed");
2131
2132        // And via the symlink again (fresh Store, re-derives the key).
2133        let via_link = Store::open(&link).unwrap();
2134        via_link
2135            .default_signer()
2136            .expect("decrypt via the symlink path must succeed");
2137
2138        fs::remove_file(&link).ok();
2139        cleanup(real);
2140    }
2141
2142    /// A keystore encrypted under the RAW path key (as the pre-canonicalization
2143    /// code wrote it) must still open after the change -- the legacy fallback
2144    /// must never lock an existing user out.
2145    #[cfg(unix)]
2146    #[test]
2147    fn legacy_raw_path_key_still_decrypts() {
2148        let real = temp_dir_path();
2149        fs::create_dir_all(&real).unwrap();
2150        let link = temp_dir_path();
2151        fs::create_dir_all(link.parent().unwrap()).unwrap();
2152        std::os::unix::fs::symlink(&real, &link).unwrap();
2153
2154        // Simulate a pre-fix keystore: encrypt a key under the machine key
2155        // derived from the RAW (symlink) path, bypassing canonicalization.
2156        let key_id = new_key_id();
2157        let signer = Ed25519Signer::generate(&key_id).unwrap();
2158        let raw_key = derive_machine_key(&link).unwrap();
2159        let canon_key = derive_machine_key(&fs::canonicalize(&link).unwrap()).unwrap();
2160        assert_ne!(raw_key, canon_key, "symlink must change the raw path key");
2161        let enc = encrypt_for_disk_v2(
2162            &raw_key,
2163            key_id.as_str(),
2164            &signer.public_key_bytes(),
2165            signer.secret_bytes().as_slice(),
2166        )
2167        .unwrap();
2168        let entry = EncryptedEntry {
2169            id:               key_id.clone(),
2170            algorithm:        "ed25519".into(),
2171            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
2172            public_key:       signer.public_key_bytes(),
2173            enc_priv_key:     enc,
2174            nonce:            Vec::new(),
2175            valid_until:      None,
2176            successor_key_id: None,
2177        };
2178
2179        // The store opened via the symlink has the canonical key as primary and
2180        // the raw-path key as the legacy fallback. The entry above is encrypted
2181        // under the raw key, so decryption must fall back rather than fail.
2182        let store = Store::open(&link).unwrap();
2183        store.write_entry(&entry).unwrap();
2184        let got = store
2185            .signer(key_id.as_str())
2186            .expect("legacy raw-path key must decrypt via the fallback");
2187        assert_eq!(got.public_key_bytes(), signer.public_key_bytes());
2188
2189        fs::remove_file(&link).ok();
2190        cleanup(real);
2191    }
2192
2193    /// A keystore wrapped under the v1 hostname+username machine key (every
2194    /// keystore written before the stable-primary change) must decrypt via
2195    /// the fallback chain AND be transparently rewrapped under the primary,
2196    /// so a later hostname rename can no longer brick it. This is the
2197    /// migration path for the recurring real-world failure where macOS
2198    /// renames `kern.hostname` (network collision, DHCP) and the keystore
2199    /// dies with "MAC verification failed -- wrong machine".
2200    #[test]
2201    fn v1_wrapped_keystore_decrypts_and_rewraps_under_primary() {
2202        let dir = temp_dir_path();
2203        fs::create_dir_all(&dir).unwrap();
2204        let canonical = fs::canonicalize(&dir).unwrap();
2205
2206        // AUD-19: the primary is now the SECRET seed-derived key. A legacy
2207        // entry wrapped under the old guessable v1 key must migrate to it.
2208        // Migration always applies now (there is always a seed primary),
2209        // regardless of whether a hardware-stable id exists.
2210        let primary = derive_seed_primary_key(&canonical).unwrap();
2211        let v1_key = derive_machine_key(&canonical).unwrap();
2212        assert_ne!(primary, v1_key, "seed-primary and v1 derivations must differ");
2213
2214        // Simulate the pre-fix keystore: entry wrapped under the v1 key.
2215        let key_id = new_key_id();
2216        let signer = Ed25519Signer::generate(&key_id).unwrap();
2217        let enc = encrypt_for_disk_v2(
2218            &v1_key,
2219            key_id.as_str(),
2220            &signer.public_key_bytes(),
2221            signer.secret_bytes().as_slice(),
2222        )
2223        .unwrap();
2224        let entry = EncryptedEntry {
2225            id:               key_id.clone(),
2226            algorithm:        "ed25519".into(),
2227            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
2228            public_key:       signer.public_key_bytes(),
2229            enc_priv_key:     enc,
2230            nonce:            Vec::new(),
2231            valid_until:      None,
2232            successor_key_id: None,
2233        };
2234
2235        let store = Store::open(&dir).unwrap();
2236        store.write_entry(&entry).unwrap();
2237        let got = store
2238            .signer(key_id.as_str())
2239            .expect("v1-wrapped entry must decrypt via the fallback chain");
2240        assert_eq!(got.public_key_bytes(), signer.public_key_bytes());
2241
2242        // The successful fallback decrypt must have rewrapped the on-disk
2243        // entry under the PRIMARY key: after migration the entry decrypts
2244        // with the primary directly and no longer with the old v1 key.
2245        let migrated = store.read_entry(key_id.as_str()).unwrap();
2246        assert!(
2247            decrypt_from_disk(
2248                &primary,
2249                &migrated.id,
2250                &migrated.public_key,
2251                &migrated.enc_priv_key,
2252                &migrated.nonce,
2253            )
2254            .is_ok(),
2255            "entry must be rewrapped under the primary machine key"
2256        );
2257        assert!(
2258            decrypt_from_disk(
2259                &v1_key,
2260                &migrated.id,
2261                &migrated.public_key,
2262                &migrated.enc_priv_key,
2263                &migrated.nonce,
2264            )
2265            .is_err(),
2266            "rewrapped entry must no longer decrypt under the old v1 key"
2267        );
2268
2269        cleanup(dir);
2270    }
2271
2272    /// A keystore wrapped under a v1 key derived from the mDNS
2273    /// LocalHostName (the hostname the machine reported before macOS
2274    /// renamed `kern.hostname` away from it) must decrypt via the
2275    /// LocalHostName fallback candidates. This is the exact drift shape
2276    /// that repeatedly bricked real keystores.
2277    #[cfg(target_os = "macos")]
2278    #[test]
2279    fn local_hostname_variant_recovers_drifted_keystore() {
2280        let variants = local_hostname_variants();
2281        let Some(old_hostname) = variants.first() else {
2282            // No LocalHostName on this machine; nothing to test.
2283            return;
2284        };
2285        let Ok(user) = std::env::var("USER") else {
2286            return;
2287        };
2288
2289        let dir = temp_dir_path();
2290        fs::create_dir_all(&dir).unwrap();
2291        let canonical = fs::canonicalize(&dir).unwrap();
2292
2293        let drifted_key =
2294            derive_machine_key_v1_from_parts(old_hostname, &user, &canonical);
2295
2296        let key_id = new_key_id();
2297        let signer = Ed25519Signer::generate(&key_id).unwrap();
2298        let enc = encrypt_for_disk_v2(
2299            &drifted_key,
2300            key_id.as_str(),
2301            &signer.public_key_bytes(),
2302            signer.secret_bytes().as_slice(),
2303        )
2304        .unwrap();
2305        let entry = EncryptedEntry {
2306            id:               key_id.clone(),
2307            algorithm:        "ed25519".into(),
2308            created_at:       crate::statements::unix_to_rfc3339(unix_now()),
2309            public_key:       signer.public_key_bytes(),
2310            enc_priv_key:     enc,
2311            nonce:            Vec::new(),
2312            valid_until:      None,
2313            successor_key_id: None,
2314        };
2315
2316        let store = Store::open(&dir).unwrap();
2317        store.write_entry(&entry).unwrap();
2318        let got = store.signer(key_id.as_str()).expect(
2319            "keystore wrapped under the LocalHostName-derived v1 key must \
2320             decrypt via the drift-recovery candidates",
2321        );
2322        assert_eq!(got.public_key_bytes(), signer.public_key_bytes());
2323
2324        cleanup(dir);
2325    }
2326
2327    #[test]
2328    fn encrypt_decrypt_roundtrip() {
2329        // Routes the legacy public API through the dispatcher; v1
2330        // ciphertexts must still decrypt correctly.
2331        let key = [42u8; 32];
2332        let plaintext = b"super secret private key material here!";
2333        let (enc, nonce) = aes_gcm_encrypt(&key, plaintext).unwrap();
2334        let dec = aes_gcm_decrypt(&key, &enc, &nonce).unwrap();
2335        assert_eq!(dec, plaintext);
2336    }
2337
2338    #[test]
2339    fn decrypt_wrong_key_fails() {
2340        let key   = [42u8; 32];
2341        let wrong = [99u8; 32];
2342        let (enc, nonce) = aes_gcm_encrypt(&key, b"secret").unwrap();
2343        assert!(aes_gcm_decrypt(&wrong, &enc, &nonce).is_err());
2344    }
2345
2346    // --- v2 AEAD tests (TS-2026-001 fix) -----------------------------------
2347
2348    // Fixed entry id + pubkey for the unit-level v2 tests below. The AAD
2349    // builder binds these into the GCM tag, so encrypt and decrypt must
2350    // see identical values. Using constants keeps each test focused on
2351    // its own bit-flip / tamper assertion without dragging Store setup
2352    // into the picture.
2353    const TEST_ENTRY_ID: &str = "key_unit_test_entry_0001";
2354    const TEST_PUBLIC_KEY: &[u8; 32] = &[0xAA; 32];
2355
2356    #[test]
2357    fn v2_encrypt_decrypt_roundtrip() {
2358        let key = [7u8; 32];
2359        let plaintext = b"super secret private key material here!";
2360        let blob =
2361            encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, plaintext).unwrap();
2362        // Structural check on the framing.
2363        assert_eq!(blob[0], KEYSTORE_MAGIC, "magic byte");
2364        assert_eq!(blob[1], KEYSTORE_VERSION_V2, "version byte");
2365        assert_eq!(blob.len(), 2 + 12 + plaintext.len() + 16,
2366                   "magic+version+nonce+ct+tag length");
2367
2368        let dec =
2369            decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]).unwrap();
2370        assert_eq!(&*dec, plaintext);
2371    }
2372
2373    // ── KeyStore::encrypt_secret / decrypt_secret (AUD-02) ─────────────
2374    // The hub DPoP key used to be written to config.json as plaintext hex.
2375    // These lock the machine-bound, context-bound at-rest sealing that
2376    // replaces it.
2377
2378    #[test]
2379    fn encrypt_secret_roundtrips_and_hides_plaintext() {
2380        let (store, dir) = make_store();
2381        // A 32-byte Ed25519 secret, the actual thing we are protecting.
2382        let secret = [0x42u8; 32];
2383        let ctx = "hub-dpop:v1:hub_abc";
2384        let blob = store.encrypt_secret(ctx, &secret).unwrap();
2385
2386        // Fail-before-fix invariant: the sealed blob must NOT contain the
2387        // raw secret bytes. (The old code stored them verbatim.)
2388        assert!(
2389            !blob.windows(secret.len()).any(|w| w == secret),
2390            "sealed blob must not contain the raw secret"
2391        );
2392
2393        let recovered = store.decrypt_secret(ctx, &blob).unwrap();
2394        assert_eq!(recovered.as_slice(), &secret, "roundtrip must recover the secret");
2395        cleanup(dir);
2396    }
2397
2398    #[test]
2399    fn decrypt_secret_wrong_context_fails_closed() {
2400        let (store, dir) = make_store();
2401        let secret = [0x11u8; 32];
2402        let blob = store.encrypt_secret("hub-dpop:v1:hub_A", &secret).unwrap();
2403        // A blob sealed for hub A must not open under hub B's context —
2404        // this is what prevents an intra-file ciphertext swap.
2405        let r = store.decrypt_secret("hub-dpop:v1:hub_B", &blob);
2406        assert!(r.is_err(), "wrong context must fail closed, not return wrong bytes");
2407        cleanup(dir);
2408    }
2409
2410    #[test]
2411    fn v2_decrypt_wrong_key_fails() {
2412        let key   = [7u8; 32];
2413        let wrong = [99u8; 32];
2414        let blob = encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"secret").unwrap();
2415        // Wrong key with v2 framing: AEAD must reject. Dispatcher will
2416        // try v1 fallback (which also fails on garbage), so the final
2417        // error surfaces as a MAC failure rather than wrong plaintext.
2418        let result = decrypt_from_disk(&wrong, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
2419        assert!(result.is_err(), "wrong key must fail");
2420    }
2421
2422    #[test]
2423    fn v2_tamper_ciphertext_fails() {
2424        let key = [7u8; 32];
2425        let mut blob = encrypt_for_disk_v2(
2426            &key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
2427        ).unwrap();
2428        // Flip one bit inside the ciphertext body (after the 14-byte
2429        // framing). GCM authenticates ciphertext + nonce; any flip must
2430        // fail.
2431        let last = blob.len() - 5;
2432        blob[last] ^= 0x01;
2433        let result = decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
2434        assert!(result.is_err(), "tampered ciphertext must fail to decrypt");
2435    }
2436
2437    #[test]
2438    fn v2_tamper_nonce_fails() {
2439        let key = [7u8; 32];
2440        let mut blob = encrypt_for_disk_v2(
2441            &key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
2442        ).unwrap();
2443        // Flip a bit in the nonce (bytes [2..14]).
2444        blob[5] ^= 0x01;
2445        let result = decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
2446        assert!(result.is_err(), "tampered nonce must fail to decrypt");
2447    }
2448
2449    #[test]
2450    fn v2_tamper_tag_fails() {
2451        let key = [7u8; 32];
2452        let mut blob = encrypt_for_disk_v2(
2453            &key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
2454        ).unwrap();
2455        // Flip a bit in the trailing GCM tag (last 16 bytes).
2456        let len = blob.len();
2457        blob[len - 1] ^= 0x80;
2458        let result = decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
2459        assert!(result.is_err(), "tampered GCM tag must fail to decrypt");
2460    }
2461
2462    #[test]
2463    fn v2_nonces_are_unique_across_writes() {
2464        // Sanity check: two encryptions of identical plaintext under the
2465        // same key must produce different blobs (random per-write nonce).
2466        // Without this property, AES-GCM is catastrophically broken.
2467        let key = [7u8; 32];
2468        let blob_a =
2469            encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"identical").unwrap();
2470        let blob_b =
2471            encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"identical").unwrap();
2472        assert_ne!(blob_a, blob_b,
2473                   "two v2 encryptions of the same plaintext must differ");
2474        assert_ne!(&blob_a[2..14], &blob_b[2..14], "nonces must differ");
2475
2476        // L1 (TS-2026-001 audit): draw 10k nonces in a row and assert
2477        // every one is distinct. A duplicate at this volume would be a
2478        // strong (10k^2 / 2^96 ~ 2^-65 floor) signal that the OS CSPRNG
2479        // backing aead::OsRng is misbehaving on this build. Cheap, fast,
2480        // and catches a regression class (PRNG mis-seeding,
2481        // accidentally-deterministic nonce, RNG getting forked across
2482        // threads without re-seed) that the 2-sample check above can't.
2483        const N: usize = 10_000;
2484        let mut nonces: std::collections::HashSet<Vec<u8>> =
2485            std::collections::HashSet::with_capacity(N);
2486        for _ in 0..N {
2487            let blob =
2488                encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"x").unwrap();
2489            // bytes [2..14] are the 12-byte GCM nonce.
2490            nonces.insert(blob[2..14].to_vec());
2491        }
2492        assert_eq!(
2493            nonces.len(),
2494            N,
2495            "all {} v2 nonces must be unique; collision => RNG defect",
2496            N
2497        );
2498    }
2499
2500    #[test]
2501    fn v2_tamper_version_byte_fails() {
2502        // M2: flipping the version byte must cause decryption to fail.
2503        // The framing sanity check catches obvious flips immediately;
2504        // the AAD-binding test below covers the case where the framing
2505        // sanity check would otherwise pass.
2506        let key = [7u8; 32];
2507        let mut blob = encrypt_for_disk_v2(
2508            &key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
2509        ).unwrap();
2510        assert_eq!(blob[1], KEYSTORE_VERSION_V2);
2511        blob[1] = 0xff;
2512        assert!(
2513            decrypt_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob).is_err(),
2514            "altered version byte must be rejected"
2515        );
2516    }
2517
2518    #[test]
2519    fn v2_aad_binding_detects_framing_substitution() {
2520        // M2 direct check: encrypt a payload with v2 AAD, then construct
2521        // a blob whose framing claims to be v2 but whose ciphertext was
2522        // computed under a different AAD (empty). decrypt_v2 must
2523        // reject with MAC failure rather than returning the plaintext.
2524        let key = [7u8; 32];
2525        let plaintext = b"M2 AAD bound material";
2526
2527        // Compute a v2-framed blob without supplying AAD -- mimics what
2528        // the *pre-M2* code would have produced. This is the exact
2529        // attack surface AAD closes: an old blob whose framing is v2
2530        // but whose tag was computed empty.
2531        use aes_gcm::aead::Aead;
2532        let key_buf: Zeroizing<[u8; 32]> = Zeroizing::new(key);
2533        let aead_key: &AesKey<Aes256Gcm> = AesKey::<Aes256Gcm>::from_slice(key_buf.as_slice());
2534        let cipher = Aes256Gcm::new(aead_key);
2535        let nonce = Aes256Gcm::generate_nonce(&mut AeadOsRng);
2536        let ct_no_aad = cipher.encrypt(&nonce, plaintext.as_slice()).unwrap();
2537
2538        let mut forged = Vec::with_capacity(2 + 12 + ct_no_aad.len());
2539        forged.push(KEYSTORE_MAGIC);
2540        forged.push(KEYSTORE_VERSION_V2);
2541        forged.extend_from_slice(nonce.as_slice());
2542        forged.extend_from_slice(&ct_no_aad);
2543
2544        // Framing sanity passes. AAD does not. decrypt_v2 must reject.
2545        assert_eq!(forged[0], KEYSTORE_MAGIC);
2546        assert_eq!(forged[1], KEYSTORE_VERSION_V2);
2547        let result = decrypt_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &forged);
2548        assert!(result.is_err(),
2549                "ciphertext computed without AAD must fail to decrypt now that AAD is bound");
2550    }
2551
2552    #[test]
2553    fn dispatcher_surfaces_v2_error_on_corrupted_v2_blob() {
2554        // M1: a v2-shaped blob whose AEAD verification fails (and
2555        // whose v1 fallback also fails, since the bytes are garbage
2556        // under both constructions) must surface the v2 MAC error, not
2557        // the v1 "ciphertext too short" / random-junk error. The user
2558        // sees a meaningful message that points at the right
2559        // remediation.
2560        let key = [7u8; 32];
2561        let mut blob =
2562            encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"hello").unwrap();
2563        // Flip a byte in the GCM tag (last 16 bytes) so the v2 AEAD
2564        // rejects but the framing still classifies as v2.
2565        let last = blob.len() - 1;
2566        blob[last] ^= 0x01;
2567
2568        let err =
2569            decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]).unwrap_err();
2570        // The dispatcher should bubble the v2 error string up. v2's
2571        // error message contains "MAC verification failed"; v1's
2572        // shape on garbage data is either "ciphertext too short" or
2573        // a different MAC error. Match on the v2-specific tail.
2574        assert!(
2575            err.contains("MAC verification failed"),
2576            "dispatcher must surface the v2 MAC error on corrupted v2 blob, got: {err}"
2577        );
2578    }
2579
2580    #[test]
2581    fn legacy_v1_ciphertext_still_decrypts_via_dispatcher() {
2582        // Simulates an on-disk keystore written by Treeship <= v0.10.2:
2583        // the dispatcher must successfully route legacy ciphertexts
2584        // through the v1 path so existing users are not locked out.
2585        let key = [13u8; 32];
2586        let plaintext = b"pre-v0.10.3 keystore entry";
2587        let (legacy_blob, legacy_nonce) =
2588            legacy_v1_encrypt(&key, plaintext).unwrap();
2589
2590        // Sanity: legacy blob does NOT start with v2 framing.
2591        assert!(is_legacy_v1(&legacy_blob),
2592                "legacy_v1_encrypt output must classify as legacy");
2593
2594        // Dispatcher must accept it. AAD inputs are irrelevant for the
2595        // v1 path (it doesn't use them), but the signature requires them
2596        // — pass the same placeholder constants used elsewhere.
2597        let dec = decrypt_from_disk(
2598            &key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &legacy_blob, &legacy_nonce,
2599        )
2600        .unwrap();
2601        assert_eq!(&*dec, plaintext);
2602    }
2603
2604    #[test]
2605    fn store_signer_migrates_legacy_entry_to_v2() {
2606        // End-to-end: write a key entry with the legacy v1 ciphertext
2607        // (as if upgrading from v0.10.2), call `signer()`, then verify
2608        // the on-disk entry has been rewritten in v2 format.
2609        let (store, dir) = make_store();
2610
2611        // Generate normally (this writes v2). Then re-encrypt the
2612        // secret in v1 format and overwrite the entry on disk to
2613        // simulate the upgrade scenario.
2614        let info = store.generate(true).unwrap();
2615        let entry_path = store.entry_path(&info.id);
2616
2617        // Pull the v2 entry off disk, decrypt to recover the secret,
2618        // then re-encode in legacy v1 format and write it back.
2619        let v2_entry: EncryptedEntry =
2620            serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
2621        let secret = decrypt_from_disk(
2622            &store.machine_key,
2623            &v2_entry.id,
2624            &v2_entry.public_key,
2625            &v2_entry.enc_priv_key,
2626            &v2_entry.nonce,
2627        )
2628            .unwrap();
2629        let (legacy_blob, legacy_nonce) =
2630            legacy_v1_encrypt(&store.machine_key, &secret).unwrap();
2631        let legacy_entry = EncryptedEntry {
2632            id:               v2_entry.id.clone(),
2633            algorithm:        v2_entry.algorithm.clone(),
2634            created_at:       v2_entry.created_at.clone(),
2635            public_key:       v2_entry.public_key.clone(),
2636            enc_priv_key:     legacy_blob,
2637            nonce:            legacy_nonce,
2638            valid_until:      v2_entry.valid_until.clone(),
2639            successor_key_id: v2_entry.successor_key_id.clone(),
2640        };
2641        fs::write(&entry_path, serde_json::to_vec_pretty(&legacy_entry).unwrap()).unwrap();
2642
2643        // Reload with a fresh Store so the cache doesn't paper over the
2644        // on-disk change.
2645        let store2 = Store::open(&dir).unwrap();
2646        // Loading the signer must succeed (legacy path works) AND
2647        // trigger the transparent migration to v2.
2648        let _signer = store2.signer(&info.id).unwrap();
2649
2650        let after: EncryptedEntry =
2651            serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
2652        assert!(!is_legacy_v1(&after.enc_priv_key),
2653                "post-migration entry must be in v2 format");
2654        assert_eq!(after.enc_priv_key[0], KEYSTORE_MAGIC);
2655        assert_eq!(after.enc_priv_key[1], KEYSTORE_VERSION_V2);
2656        assert!(after.nonce.is_empty(),
2657                "v2 entries serialize an empty legacy nonce field");
2658
2659        // L2 (TS-2026-001 audit): the framing check above proves the
2660        // migrator *wrote* a v2-shaped blob, but a downstream
2661        // assert_eq! on framing alone doesn't prove the v2 ciphertext
2662        // is actually a working AEAD encryption of the right secret.
2663        // Load the signer one more time through a fresh Store; this
2664        // routes through the dispatcher's v2-first branch and would
2665        // fail loudly if the migration had produced garbage.
2666        let store3 = Store::open(&dir).unwrap();
2667        let _signer = store3
2668            .signer(&info.id)
2669            .expect("post-migration v2 decrypt works");
2670
2671        cleanup(dir);
2672    }
2673
2674    #[test]
2675    fn persist_and_reload() {
2676        let (store, dir) = make_store();
2677        let info = store.generate(true).unwrap();
2678
2679        // Open a new Store instance pointing to the same directory.
2680        let store2 = Store::open(&dir).unwrap();
2681        let signer = store2.signer(&info.id).unwrap();
2682        assert_eq!(signer.key_id(), info.id);
2683
2684        // The reloaded signer must produce signatures verifiable with
2685        // the same public key.
2686        let verifier = {
2687            use crate::attestation::Verifier;
2688            use ed25519_dalek::VerifyingKey;
2689            let pk_bytes: [u8; 32] = info.public_key.try_into().unwrap();
2690            let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
2691            let mut v = Verifier::new(std::collections::HashMap::new());
2692            v.add_key(info.id.clone(), vk);
2693            v
2694        };
2695
2696        use crate::attestation::sign;
2697        use crate::statements::ActionStatement;
2698        let stmt   = ActionStatement::new("agent://test", "tool.call");
2699        let pt     = crate::statements::payload_type("action");
2700        let signed = sign(&pt, &stmt, signer.as_ref()).unwrap();
2701        verifier.verify(&signed.envelope).unwrap();
2702
2703        cleanup(dir);
2704    }
2705
2706    #[test]
2707    fn list_keys() {
2708        let (store, dir) = make_store();
2709        store.generate(true).unwrap();
2710        store.generate(false).unwrap();
2711
2712        let keys = store.list().unwrap();
2713        assert_eq!(keys.len(), 2);
2714        assert_eq!(keys.iter().filter(|k| k.is_default).count(), 1);
2715        cleanup(dir);
2716    }
2717
2718    #[test]
2719    fn no_default_key_errors() {
2720        let (store, dir) = make_store();
2721        assert!(store.default_signer().is_err());
2722        cleanup(dir);
2723    }
2724
2725    #[test]
2726    fn rotate_mints_successor_and_links_predecessor() {
2727        let (store, dir) = make_store();
2728        let pred = store.generate(true).unwrap();
2729        assert!(pred.valid_until.is_none(), "fresh key has no expiry");
2730        assert!(pred.successor_key_id.is_none(), "fresh key has no successor");
2731
2732        let result = store
2733            .rotate(None, std::time::Duration::from_secs(3600), true)
2734            .unwrap();
2735
2736        // Predecessor metadata is updated.
2737        assert_eq!(result.predecessor.id, pred.id);
2738        assert!(result.predecessor.valid_until.is_some(),
2739                "predecessor must get valid_until after rotation");
2740        assert_eq!(result.predecessor.successor_key_id.as_deref(),
2741                   Some(result.successor.id.as_str()),
2742                   "predecessor must link forward to successor");
2743        assert!(!result.predecessor.is_default,
2744                "after rotation with set_default=true, predecessor is no longer default");
2745
2746        // Successor is fresh.
2747        assert_ne!(result.successor.id, pred.id);
2748        assert!(result.successor.valid_until.is_none(), "successor has no expiry yet");
2749        assert!(result.successor.successor_key_id.is_none(), "successor is chain head");
2750        assert!(result.successor.is_default, "successor is the new default");
2751
2752        // Same metadata visible via list().
2753        let listed = store.list().unwrap();
2754        assert_eq!(listed.len(), 2);
2755        let pred_listed = listed.iter().find(|k| k.id == pred.id).unwrap();
2756        assert!(pred_listed.valid_until.is_some());
2757        assert_eq!(pred_listed.successor_key_id.as_deref(),
2758                   Some(result.successor.id.as_str()));
2759
2760        cleanup(dir);
2761    }
2762
2763    #[test]
2764    fn rotate_with_set_default_false_keeps_predecessor_active() {
2765        let (store, dir) = make_store();
2766        let pred = store.generate(true).unwrap();
2767
2768        let result = store
2769            .rotate(None, std::time::Duration::from_secs(3600), false)
2770            .unwrap();
2771
2772        // Predecessor is still default. Successor exists but is not default.
2773        assert!(result.predecessor.is_default);
2774        assert!(!result.successor.is_default);
2775        assert_eq!(store.default_key_id().unwrap(), pred.id);
2776
2777        cleanup(dir);
2778    }
2779
2780    #[test]
2781    fn rotate_predecessor_signing_still_works_during_grace_window() {
2782        let (store, dir) = make_store();
2783        let pred = store.generate(true).unwrap();
2784        let _ = store
2785            .rotate(None, std::time::Duration::from_secs(3600), true)
2786            .unwrap();
2787
2788        // Predecessor key must still be loadable and capable of signing
2789        // during its grace window. Verifiers can refuse on lifecycle, but
2790        // the keystore must not preemptively destroy material.
2791        let signer = store.signer(&pred.id).unwrap();
2792        let pae = crate::attestation::pae("text/plain", b"grace-window-payload");
2793        let sig = signer.sign(&pae).unwrap();
2794        assert_eq!(sig.len(), 64);
2795
2796        cleanup(dir);
2797    }
2798
2799    #[test]
2800    fn rotate_refuses_to_rotate_already_rotated_key() {
2801        let (store, dir) = make_store();
2802        store.generate(true).unwrap();
2803        let r1 = store
2804            .rotate(None, std::time::Duration::from_secs(60), true)
2805            .unwrap();
2806
2807        // Rotating the predecessor again must be refused -- it already
2808        // points at r1.successor. Caller should rotate the chain head.
2809        let err = store
2810            .rotate(Some(&r1.predecessor.id),
2811                    std::time::Duration::from_secs(60),
2812                    true)
2813            .unwrap_err();
2814        match err {
2815            KeyError::Crypto(msg) => assert!(
2816                msg.contains("already been rotated"),
2817                "error must explain why: {msg}"
2818            ),
2819            other => panic!("expected Crypto error, got {other:?}"),
2820        }
2821        cleanup(dir);
2822    }
2823
2824    #[test]
2825    fn successor_chain_walks_forward() {
2826        let (store, dir) = make_store();
2827        let k0 = store.generate(true).unwrap();
2828        let r1 = store
2829            .rotate(None, std::time::Duration::from_secs(60), true)
2830            .unwrap();
2831        let r2 = store
2832            .rotate(None, std::time::Duration::from_secs(60), true)
2833            .unwrap();
2834
2835        let chain = store.successor_chain(&k0.id).unwrap();
2836        assert_eq!(chain, vec![k0.id.clone(), r1.successor.id.clone(), r2.successor.id.clone()],
2837                   "chain must be ordered head -> tail");
2838
2839        // Mid-chain start: chain from r1.successor should drop k0.
2840        let mid = store.successor_chain(&r1.successor.id).unwrap();
2841        assert_eq!(mid, vec![r1.successor.id.clone(), r2.successor.id.clone()]);
2842
2843        // Tail: just itself.
2844        let tail = store.successor_chain(&r2.successor.id).unwrap();
2845        assert_eq!(tail, vec![r2.successor.id.clone()]);
2846
2847        cleanup(dir);
2848    }
2849
2850    #[test]
2851    fn valid_keys_at_filters_by_grace_window() {
2852        let (store, dir) = make_store();
2853        let _ = store.generate(true).unwrap();
2854        let result = store
2855            .rotate(None, std::time::Duration::from_secs(3600), true)
2856            .unwrap();
2857
2858        // At time-of-rotation, both keys must be valid -- predecessor is
2859        // mid-grace, successor is freshly minted.
2860        let now = unix_now();
2861        let valid_now = store.valid_keys_at(now).unwrap();
2862        assert_eq!(valid_now.len(), 2, "both predecessor (in grace) and successor should be valid");
2863
2864        // After the grace window expires, only the successor remains.
2865        let after_grace = unix_now() + 7200;
2866        let valid_after = store.valid_keys_at(after_grace).unwrap();
2867        assert_eq!(valid_after.len(), 1,
2868                   "after grace window only successor remains valid");
2869        assert_eq!(valid_after[0].id, result.successor.id);
2870
2871        cleanup(dir);
2872    }
2873
2874    /// Regression: if the successor key file is missing on disk (because a
2875    /// prior rotate() crashed AFTER stamping the predecessor but BEFORE
2876    /// writing the successor), retrying must NOT be wedged. With the
2877    /// successor-first write order this scenario can't be reached by a
2878    /// single-process crash, but we still need to defend against an operator
2879    /// who manually deletes a successor file mid-life. The recovery path
2880    /// is: clear the predecessor's successor pointer (or restore the file
2881    /// from backup) and try again.
2882    /// Regression: even if the manifest write FAILED (say, disk full at
2883    /// the worst possible moment), the in-memory cache must reflect the
2884    /// stamped predecessor that already landed on disk -- otherwise a
2885    /// same-process retry would skip the already-rotated guard and mint
2886    /// a duplicate successor.
2887    ///
2888    /// We can't easily inject a manifest-write failure mid-test, but we
2889    /// can verify the precondition that makes the recovery work: after a
2890    /// successful rotate(), the cache holds the stamped predecessor (so
2891    /// any subsequent rotate would correctly refuse). Combined with the
2892    /// write order (cache update BEFORE manifest write in rotate()),
2893    /// this proves a manifest-write crash leaves the cache aligned with
2894    /// disk, not behind it.
2895    #[test]
2896    fn rotate_cache_reflects_stamped_predecessor_for_retry_safety() {
2897        let (store, dir) = make_store();
2898        let pred = store.generate(true).unwrap();
2899        let _ = store
2900            .rotate(None, std::time::Duration::from_secs(60), true)
2901            .unwrap();
2902
2903        // The cache must have the stamped predecessor; a same-process
2904        // retry of rotate(predecessor) MUST be refused. If the cache
2905        // were stale (still showing the unstamped predecessor), this
2906        // call would proceed and mint a duplicate successor.
2907        let err = store
2908            .rotate(Some(&pred.id),
2909                    std::time::Duration::from_secs(60),
2910                    true)
2911            .unwrap_err();
2912        match err {
2913            KeyError::Crypto(msg) => assert!(
2914                msg.contains("already been rotated"),
2915                "cache should reflect stamped predecessor; got: {msg}"
2916            ),
2917            other => panic!("expected Crypto error, got {other:?}"),
2918        }
2919
2920        cleanup(dir);
2921    }
2922
2923    #[test]
2924    fn rotated_predecessor_pointing_at_missing_successor_surfaces_clear_error() {
2925        let (store, dir) = make_store();
2926        store.generate(true).unwrap();
2927        let result = store
2928            .rotate(None, std::time::Duration::from_secs(60), true)
2929            .unwrap();
2930
2931        // Simulate operator-deleted successor file. The manifest still
2932        // references it, so a cold-cache reader trying to walk the chain
2933        // hits a clear NotFound for the missing key.
2934        let succ_path = store.entry_path(&result.successor.id);
2935        fs::remove_file(&succ_path).unwrap();
2936
2937        // Open a fresh Store instance so the cache doesn't paper over the
2938        // missing on-disk entry. successor_chain() walks via load_entry;
2939        // the missing file must produce KeyError::NotFound, not a panic
2940        // and not an infinite loop.
2941        let store2 = Store::open(&dir).unwrap();
2942        let err = store2.successor_chain(&result.predecessor.id).unwrap_err();
2943        match err {
2944            KeyError::NotFound(id) => assert_eq!(id, result.successor.id),
2945            other => panic!("expected NotFound error, got {other:?}"),
2946        }
2947
2948        cleanup(dir);
2949    }
2950
2951    /// Pre-0.9.5 entry files lack `valid_until` and `successor_key_id`.
2952    /// They must still deserialize cleanly and be visible via `list()` /
2953    /// `default_signer()` etc.
2954    #[test]
2955    fn legacy_entry_without_lifecycle_fields_loads() {
2956        let (store, dir) = make_store();
2957        let info = store.generate(true).unwrap();
2958
2959        // Re-serialize the on-disk entry without the new fields, simulating
2960        // a file created by a 0.9.4 or earlier CLI.
2961        let path = store.entry_path(&info.id);
2962        let raw  = fs::read(&path).unwrap();
2963        let mut json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
2964        let obj = json.as_object_mut().unwrap();
2965        obj.remove("valid_until");
2966        obj.remove("successor_key_id");
2967        fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap();
2968
2969        // A fresh Store (cold cache) must still load the entry and treat
2970        // the missing fields as None.
2971        let store2 = Store::open(&dir).unwrap();
2972        let listed = store2.list().unwrap();
2973        assert_eq!(listed.len(), 1);
2974        assert!(listed[0].valid_until.is_none(),
2975                "missing valid_until must default to None on legacy entry");
2976        assert!(listed[0].successor_key_id.is_none(),
2977                "missing successor_key_id must default to None on legacy entry");
2978        let signer = store2.default_signer().unwrap();
2979        assert_eq!(signer.key_id(), info.id);
2980
2981        cleanup(dir);
2982    }
2983
2984    // --- keystore permission hardening (PR 1) -------------------------------
2985
2986    // The perm tests below mutate the process-global env var
2987    // TREESHIP_ALLOW_INSECURE_KEY_PERMS. cargo test runs cases in
2988    // parallel by default, so without serialization one test can set
2989    // the bypass while another expects it unset and racefully fail.
2990    // This mutex serializes them; everything else in the file remains
2991    // parallel-safe.
2992    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2993
2994    #[test]
2995    #[cfg(unix)]
2996    fn write_entry_creates_file_with_0600() {
2997        use std::os::unix::fs::PermissionsExt;
2998        let (store, dir) = make_store();
2999        let info = store.generate(true).unwrap();
3000        let mode = fs::metadata(store.entry_path(&info.id))
3001            .unwrap()
3002            .permissions()
3003            .mode()
3004            & 0o777;
3005        assert_eq!(mode, 0o600, "freshly written key file must be 0600, got {:o}", mode);
3006        cleanup(dir);
3007    }
3008
3009    #[test]
3010    #[cfg(unix)]
3011    fn signer_refuses_world_readable_key() {
3012        use std::os::unix::fs::PermissionsExt;
3013        // Mutex prevents the bypass var from being toggled by a
3014        // sibling test mid-flight (cargo test parallel runner).
3015        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3016        // Make sure the bypass var is not leaking from the host env.
3017        std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
3018
3019        let (store, dir) = make_store();
3020        let info = store.generate(true).unwrap();
3021
3022        // Loosen perms on the key file -- simulates a checkout, scp, or
3023        // shared-volume mishap.
3024        let path = store.entry_path(&info.id);
3025        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
3026
3027        match store.signer(&info.id) {
3028            Err(KeyError::InsecureKeyPerms { path: p, mode }) => {
3029                assert_eq!(p, path);
3030                assert_eq!(mode & 0o777, 0o644);
3031            }
3032            other => panic!("expected InsecureKeyPerms, got {:?}", other.map(|_| "ok")),
3033        }
3034        cleanup(dir);
3035    }
3036
3037    #[test]
3038    #[cfg(unix)]
3039    fn signer_bypass_via_env_var() {
3040        use std::os::unix::fs::PermissionsExt;
3041        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3042        let (store, dir) = make_store();
3043        let info = store.generate(true).unwrap();
3044        let path = store.entry_path(&info.id);
3045        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
3046
3047        std::env::set_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS", "1");
3048        let result = store.signer(&info.id);
3049        std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
3050
3051        assert!(
3052            result.is_ok(),
3053            "bypass env var must allow signing: {:?}",
3054            result.err()
3055        );
3056        cleanup(dir);
3057    }
3058
3059    // --- v0.10.4 P2: TOCTOU window in signer() perm-check ---------------
3060
3061    /// Structural / single-open proof: the on-disk key file is opened
3062    /// EXACTLY ONCE during `signer()`. The fix replaces the prior
3063    /// `check_key_file_perms(path) + load_entry(id) -> fs::read(path)`
3064    /// two-open shape with `read_entry_with_perm_check`, which opens
3065    /// once and fstat's the resulting fd. We can't reliably race the
3066    /// FS in a unit test, so instead we assert the structural
3067    /// invariant: after `signer()` succeeds, only the bytes that the
3068    /// open file descriptor saw at perm-check time can have been read.
3069    ///
3070    /// The simulation: stage an attacker-controlled "loose perms"
3071    /// envelope at the path, then call `signer()`. With the fixed
3072    /// single-open shape, perm-check on the open fd fails before any
3073    /// content is read -- we get `InsecureKeyPerms`, not a successful
3074    /// signer. The legacy two-open code would have observed the perm
3075    /// failure on the same loose file too, but the property we are
3076    /// pinning here is that the perm rejection comes from the SAME fd
3077    /// the read would have used (no chance for an intermediate swap).
3078    #[test]
3079    #[cfg(unix)]
3080    fn signer_rejects_post_check_swap() {
3081        use std::os::unix::fs::PermissionsExt;
3082        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3083        std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
3084
3085        let (store, dir) = make_store();
3086        let info = store.generate(true).unwrap();
3087        let path = store.entry_path(&info.id);
3088
3089        // Snapshot the legit (0o600) v2 ciphertext bytes so we can
3090        // confirm that even if an attacker were to swap THIS exact
3091        // content under a loose-perms file, the single-open gate
3092        // catches it on the fd.
3093        let original_bytes = fs::read(&path).unwrap();
3094        assert!(!original_bytes.is_empty(), "test sanity");
3095
3096        // Stage the swapped file: same envelope content (so the JSON
3097        // parses and AEAD would succeed if we got that far), but
3098        // loose perms. With the old two-open shape, an attacker could
3099        // present 0o600 to perm-check, then race in this 0o644
3100        // version before the read; with the new single-open shape,
3101        // we open once, fstat the fd, and reject before reading.
3102        fs::write(&path, &original_bytes).unwrap();
3103        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
3104
3105        match store.signer(&info.id) {
3106            Err(KeyError::InsecureKeyPerms { path: p, mode }) => {
3107                assert_eq!(p, path);
3108                assert_eq!(mode & 0o777, 0o644);
3109            }
3110            Err(other) => panic!(
3111                "expected InsecureKeyPerms from single-open fstat gate, got {:?}",
3112                other
3113            ),
3114            Ok(_) => panic!(
3115                "expected InsecureKeyPerms from single-open fstat gate, got ok signer"
3116            ),
3117        }
3118
3119        // The "structural" half of the test: invoke the helper
3120        // directly. It must reject on the open fd, never returning
3121        // an `EncryptedEntry`. This pins the no-second-open property
3122        // -- if a future refactor reintroduces a path-based read
3123        // after the perm gate, this assertion still holds (the gate
3124        // would still trip on the same loose fd) but the code review
3125        // diff is the real test for the structural invariant.
3126        let direct = store.read_entry_with_perm_check(&info.id);
3127        assert!(
3128            matches!(direct, Err(KeyError::InsecureKeyPerms { .. })),
3129            "read_entry_with_perm_check must reject before reading bytes; got {:?}",
3130            direct.map(|_| "ok")
3131        );
3132
3133        cleanup(dir);
3134    }
3135
3136    // --- TS-2026-001 H3 migration-lock concurrency test -----------------
3137
3138    /// H3: two threads calling `Store::signer` on the same legacy v1
3139    /// entry must both succeed, the on-disk entry must end up as a
3140    /// valid v2 entry (decryptable via the v2 path), and no `.tmp`
3141    /// fragment must be left in the keystore directory.
3142    ///
3143    /// Without the advisory lock around `migrate_entry_to_v2`, two
3144    /// concurrent migrators would race the read-modify-rename cycle:
3145    /// the loser's rename would clobber the winner's v2 entry with
3146    /// its own (also-valid) v2 entry, but in between the two
3147    /// renames a third reader could observe a v2 entry, decrypt
3148    /// successfully, then have its in-memory state invalidated by
3149    /// the second writer. The flock turns the race into a queue --
3150    /// both writers produce identical v2 plaintext, only one rename
3151    /// per entry is actually needed, and the second writer's
3152    /// post-lock recheck observes the v2 state and exits cleanly.
3153    #[test]
3154    fn concurrent_migration_serializes_correctly() {
3155        use std::sync::Arc;
3156        use std::thread;
3157
3158        // Set up a legacy v1 entry on disk -- same shape as the
3159        // store_signer_migrates_legacy_entry_to_v2 test, just shared
3160        // with two threads.
3161        let (store, dir) = make_store();
3162        let info = store.generate(true).unwrap();
3163        let entry_path = store.entry_path(&info.id);
3164
3165        let v2_entry: EncryptedEntry =
3166            serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
3167        let secret = decrypt_from_disk(
3168            &store.machine_key,
3169            &v2_entry.id,
3170            &v2_entry.public_key,
3171            &v2_entry.enc_priv_key,
3172            &v2_entry.nonce,
3173        )
3174            .unwrap();
3175        let (legacy_blob, legacy_nonce) =
3176            legacy_v1_encrypt(&store.machine_key, &secret).unwrap();
3177        let legacy_entry = EncryptedEntry {
3178            id:               v2_entry.id.clone(),
3179            algorithm:        v2_entry.algorithm.clone(),
3180            created_at:       v2_entry.created_at.clone(),
3181            public_key:       v2_entry.public_key.clone(),
3182            enc_priv_key:     legacy_blob,
3183            nonce:            legacy_nonce,
3184            valid_until:      v2_entry.valid_until.clone(),
3185            successor_key_id: v2_entry.successor_key_id.clone(),
3186        };
3187        fs::write(&entry_path, serde_json::to_vec_pretty(&legacy_entry).unwrap()).unwrap();
3188
3189        // Two independent Store instances racing on the same on-disk
3190        // legacy entry. Using independent Store instances forces the
3191        // lock-on-disk path to engage (a shared Store would serialize
3192        // through the internal RwLock cache and we'd be testing the
3193        // wrong thing).
3194        let dir_a = Arc::new(dir.clone());
3195        let dir_b = Arc::new(dir.clone());
3196        let id_a = info.id.clone();
3197        let id_b = info.id.clone();
3198
3199        let h1 = thread::spawn(move || -> Result<(), String> {
3200            let s = Store::open(&*dir_a).map_err(|e| e.to_string())?;
3201            let _signer = s.signer(&id_a).map_err(|e| e.to_string())?;
3202            Ok(())
3203        });
3204        let h2 = thread::spawn(move || -> Result<(), String> {
3205            let s = Store::open(&*dir_b).map_err(|e| e.to_string())?;
3206            let _signer = s.signer(&id_b).map_err(|e| e.to_string())?;
3207            Ok(())
3208        });
3209
3210        h1.join().unwrap().expect("thread 1 signer load must succeed");
3211        h2.join().unwrap().expect("thread 2 signer load must succeed");
3212
3213        // Post-condition: on-disk entry is v2 framed.
3214        let after: EncryptedEntry =
3215            serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
3216        assert!(
3217            !is_legacy_v1(&after.enc_priv_key),
3218            "post-concurrent-migration entry must be in v2 format"
3219        );
3220        assert_eq!(after.enc_priv_key[0], KEYSTORE_MAGIC);
3221        assert_eq!(after.enc_priv_key[1], KEYSTORE_VERSION_V2);
3222
3223        // v2 decrypts cleanly. Use the post-migration entry's own id +
3224        // pubkey — the migration must have re-encrypted with those bound
3225        // into the AAD, or this assertion would surface a MAC failure.
3226        let dec = decrypt_v2(
3227            &store.machine_key,
3228            &after.id,
3229            &after.public_key,
3230            &after.enc_priv_key,
3231        )
3232            .expect("v2 entry must decrypt cleanly after concurrent migration");
3233        assert_eq!(dec.len(), 32, "decrypted secret must be a 32-byte ed25519 scalar");
3234
3235        // No stale .tmp file left behind.
3236        for entry in fs::read_dir(&dir).unwrap() {
3237            let p = entry.unwrap().path();
3238            assert!(
3239                p.extension().is_none_or(|e| e != "tmp"),
3240                "no .tmp fragment must remain after migration, found: {}",
3241                p.display()
3242            );
3243        }
3244
3245        cleanup(dir);
3246    }
3247
3248    // --- TS-2026-001 H1 + H2 atomic write tests ------------------------
3249
3250    /// H1: a partial failure between writing the tmp file and renaming
3251    /// it into place MUST leave the original on-disk file intact. We
3252    /// simulate the failure by pre-creating a tmp file (so the next
3253    /// write_file_600 would clobber it) and then independently verifying
3254    /// that an already-written key entry remains decryptable even after
3255    /// a fresh write_file_600 fails partway.
3256    ///
3257    /// We exercise the failure path by pointing the rename at an
3258    /// unwritable target. On Unix we make the *parent directory*
3259    /// read-only after the original key is in place, which causes the
3260    /// final fs::rename to fail with EACCES. The original key file is
3261    /// unaffected because rename(2) returns before touching the target.
3262    #[test]
3263    #[cfg(unix)]
3264    fn atomic_write_leaves_original_intact_on_partial_failure() {
3265        use std::os::unix::fs::PermissionsExt;
3266        let (store, dir) = make_store();
3267        let info = store.generate(true).unwrap();
3268        let entry_path = store.entry_path(&info.id);
3269
3270        // Capture the original bytes for byte-identity comparison.
3271        let original = fs::read(&entry_path).expect("entry file must exist");
3272        assert!(!original.is_empty(), "freshly generated entry must be non-empty");
3273
3274        // Lock the directory: read+execute only, no write. fs::rename
3275        // into this directory will fail.
3276        let orig_dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
3277        fs::set_permissions(&dir, fs::Permissions::from_mode(0o500)).unwrap();
3278
3279        // Attempt a fresh write to the SAME path -- must fail because
3280        // the directory is read-only, exercising the rename-failure
3281        // branch.
3282        let res = write_file_600(&entry_path, b"new junk that must not land");
3283        assert!(res.is_err(), "write_file_600 must fail when dir is read-only");
3284
3285        // Restore perms so we can read back the entry.
3286        fs::set_permissions(&dir, fs::Permissions::from_mode(orig_dir_mode)).unwrap();
3287
3288        // The original key file must be byte-identical to what we
3289        // captured before the failed write.
3290        let after = fs::read(&entry_path).expect("entry file must still exist after failed write");
3291        assert_eq!(
3292            after, original,
3293            "failed atomic write must not corrupt the original file",
3294        );
3295
3296        // And the keystore must still produce a working signer from it.
3297        let store2 = Store::open(&dir).unwrap();
3298        let signer = store2
3299            .signer(&info.id)
3300            .expect("original key must still decrypt after a failed write");
3301        let pae = crate::attestation::pae("text/plain", b"survive");
3302        assert_eq!(signer.sign(&pae).unwrap().len(), 64);
3303
3304        // No stale tmp file left behind.
3305        let tmp = entry_path.with_extension("tmp");
3306        assert!(!tmp.exists(), "tmp file must be cleaned up after rename failure");
3307
3308        cleanup(dir);
3309    }
3310
3311    /// H2: the entry file's mode is 0o600 at the moment of creation, set
3312    /// via OpenOptionsExt::mode rather than a post-write set_permissions
3313    /// (which had a tiny window of looser perms). Also confirms the tmp
3314    /// file is removed by the rename.
3315    #[test]
3316    #[cfg(unix)]
3317    fn mode_is_600_at_creation() {
3318        use std::os::unix::fs::PermissionsExt;
3319        let (store, dir) = make_store();
3320        let info = store.generate(true).unwrap();
3321        let entry_path = store.entry_path(&info.id);
3322
3323        let mode = fs::metadata(&entry_path).unwrap().permissions().mode() & 0o777;
3324        assert_eq!(mode, 0o600, "entry file must be 0600 at creation, got {:o}", mode);
3325
3326        let tmp = entry_path.with_extension("tmp");
3327        assert!(
3328            !tmp.exists(),
3329            "no .tmp file must be left behind after a successful atomic write"
3330        );
3331
3332        cleanup(dir);
3333    }
3334
3335    #[test]
3336    #[cfg(unix)]
3337    fn fix_perms_repairs_loose_modes() {
3338        use std::os::unix::fs::PermissionsExt;
3339        let (store, dir) = make_store();
3340        let info = store.generate(true).unwrap();
3341        let key_path = store.entry_path(&info.id);
3342
3343        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
3344        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
3345
3346        let changes = store.fix_perms().unwrap();
3347        // dir + key file + manifest = 3 paths to fix (manifest may already be 0600
3348        // depending on Manifest write path; we only assert the loose ones moved).
3349        assert!(
3350            changes.iter().any(|(p, _, _)| p == &dir),
3351            "dir should be repaired"
3352        );
3353        assert!(
3354            changes.iter().any(|(p, _, _)| p == &key_path),
3355            "key file should be repaired"
3356        );
3357
3358        let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
3359        let key_mode = fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
3360        assert_eq!(dir_mode, 0o700);
3361        assert_eq!(key_mode, 0o600);
3362
3363        // After repair, signing must work again.
3364        store.signer(&info.id).expect("signing must work after fix_perms");
3365
3366        cleanup(dir);
3367    }
3368
3369    // --- TS-2026-001 post-merge fix-up: entry-binding AAD ------------------
3370
3371    /// Post-merge audit fix: the v2 AAD now binds entry id + public key
3372    /// into the GCM tag. Without that binding, a local attacker with
3373    /// write access to ~/.treeship/keys/ could copy entry A's
3374    /// `enc_priv_key` ciphertext into entry B's JSON envelope; the
3375    /// decrypt would succeed (same machine key, same framing-only AAD)
3376    /// and the signer for advertised key id A would silently sign with
3377    /// key B's secret scalar.
3378    ///
3379    /// This test performs exactly that swap and asserts decryption now
3380    /// fails. Before the fix this test would silently pass with the
3381    /// wrong scalar -- a true regression guard.
3382    #[test]
3383    fn cross_entry_swap_fails_decryption() {
3384        let (store, dir) = make_store();
3385
3386        // Two independent keys in the same store, same machine key.
3387        let a = store.generate(true).unwrap();
3388        let b = store.generate(false).unwrap();
3389
3390        // Snapshot both on-disk envelopes.
3391        let path_a = store.entry_path(&a.id);
3392        let path_b = store.entry_path(&b.id);
3393        let entry_a: EncryptedEntry =
3394            serde_json::from_slice(&fs::read(&path_a).unwrap()).unwrap();
3395        let entry_b: EncryptedEntry =
3396            serde_json::from_slice(&fs::read(&path_b).unwrap()).unwrap();
3397
3398        // Sanity: both are v2 framed, and the ciphertexts differ.
3399        assert_eq!(entry_a.enc_priv_key[0], KEYSTORE_MAGIC);
3400        assert_eq!(entry_a.enc_priv_key[1], KEYSTORE_VERSION_V2);
3401        assert_eq!(entry_b.enc_priv_key[0], KEYSTORE_MAGIC);
3402        assert_eq!(entry_b.enc_priv_key[1], KEYSTORE_VERSION_V2);
3403        assert_ne!(
3404            entry_a.enc_priv_key, entry_b.enc_priv_key,
3405            "two freshly-generated entries must have distinct ciphertexts"
3406        );
3407
3408        // The attack: copy B's enc_priv_key into A's envelope. Leave
3409        // everything else (id, public_key, algorithm) as it was in A.
3410        // This is the file an attacker with write access to the keys
3411        // directory would produce.
3412        let mut tampered_a = entry_a.clone();
3413        tampered_a.enc_priv_key = entry_b.enc_priv_key.clone();
3414        // The v2 nonce travels inline with the ciphertext (bytes
3415        // [2..14] of enc_priv_key), so swapping the blob also swaps
3416        // the nonce; the separate JSON `nonce` field is empty for v2
3417        // entries either way.
3418        fs::write(&path_a, serde_json::to_vec_pretty(&tampered_a).unwrap()).unwrap();
3419
3420        // Fresh Store so the in-memory cache doesn't paper over the
3421        // on-disk tamper.
3422        let store2 = Store::open(&dir).unwrap();
3423        let err = match store2.signer(&a.id) {
3424            Ok(_) => panic!(
3425                "swapping B's ciphertext into A's envelope must fail decrypt; \
3426                 got Ok which means the signer would silently sign with key B"
3427            ),
3428            Err(e) => e,
3429        };
3430
3431        // The specific error must be a crypto/MAC failure, not (e.g.)
3432        // a NotFound or InsecureKeyPerms surface that could mask the
3433        // class of bug.
3434        match err {
3435            KeyError::Crypto(msg) => assert!(
3436                msg.contains("MAC verification failed"),
3437                "swap must surface MAC failure; got: {msg}"
3438            ),
3439            other => panic!("expected Crypto MAC error, got: {other:?}"),
3440        }
3441
3442        cleanup(dir);
3443    }
3444
3445    /// Companion to `cross_entry_swap_fails_decryption`: the id field
3446    /// is also bound into the AAD, so editing the JSON `id` while
3447    /// leaving the ciphertext alone must also fail. (An attacker who
3448    /// renames a stolen entry file onto a victim's id without
3449    /// re-encrypting would land here.)
3450    #[test]
3451    fn aad_tampered_entry_id_fails_decryption() {
3452        let (store, dir) = make_store();
3453        let info = store.generate(true).unwrap();
3454        let path = store.entry_path(&info.id);
3455
3456        let mut entry: EncryptedEntry =
3457            serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
3458        assert_eq!(entry.id, info.id, "sanity: id matches what generate returned");
3459
3460        // Pretend the attacker forged an id. Note we write this back to
3461        // the SAME file path so Store::load_entry by the original id
3462        // finds it; if we changed the path too we'd just be testing
3463        // NotFound, which isn't the point.
3464        entry.id = "key_attacker_substituted_id".to_string();
3465        fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();
3466
3467        // Fresh Store so cache doesn't paper this over. Load via the
3468        // tampered id (matching what's in the JSON) so we exercise the
3469        // decrypt path rather than a path-vs-id mismatch.
3470        let store2 = Store::open(&dir).unwrap();
3471        // Drop the cache by opening fresh; load by the on-disk id.
3472        // The entry_path for "key_attacker_substituted_id" doesn't
3473        // exist, so we deliberately call the lower-level read by
3474        // path-of-original and assert decrypt fails via the dispatcher.
3475        // Easiest: bypass entry_path and invoke decrypt_from_disk with
3476        // the tampered id directly.
3477        let key_buf = store2.machine_key;
3478        let result = decrypt_from_disk(
3479            &key_buf,
3480            &entry.id,          // tampered id (bound into AAD)
3481            &entry.public_key,  // original pubkey
3482            &entry.enc_priv_key,
3483            &entry.nonce,
3484        );
3485        assert!(
3486            result.is_err(),
3487            "AAD-bound entry id mismatch must fail decrypt; got Ok"
3488        );
3489
3490        cleanup(dir);
3491    }
3492}