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