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