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