use std::{
collections::HashMap,
fs,
io::{self, Read, Write},
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use aes_gcm::{
aead::{Aead, KeyInit, OsRng as AeadOsRng, Payload},
AeadCore, Aes256Gcm, Key as AesKey, Nonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest as Sha2Digest, Sha256};
use zeroize::Zeroizing;
use crate::attestation::{Ed25519Signer, Signer};
pub type KeyId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyInfo {
pub id: KeyId,
pub algorithm: String, pub is_default: bool,
pub created_at: String, pub fingerprint: String,
pub public_key: Vec<u8>, #[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub successor_key_id: Option<KeyId>,
}
#[derive(Debug, Clone)]
pub struct RotationResult {
pub predecessor: KeyInfo,
pub successor: KeyInfo,
pub grace_period_until: String,
}
#[derive(Debug)]
pub enum KeyError {
Io(io::Error),
Json(serde_json::Error),
Crypto(String),
NotFound(KeyId),
EmptyKeyId,
NoDefaultKey,
InsecureKeyPerms { path: PathBuf, mode: u32 },
}
impl std::fmt::Display for KeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "keys io: {}", e),
Self::Json(e) => write!(f, "keys json: {}", e),
Self::Crypto(e) => write!(f, "keys crypto: {}", e),
Self::NotFound(k) => write!(f, "key not found: {}", k),
Self::EmptyKeyId => write!(f, "key id must not be empty"),
Self::NoDefaultKey => write!(f, "no default key — run treeship init"),
Self::InsecureKeyPerms { path, mode } => write!(
f,
"private key {} has insecure permissions (mode {:o}); \
run `treeship doctor --fix` or chmod 600 the file. \
Set TREESHIP_ALLOW_INSECURE_KEY_PERMS=1 to bypass.",
path.display(),
mode & 0o777,
),
}
}
}
impl std::error::Error for KeyError {}
impl From<io::Error> for KeyError { fn from(e: io::Error) -> Self { Self::Io(e) } }
impl From<serde_json::Error> for KeyError { fn from(e: serde_json::Error) -> Self { Self::Json(e) } }
#[derive(Serialize, Deserialize, Clone)]
struct EncryptedEntry {
id: KeyId,
algorithm: String,
created_at: String,
public_key: Vec<u8>,
enc_priv_key: Vec<u8>,
nonce: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
valid_until: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
successor_key_id: Option<KeyId>,
}
#[derive(Serialize, Deserialize, Default)]
struct Manifest {
default_key_id: Option<KeyId>,
key_ids: Vec<KeyId>,
}
pub struct Store {
dir: PathBuf,
machine_key: [u8; 32],
cache: Arc<RwLock<HashMap<KeyId, EncryptedEntry>>>,
}
impl Store {
pub fn open(dir: impl AsRef<Path>) -> Result<Self, KeyError> {
let dir = dir.as_ref().to_path_buf();
fs::create_dir_all(&dir)?;
let machine_key = derive_machine_key(&dir)?;
Ok(Self {
dir,
machine_key,
cache: Arc::new(RwLock::new(HashMap::new())),
})
}
pub fn generate(&self, set_default: bool) -> Result<KeyInfo, KeyError> {
let key_id = new_key_id();
let signer = Ed25519Signer::generate(&key_id)
.map_err(|e| KeyError::Crypto(e.to_string()))?;
let secret = signer.secret_bytes();
let pub_key = signer.public_key_bytes();
let enc = encrypt_for_disk_v2(&self.machine_key, key_id.as_str(), &pub_key, secret.as_slice())
.map_err(KeyError::Crypto)?;
let entry = EncryptedEntry {
id: key_id.clone(),
algorithm: "ed25519".into(),
created_at: crate::statements::unix_to_rfc3339(unix_now()),
public_key: pub_key.clone(),
enc_priv_key: enc,
nonce: Vec::new(),
valid_until: None,
successor_key_id: None,
};
self.write_entry(&entry)?;
let mut manifest = self.read_manifest()?;
manifest.key_ids.push(key_id.clone());
if set_default || manifest.default_key_id.is_none() {
manifest.default_key_id = Some(key_id.clone());
}
self.write_manifest(&manifest)?;
self.cache.write().unwrap().insert(key_id.clone(), entry);
Ok(KeyInfo {
id: key_id.clone(),
algorithm: "ed25519".into(),
is_default: manifest.default_key_id.as_deref() == Some(key_id.as_str()),
created_at: crate::statements::unix_to_rfc3339(unix_now()),
fingerprint: fingerprint(&pub_key),
public_key: pub_key,
valid_until: None,
successor_key_id: None,
})
}
pub fn rotate(
&self,
predecessor_id: Option<&str>,
grace_period: std::time::Duration,
set_default: bool,
) -> Result<RotationResult, KeyError> {
let pred_id = match predecessor_id {
Some(id) => id.to_string(),
None => self.default_key_id()?,
};
let pred_entry_existing = self.load_entry(&pred_id)?;
if let Some(existing) = &pred_entry_existing.successor_key_id {
return Err(KeyError::Crypto(format!(
"key {pred_id} has already been rotated to {existing}; \
rotate the chain head instead"
)));
}
let succ_id = new_key_id();
let signer = Ed25519Signer::generate(&succ_id)
.map_err(|e| KeyError::Crypto(e.to_string()))?;
let succ_secret = signer.secret_bytes();
let succ_pub_key = signer.public_key_bytes();
let succ_enc =
encrypt_for_disk_v2(&self.machine_key, succ_id.as_str(), &succ_pub_key, succ_secret.as_slice())
.map_err(KeyError::Crypto)?;
let succ_created = crate::statements::unix_to_rfc3339(unix_now());
let succ_entry = EncryptedEntry {
id: succ_id.clone(),
algorithm: "ed25519".into(),
created_at: succ_created.clone(),
public_key: succ_pub_key.clone(),
enc_priv_key: succ_enc,
nonce: Vec::new(),
valid_until: None,
successor_key_id: None,
};
let valid_until = crate::statements::unix_to_rfc3339(
unix_now() + grace_period.as_secs(),
);
let mut pred_entry = pred_entry_existing;
pred_entry.valid_until = Some(valid_until.clone());
pred_entry.successor_key_id = Some(succ_id.clone());
self.write_entry(&succ_entry)?;
self.write_entry(&pred_entry)?;
{
let mut cache = self.cache.write().unwrap();
cache.insert(pred_entry.id.clone(), pred_entry.clone());
cache.insert(succ_id.clone(), succ_entry.clone());
}
let mut manifest = self.read_manifest()?;
manifest.key_ids.push(succ_id.clone());
if set_default {
manifest.default_key_id = Some(succ_id.clone());
}
self.write_manifest(&manifest)?;
let default_id = manifest.default_key_id.clone();
let predecessor = KeyInfo {
id: pred_entry.id.clone(),
algorithm: pred_entry.algorithm.clone(),
is_default: default_id.as_deref() == Some(pred_entry.id.as_str()),
created_at: pred_entry.created_at.clone(),
fingerprint: fingerprint(&pred_entry.public_key),
public_key: pred_entry.public_key.clone(),
valid_until: pred_entry.valid_until.clone(),
successor_key_id: pred_entry.successor_key_id.clone(),
};
let successor = KeyInfo {
id: succ_id.clone(),
algorithm: "ed25519".into(),
is_default: default_id.as_deref() == Some(succ_id.as_str()),
created_at: succ_created,
fingerprint: fingerprint(&succ_pub_key),
public_key: succ_pub_key,
valid_until: None,
successor_key_id: None,
};
Ok(RotationResult {
predecessor,
successor,
grace_period_until: valid_until,
})
}
pub fn successor_chain(&self, id: &str) -> Result<Vec<KeyId>, KeyError> {
let mut chain = Vec::new();
let mut cursor = id.to_string();
let max_steps = self.read_manifest()?.key_ids.len() + 1;
for _ in 0..max_steps {
chain.push(cursor.clone());
let entry = self.load_entry(&cursor)?;
match entry.successor_key_id {
Some(next) => cursor = next,
None => return Ok(chain),
}
}
Err(KeyError::Crypto(format!(
"rotation chain starting at {id} exceeds keystore size; suspected loop"
)))
}
pub fn valid_keys_at(&self, at_unix_secs: u64) -> Result<Vec<KeyInfo>, KeyError> {
let cutoff_rfc = crate::statements::unix_to_rfc3339(at_unix_secs);
Ok(self.list()?
.into_iter()
.filter(|k| match &k.valid_until {
None => true,
Some(until) => until.as_str() > cutoff_rfc.as_str(),
})
.collect())
}
pub fn default_signer(&self) -> Result<Box<dyn Signer>, KeyError> {
let manifest = self.read_manifest()?;
let id = manifest.default_key_id.ok_or(KeyError::NoDefaultKey)?;
self.signer(&id)
}
pub fn signer(&self, id: &str) -> Result<Box<dyn Signer>, KeyError> {
let entry = self.read_entry_with_perm_check(id)?;
let was_legacy = is_legacy_v1(&entry.enc_priv_key);
let secret = decrypt_from_disk(
&self.machine_key,
&entry.id,
&entry.public_key,
&entry.enc_priv_key,
&entry.nonce,
)
.map_err(|e| self.enrich_crypto_error(e))?;
let secret_arr: Zeroizing<[u8; 32]> = Zeroizing::new(
secret.as_slice().try_into()
.map_err(|_| KeyError::Crypto("decrypted key is wrong length".into()))?
);
if was_legacy {
if let Err(e) = self.migrate_entry_to_v2(&entry, &secret_arr) {
eprintln!(
"treeship: keystore entry {} could not be migrated \
from legacy v1 format to v2 ({}); will retry next \
load",
entry.id, e
);
}
}
let signer = Ed25519Signer::from_bytes(&entry.id, &secret_arr)
.map_err(|e| KeyError::Crypto(e.to_string()))?;
Ok(Box::new(signer))
}
fn migrate_entry_to_v2(
&self,
old_entry: &EncryptedEntry,
secret: &[u8; 32],
) -> Result<(), KeyError> {
let entry_path = self.entry_path(&old_entry.id);
let lock_path = entry_path.with_extension("migrate.lock");
let lock_file = open_migration_lock_file(&lock_path)
.map_err(KeyError::Io)?;
#[cfg(not(target_family = "wasm"))]
{
use fs2::FileExt;
lock_file.lock_exclusive().map_err(KeyError::Io)?;
}
if let Ok(current) = self.read_entry(&old_entry.id) {
if !is_legacy_v1(¤t.enc_priv_key) {
if let Ok(mut cache) = self.cache.write() {
cache.insert(current.id.clone(), current);
}
return Ok(());
}
}
let new_ciphertext = encrypt_for_disk_v2(
&self.machine_key,
&old_entry.id,
&old_entry.public_key,
secret,
)
.map_err(KeyError::Crypto)?;
let migrated = EncryptedEntry {
id: old_entry.id.clone(),
algorithm: old_entry.algorithm.clone(),
created_at: old_entry.created_at.clone(),
public_key: old_entry.public_key.clone(),
enc_priv_key: new_ciphertext,
nonce: Vec::new(),
valid_until: old_entry.valid_until.clone(),
successor_key_id: old_entry.successor_key_id.clone(),
};
self.write_entry(&migrated)?;
if let Ok(mut cache) = self.cache.write() {
cache.insert(migrated.id.clone(), migrated);
}
let _ = std::fs::remove_file(&lock_path);
drop(lock_file);
Ok(())
}
fn enrich_crypto_error(&self, raw: String) -> KeyError {
if !raw.contains("MAC verification failed") {
return KeyError::Crypto(raw);
}
let legacy_seed_dot = self.dir.join(".machineseed");
let legacy_seed = self.dir.join("machine_seed");
let has_legacy_seed = legacy_seed_dot.exists() || legacy_seed.exists();
let diagnosis = if has_legacy_seed {
"your keystore was created by an older Treeship version whose \
machine-key derivation has since changed. The ciphertext is \
intact but cannot be decrypted under the current derivation."
} else {
"the keystore cannot be decrypted. Usual causes: the key file \
was copied from a different machine, the hostname or username \
changed, or the file was corrupted."
};
let ts_dir = std::env::var("HOME")
.map(|h| format!("{h}/.treeship"))
.unwrap_or_else(|_| "~/.treeship".into());
let msg = format!(
"{raw}\n\n \
Diagnosis: {diagnosis}\n\n \
Recovery (nondestructive -- the old keystore is moved aside, \
not deleted; any sealed .treeship packages you produced remain \
verifiable since their receipts embed the old public key):\n\n \
mv {ts_dir} {ts_dir}.bak.$(date +%s)\n \
treeship init\n"
);
KeyError::Crypto(msg)
}
pub fn default_key_id(&self) -> Result<KeyId, KeyError> {
self.read_manifest()?
.default_key_id
.ok_or(KeyError::NoDefaultKey)
}
pub fn list(&self) -> Result<Vec<KeyInfo>, KeyError> {
let manifest = self.read_manifest()?;
let default = manifest.default_key_id.as_deref().unwrap_or("");
manifest.key_ids.iter().map(|id| {
let entry = self.load_entry(id)?;
Ok(KeyInfo {
id: entry.id.clone(),
algorithm: entry.algorithm.clone(),
is_default: entry.id == default,
created_at: entry.created_at.clone(),
fingerprint: fingerprint(&entry.public_key),
public_key: entry.public_key.clone(),
valid_until: entry.valid_until.clone(),
successor_key_id: entry.successor_key_id.clone(),
})
}).collect()
}
pub fn set_default(&self, id: &str) -> Result<(), KeyError> {
self.load_entry(id)?;
let mut manifest = self.read_manifest()?;
manifest.default_key_id = Some(id.to_string());
self.write_manifest(&manifest)
}
pub fn public_key(&self, id: &str) -> Result<Vec<u8>, KeyError> {
Ok(self.load_entry(id)?.public_key)
}
fn load_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
if let Ok(cache) = self.cache.read() {
if let Some(entry) = cache.get(id) {
return Ok(entry.clone());
}
}
self.read_entry(id)
}
fn entry_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{}.json", id))
}
fn write_entry(&self, entry: &EncryptedEntry) -> Result<(), KeyError> {
let path = self.entry_path(&entry.id);
let json = serde_json::to_vec_pretty(entry)?;
write_file_600(&path, &json)?;
Ok(())
}
fn read_entry(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
let path = self.entry_path(id);
if !path.exists() {
return Err(KeyError::NotFound(id.to_string()));
}
let bytes = fs::read(&path)?;
let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
Ok(entry)
}
fn read_entry_with_perm_check(&self, id: &str) -> Result<EncryptedEntry, KeyError> {
let path = self.entry_path(id);
let mut file = match fs::File::open(&path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(KeyError::NotFound(id.to_string()));
}
Err(e) => return Err(KeyError::Io(e)),
};
check_open_key_file_perms(&path, &file)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let entry: EncryptedEntry = serde_json::from_slice(&bytes)?;
Ok(entry)
}
fn manifest_path(&self) -> PathBuf {
self.dir.join("manifest.json")
}
fn read_manifest(&self) -> Result<Manifest, KeyError> {
let path = self.manifest_path();
if !path.exists() {
return Ok(Manifest::default());
}
let bytes = fs::read(&path)?;
Ok(serde_json::from_slice(&bytes)?)
}
fn write_manifest(&self, m: &Manifest) -> Result<(), KeyError> {
let json = serde_json::to_vec_pretty(m)?;
write_file_600(&self.manifest_path(), &json)?;
Ok(())
}
}
const KEYSTORE_MAGIC: u8 = 0x54; const KEYSTORE_VERSION_V2: u8 = 0x02;
fn build_aad_v2(entry_id: &str, public_key: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(2 + 4 + entry_id.len() + 4 + public_key.len());
aad.push(KEYSTORE_MAGIC);
aad.push(KEYSTORE_VERSION_V2);
aad.extend_from_slice(&(entry_id.len() as u32).to_be_bytes());
aad.extend_from_slice(entry_id.as_bytes());
aad.extend_from_slice(&(public_key.len() as u32).to_be_bytes());
aad.extend_from_slice(public_key);
aad
}
fn encrypt_for_disk_v2(
key: &[u8; 32],
entry_id: &str,
public_key: &[u8],
plaintext: &[u8],
) -> Result<Vec<u8>, String> {
let key_buf: Zeroizing<[u8; 32]> = Zeroizing::new(*key);
let aead_key: &AesKey<Aes256Gcm> = AesKey::<Aes256Gcm>::from_slice(key_buf.as_slice());
let cipher = Aes256Gcm::new(aead_key);
let nonce = Aes256Gcm::generate_nonce(&mut AeadOsRng);
let aad = build_aad_v2(entry_id, public_key);
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad: aad.as_slice(),
},
)
.map_err(|e| format!("aead encrypt failed: {e}"))?;
let mut out = Vec::with_capacity(2 + 12 + ciphertext.len());
out.push(KEYSTORE_MAGIC);
out.push(KEYSTORE_VERSION_V2);
out.extend_from_slice(nonce.as_slice());
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn decrypt_v2(
key: &[u8; 32],
entry_id: &str,
public_key: &[u8],
blob: &[u8],
) -> Result<Vec<u8>, String> {
if blob.len() < 30 {
return Err("v2 ciphertext too short".into());
}
if blob[0] != KEYSTORE_MAGIC || blob[1] != KEYSTORE_VERSION_V2 {
return Err("v2 ciphertext has wrong magic/version".into());
}
let nonce_bytes = &blob[2..14];
let ct = &blob[14..];
let key_buf: Zeroizing<[u8; 32]> = Zeroizing::new(*key);
let aead_key: &AesKey<Aes256Gcm> = AesKey::<Aes256Gcm>::from_slice(key_buf.as_slice());
let cipher = Aes256Gcm::new(aead_key);
let nonce = Nonce::from_slice(nonce_bytes);
let aad = build_aad_v2(entry_id, public_key);
cipher
.decrypt(
nonce,
Payload {
msg: ct,
aad: aad.as_slice(),
},
)
.map_err(|_| "MAC verification failed — key file may be corrupt or wrong machine".into())
}
fn is_legacy_v1(blob: &[u8]) -> bool {
!(blob.len() >= 2 && blob[0] == KEYSTORE_MAGIC && blob[1] == KEYSTORE_VERSION_V2)
}
fn decrypt_from_disk(
key: &[u8; 32],
entry_id: &str,
public_key: &[u8],
enc_data: &[u8],
legacy_nonce_field: &[u8],
) -> Result<Zeroizing<Vec<u8>>, String> {
if !is_legacy_v1(enc_data) {
match decrypt_v2(key, entry_id, public_key, enc_data) {
Ok(pt) => return Ok(Zeroizing::new(pt)),
Err(v2_err) => {
return match decrypt_legacy_v1(key, enc_data, legacy_nonce_field) {
Ok(pt) => Ok(Zeroizing::new(pt)),
Err(_) => Err(v2_err),
};
}
}
}
decrypt_legacy_v1(key, enc_data, legacy_nonce_field).map(Zeroizing::new)
}
pub fn aes_gcm_decrypt(
key: &[u8; 32],
enc_data: &[u8],
_nonce_unused: &[u8],
) -> Result<Vec<u8>, String> {
decrypt_legacy_v1(key, enc_data, _nonce_unused)
}
pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
legacy_v1_encrypt(key, plaintext)
}
fn legacy_v1_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
use sha2::Sha256;
let mut nonce = [0u8; 12];
OsRng.fill_bytes(&mut nonce);
let mut enc_key_input = key.to_vec();
enc_key_input.extend_from_slice(&nonce);
enc_key_input.extend_from_slice(b"enc");
let enc_key = Sha256::digest(&enc_key_input);
let mut mac_key_input = key.to_vec();
mac_key_input.extend_from_slice(&nonce);
mac_key_input.extend_from_slice(b"mac");
let mac_key = Sha256::digest(&mac_key_input);
let ciphertext: Vec<u8> = plaintext.iter().enumerate().map(|(i, &b)| {
let mut block_input = enc_key.to_vec();
block_input.extend_from_slice(&(i as u64).to_le_bytes());
let block = Sha256::digest(&block_input);
b ^ block[i % 32]
}).collect();
let mut mac_input = mac_key.to_vec();
mac_input.extend_from_slice(&nonce);
mac_input.extend_from_slice(&ciphertext);
let mac = Sha256::digest(&mac_input);
let mut out = Vec::with_capacity(12 + 32 + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&mac);
out.extend_from_slice(&ciphertext);
Ok((out, nonce.to_vec()))
}
fn decrypt_legacy_v1(
key: &[u8; 32],
enc_data: &[u8],
_nonce_unused: &[u8],
) -> Result<Vec<u8>, String> {
if enc_data.len() < 44 {
return Err("ciphertext too short".into());
}
use sha2::Sha256;
let nonce = &enc_data[..12];
let stored_mac = &enc_data[12..44];
let ciphertext = &enc_data[44..];
let nonce_arr: [u8; 12] = nonce.try_into().unwrap();
let mut enc_key_input = key.to_vec();
enc_key_input.extend_from_slice(&nonce_arr);
enc_key_input.extend_from_slice(b"enc");
let enc_key = Sha256::digest(&enc_key_input);
let mut mac_key_input = key.to_vec();
mac_key_input.extend_from_slice(&nonce_arr);
mac_key_input.extend_from_slice(b"mac");
let mac_key = Sha256::digest(&mac_key_input);
let mut mac_input = mac_key.to_vec();
mac_input.extend_from_slice(&nonce_arr);
mac_input.extend_from_slice(ciphertext);
let computed_mac = Sha256::digest(&mac_input);
let mac_ok = stored_mac.iter().zip(computed_mac.iter())
.fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0;
if !mac_ok {
return Err("MAC verification failed — key file may be corrupt or wrong machine".into());
}
let plaintext: Vec<u8> = ciphertext.iter().enumerate().map(|(i, &b)| {
let mut block_input = enc_key.to_vec();
block_input.extend_from_slice(&(i as u64).to_le_bytes());
let block = Sha256::digest(&block_input);
b ^ block[i % 32]
}).collect();
Ok(plaintext)
}
pub fn derive_machine_key(store_dir: &Path) -> Result<[u8; 32], KeyError> {
if let Ok(id) = fs::read_to_string("/etc/machine-id") {
let trimmed = id.trim();
if !trimmed.is_empty() {
let mut h = Sha256::new();
h.update(trimmed.as_bytes());
h.update(store_dir.to_string_lossy().as_bytes());
return Ok(h.finalize().into());
}
}
#[cfg(target_os = "macos")]
{
let hostname = std::process::Command::new("hostname")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let username = std::env::var("USER").unwrap_or_default();
if !hostname.is_empty() && !username.is_empty() {
let mut h = Sha256::new();
h.update(b"treeship-machine-key:");
h.update(hostname.as_bytes());
h.update(b":");
h.update(username.as_bytes());
h.update(b":");
h.update(store_dir.to_string_lossy().as_bytes());
return Ok(h.finalize().into());
}
}
let local_seed_path = store_dir.parent().map(|p| p.join("machine_seed"));
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
let global_seed_path = home.join(".treeship").join("machine_seed");
let seed = if let Some(local) = local_seed_path.as_ref().filter(|p| p.exists()) {
fs::read_to_string(local).map_err(KeyError::Io)?
} else if global_seed_path.exists() {
fs::read_to_string(&global_seed_path).map_err(KeyError::Io)?
} else {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let seed_hex = hex_encode(&bytes);
let target = match local_seed_path.as_ref() {
Some(p) => {
let _ = fs::create_dir_all(p.parent().unwrap_or(Path::new(".")));
p.clone()
}
None => {
let _ = fs::create_dir_all(global_seed_path.parent().unwrap_or(Path::new(".")));
global_seed_path.clone()
}
};
fs::write(&target, &seed_hex).map_err(KeyError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&target, fs::Permissions::from_mode(0o600));
}
seed_hex
};
let mut h = Sha256::new();
h.update(b"treeship-machine-key-fallback:");
h.update(seed.trim().as_bytes());
h.update(b":");
h.update(store_dir.to_string_lossy().as_bytes());
Ok(h.finalize().into())
}
pub fn derive_machine_key_stable(store_dir: &Path) -> Result<[u8; 32], KeyError> {
if let Ok(id) = fs::read_to_string("/etc/machine-id") {
let trimmed = id.trim();
if !trimmed.is_empty() {
let mut h = Sha256::new();
h.update(b"treeship-machine-key-v2:");
h.update(trimmed.as_bytes());
h.update(b":");
h.update(store_dir.to_string_lossy().as_bytes());
return Ok(h.finalize().into());
}
}
#[cfg(target_os = "macos")]
{
if let Ok(output) = std::process::Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("IOPlatformSerialNumber") {
if let Some(serial) = line.split('"').nth(3) {
if !serial.is_empty() {
let mut h = Sha256::new();
h.update(b"treeship-machine-key-v2:");
h.update(serial.as_bytes());
h.update(b":");
h.update(store_dir.to_string_lossy().as_bytes());
return Ok(h.finalize().into());
}
}
}
}
}
}
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.map_err(|_| KeyError::Crypto("HOME not set".to_string()))?;
let seed_dir = home.join(".treeship").join(".internal");
let _ = fs::create_dir_all(&seed_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&seed_dir, fs::Permissions::from_mode(0o700));
}
let seed_path = seed_dir.join("machine_seed_v2");
let seed = if seed_path.exists() {
fs::read_to_string(&seed_path).map_err(KeyError::Io)?
} else {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let seed_hex = hex_encode(&bytes);
fs::write(&seed_path, &seed_hex).map_err(KeyError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&seed_path, fs::Permissions::from_mode(0o600));
}
seed_hex
};
let mut h = Sha256::new();
h.update(b"treeship-machine-key-v2-fallback:");
h.update(seed.trim().as_bytes());
h.update(b":");
h.update(store_dir.to_string_lossy().as_bytes());
Ok(h.finalize().into())
}
fn new_key_id() -> KeyId {
let mut b = [0u8; 8];
OsRng.fill_bytes(&mut b);
format!("key_{}", hex_encode(&b))
}
fn fingerprint(pub_key: &[u8]) -> String {
let h = Sha256::digest(pub_key);
hex_encode(&h[..8])
}
fn hex_encode(b: &[u8]) -> String {
b.iter().fold(String::new(), |mut s, byte| {
s.push_str(&format!("{:02x}", byte));
s
})
}
#[allow(dead_code)]
fn check_key_file_perms(path: &Path) -> Result<(), KeyError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
.map(|v| v == "1")
.unwrap_or(false)
{
return Ok(());
}
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(KeyError::InsecureKeyPerms {
path: path.to_path_buf(),
mode,
});
}
}
let _ = path;
Ok(())
}
#[allow(unused_variables)]
fn check_open_key_file_perms(path: &Path, file: &fs::File) -> Result<(), KeyError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
.map(|v| v == "1")
.unwrap_or(false)
{
return Ok(());
}
let meta = file.metadata()?;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(KeyError::InsecureKeyPerms {
path: path.to_path_buf(),
mode,
});
}
}
Ok(())
}
impl Store {
pub fn fix_perms(&self) -> Result<Vec<(PathBuf, u32, u32)>, KeyError> {
let mut changed: Vec<(PathBuf, u32, u32)> = Vec::new();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir_meta = fs::metadata(&self.dir)?;
let dir_mode = dir_meta.permissions().mode() & 0o777;
if dir_mode != 0o700 {
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?;
changed.push((self.dir.clone(), dir_mode, 0o700));
}
for entry in fs::read_dir(&self.dir)? {
let entry = entry?;
let path = entry.path();
if !entry.file_type()?.is_file() {
continue;
}
let mode = entry.metadata()?.permissions().mode() & 0o777;
if mode != 0o600 {
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
changed.push((path, mode, 0o600));
}
}
}
Ok(changed)
}
}
#[cfg(unix)]
fn open_migration_lock_file(path: &Path) -> Result<fs::File, io::Error> {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
fn open_migration_lock_file(path: &Path) -> Result<fs::File, io::Error> {
fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
}
fn write_file_600(path: &Path, data: &[u8]) -> Result<(), KeyError> {
let tmp_path = path.with_extension("tmp");
let _ = fs::remove_file(&tmp_path);
let write_result: Result<(), KeyError> = (|| {
#[cfg(unix)]
let open = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
};
#[cfg(not(unix))]
let open = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path);
let mut f = open?;
f.write_all(data)?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(KeyError::Io(e));
}
#[cfg(unix)]
{
if let Some(parent) = path.parent() {
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
}
}
Ok(())
}
fn unix_now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir_path() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("treeship-test-{}", {
let mut b = [0u8; 4];
rand::thread_rng().fill_bytes(&mut b);
hex_encode(&b)
}));
p
}
fn make_store() -> (Store, PathBuf) {
let dir = temp_dir_path();
let store = Store::open(&dir).unwrap();
(store, dir)
}
fn cleanup(dir: PathBuf) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn generate_key() {
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
assert!(info.id.starts_with("key_"));
assert_eq!(info.algorithm, "ed25519");
assert!(!info.fingerprint.is_empty());
assert_eq!(info.public_key.len(), 32);
cleanup(dir);
}
#[test]
fn default_signer_works() {
let (store, dir) = make_store();
store.generate(true).unwrap();
let signer = store.default_signer().unwrap();
assert!(!signer.key_id().is_empty());
let pae = crate::attestation::pae("text/plain", b"test");
let sig = signer.sign(&pae).unwrap();
assert_eq!(sig.len(), 64);
cleanup(dir);
}
#[test]
fn encrypt_decrypt_roundtrip() {
let key = [42u8; 32];
let plaintext = b"super secret private key material here!";
let (enc, nonce) = aes_gcm_encrypt(&key, plaintext).unwrap();
let dec = aes_gcm_decrypt(&key, &enc, &nonce).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn decrypt_wrong_key_fails() {
let key = [42u8; 32];
let wrong = [99u8; 32];
let (enc, nonce) = aes_gcm_encrypt(&key, b"secret").unwrap();
assert!(aes_gcm_decrypt(&wrong, &enc, &nonce).is_err());
}
const TEST_ENTRY_ID: &str = "key_unit_test_entry_0001";
const TEST_PUBLIC_KEY: &[u8; 32] = &[0xAA; 32];
#[test]
fn v2_encrypt_decrypt_roundtrip() {
let key = [7u8; 32];
let plaintext = b"super secret private key material here!";
let blob =
encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, plaintext).unwrap();
assert_eq!(blob[0], KEYSTORE_MAGIC, "magic byte");
assert_eq!(blob[1], KEYSTORE_VERSION_V2, "version byte");
assert_eq!(blob.len(), 2 + 12 + plaintext.len() + 16,
"magic+version+nonce+ct+tag length");
let dec =
decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]).unwrap();
assert_eq!(&*dec, plaintext);
}
#[test]
fn v2_decrypt_wrong_key_fails() {
let key = [7u8; 32];
let wrong = [99u8; 32];
let blob = encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"secret").unwrap();
let result = decrypt_from_disk(&wrong, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
assert!(result.is_err(), "wrong key must fail");
}
#[test]
fn v2_tamper_ciphertext_fails() {
let key = [7u8; 32];
let mut blob = encrypt_for_disk_v2(
&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
).unwrap();
let last = blob.len() - 5;
blob[last] ^= 0x01;
let result = decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
assert!(result.is_err(), "tampered ciphertext must fail to decrypt");
}
#[test]
fn v2_tamper_nonce_fails() {
let key = [7u8; 32];
let mut blob = encrypt_for_disk_v2(
&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
).unwrap();
blob[5] ^= 0x01;
let result = decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
assert!(result.is_err(), "tampered nonce must fail to decrypt");
}
#[test]
fn v2_tamper_tag_fails() {
let key = [7u8; 32];
let mut blob = encrypt_for_disk_v2(
&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
).unwrap();
let len = blob.len();
blob[len - 1] ^= 0x80;
let result = decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]);
assert!(result.is_err(), "tampered GCM tag must fail to decrypt");
}
#[test]
fn v2_nonces_are_unique_across_writes() {
let key = [7u8; 32];
let blob_a =
encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"identical").unwrap();
let blob_b =
encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"identical").unwrap();
assert_ne!(blob_a, blob_b,
"two v2 encryptions of the same plaintext must differ");
assert_ne!(&blob_a[2..14], &blob_b[2..14], "nonces must differ");
const N: usize = 10_000;
let mut nonces: std::collections::HashSet<Vec<u8>> =
std::collections::HashSet::with_capacity(N);
for _ in 0..N {
let blob =
encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"x").unwrap();
nonces.insert(blob[2..14].to_vec());
}
assert_eq!(
nonces.len(),
N,
"all {} v2 nonces must be unique; collision => RNG defect",
N
);
}
#[test]
fn v2_tamper_version_byte_fails() {
let key = [7u8; 32];
let mut blob = encrypt_for_disk_v2(
&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"super secret private key"
).unwrap();
assert_eq!(blob[1], KEYSTORE_VERSION_V2);
blob[1] = 0xff;
assert!(
decrypt_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob).is_err(),
"altered version byte must be rejected"
);
}
#[test]
fn v2_aad_binding_detects_framing_substitution() {
let key = [7u8; 32];
let plaintext = b"M2 AAD bound material";
use aes_gcm::aead::Aead;
let key_buf: Zeroizing<[u8; 32]> = Zeroizing::new(key);
let aead_key: &AesKey<Aes256Gcm> = AesKey::<Aes256Gcm>::from_slice(key_buf.as_slice());
let cipher = Aes256Gcm::new(aead_key);
let nonce = Aes256Gcm::generate_nonce(&mut AeadOsRng);
let ct_no_aad = cipher.encrypt(&nonce, plaintext.as_slice()).unwrap();
let mut forged = Vec::with_capacity(2 + 12 + ct_no_aad.len());
forged.push(KEYSTORE_MAGIC);
forged.push(KEYSTORE_VERSION_V2);
forged.extend_from_slice(nonce.as_slice());
forged.extend_from_slice(&ct_no_aad);
assert_eq!(forged[0], KEYSTORE_MAGIC);
assert_eq!(forged[1], KEYSTORE_VERSION_V2);
let result = decrypt_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &forged);
assert!(result.is_err(),
"ciphertext computed without AAD must fail to decrypt now that AAD is bound");
}
#[test]
fn dispatcher_surfaces_v2_error_on_corrupted_v2_blob() {
let key = [7u8; 32];
let mut blob =
encrypt_for_disk_v2(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, b"hello").unwrap();
let last = blob.len() - 1;
blob[last] ^= 0x01;
let err =
decrypt_from_disk(&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &blob, &[]).unwrap_err();
assert!(
err.contains("MAC verification failed"),
"dispatcher must surface the v2 MAC error on corrupted v2 blob, got: {err}"
);
}
#[test]
fn legacy_v1_ciphertext_still_decrypts_via_dispatcher() {
let key = [13u8; 32];
let plaintext = b"pre-v0.10.3 keystore entry";
let (legacy_blob, legacy_nonce) =
legacy_v1_encrypt(&key, plaintext).unwrap();
assert!(is_legacy_v1(&legacy_blob),
"legacy_v1_encrypt output must classify as legacy");
let dec = decrypt_from_disk(
&key, TEST_ENTRY_ID, TEST_PUBLIC_KEY, &legacy_blob, &legacy_nonce,
)
.unwrap();
assert_eq!(&*dec, plaintext);
}
#[test]
fn store_signer_migrates_legacy_entry_to_v2() {
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let entry_path = store.entry_path(&info.id);
let v2_entry: EncryptedEntry =
serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
let secret = decrypt_from_disk(
&store.machine_key,
&v2_entry.id,
&v2_entry.public_key,
&v2_entry.enc_priv_key,
&v2_entry.nonce,
)
.unwrap();
let (legacy_blob, legacy_nonce) =
legacy_v1_encrypt(&store.machine_key, &secret).unwrap();
let legacy_entry = EncryptedEntry {
id: v2_entry.id.clone(),
algorithm: v2_entry.algorithm.clone(),
created_at: v2_entry.created_at.clone(),
public_key: v2_entry.public_key.clone(),
enc_priv_key: legacy_blob,
nonce: legacy_nonce,
valid_until: v2_entry.valid_until.clone(),
successor_key_id: v2_entry.successor_key_id.clone(),
};
fs::write(&entry_path, serde_json::to_vec_pretty(&legacy_entry).unwrap()).unwrap();
let store2 = Store::open(&dir).unwrap();
let _signer = store2.signer(&info.id).unwrap();
let after: EncryptedEntry =
serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
assert!(!is_legacy_v1(&after.enc_priv_key),
"post-migration entry must be in v2 format");
assert_eq!(after.enc_priv_key[0], KEYSTORE_MAGIC);
assert_eq!(after.enc_priv_key[1], KEYSTORE_VERSION_V2);
assert!(after.nonce.is_empty(),
"v2 entries serialize an empty legacy nonce field");
let store3 = Store::open(&dir).unwrap();
let _signer = store3
.signer(&info.id)
.expect("post-migration v2 decrypt works");
cleanup(dir);
}
#[test]
fn persist_and_reload() {
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let store2 = Store::open(&dir).unwrap();
let signer = store2.signer(&info.id).unwrap();
assert_eq!(signer.key_id(), info.id);
let verifier = {
use crate::attestation::Verifier;
use ed25519_dalek::VerifyingKey;
let pk_bytes: [u8; 32] = info.public_key.try_into().unwrap();
let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
let mut v = Verifier::new(std::collections::HashMap::new());
v.add_key(info.id.clone(), vk);
v
};
use crate::attestation::sign;
use crate::statements::ActionStatement;
let stmt = ActionStatement::new("agent://test", "tool.call");
let pt = crate::statements::payload_type("action");
let signed = sign(&pt, &stmt, signer.as_ref()).unwrap();
verifier.verify(&signed.envelope).unwrap();
cleanup(dir);
}
#[test]
fn list_keys() {
let (store, dir) = make_store();
store.generate(true).unwrap();
store.generate(false).unwrap();
let keys = store.list().unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys.iter().filter(|k| k.is_default).count(), 1);
cleanup(dir);
}
#[test]
fn no_default_key_errors() {
let (store, dir) = make_store();
assert!(store.default_signer().is_err());
cleanup(dir);
}
#[test]
fn rotate_mints_successor_and_links_predecessor() {
let (store, dir) = make_store();
let pred = store.generate(true).unwrap();
assert!(pred.valid_until.is_none(), "fresh key has no expiry");
assert!(pred.successor_key_id.is_none(), "fresh key has no successor");
let result = store
.rotate(None, std::time::Duration::from_secs(3600), true)
.unwrap();
assert_eq!(result.predecessor.id, pred.id);
assert!(result.predecessor.valid_until.is_some(),
"predecessor must get valid_until after rotation");
assert_eq!(result.predecessor.successor_key_id.as_deref(),
Some(result.successor.id.as_str()),
"predecessor must link forward to successor");
assert!(!result.predecessor.is_default,
"after rotation with set_default=true, predecessor is no longer default");
assert_ne!(result.successor.id, pred.id);
assert!(result.successor.valid_until.is_none(), "successor has no expiry yet");
assert!(result.successor.successor_key_id.is_none(), "successor is chain head");
assert!(result.successor.is_default, "successor is the new default");
let listed = store.list().unwrap();
assert_eq!(listed.len(), 2);
let pred_listed = listed.iter().find(|k| k.id == pred.id).unwrap();
assert!(pred_listed.valid_until.is_some());
assert_eq!(pred_listed.successor_key_id.as_deref(),
Some(result.successor.id.as_str()));
cleanup(dir);
}
#[test]
fn rotate_with_set_default_false_keeps_predecessor_active() {
let (store, dir) = make_store();
let pred = store.generate(true).unwrap();
let result = store
.rotate(None, std::time::Duration::from_secs(3600), false)
.unwrap();
assert!(result.predecessor.is_default);
assert!(!result.successor.is_default);
assert_eq!(store.default_key_id().unwrap(), pred.id);
cleanup(dir);
}
#[test]
fn rotate_predecessor_signing_still_works_during_grace_window() {
let (store, dir) = make_store();
let pred = store.generate(true).unwrap();
let _ = store
.rotate(None, std::time::Duration::from_secs(3600), true)
.unwrap();
let signer = store.signer(&pred.id).unwrap();
let pae = crate::attestation::pae("text/plain", b"grace-window-payload");
let sig = signer.sign(&pae).unwrap();
assert_eq!(sig.len(), 64);
cleanup(dir);
}
#[test]
fn rotate_refuses_to_rotate_already_rotated_key() {
let (store, dir) = make_store();
store.generate(true).unwrap();
let r1 = store
.rotate(None, std::time::Duration::from_secs(60), true)
.unwrap();
let err = store
.rotate(Some(&r1.predecessor.id),
std::time::Duration::from_secs(60),
true)
.unwrap_err();
match err {
KeyError::Crypto(msg) => assert!(
msg.contains("already been rotated"),
"error must explain why: {msg}"
),
other => panic!("expected Crypto error, got {other:?}"),
}
cleanup(dir);
}
#[test]
fn successor_chain_walks_forward() {
let (store, dir) = make_store();
let k0 = store.generate(true).unwrap();
let r1 = store
.rotate(None, std::time::Duration::from_secs(60), true)
.unwrap();
let r2 = store
.rotate(None, std::time::Duration::from_secs(60), true)
.unwrap();
let chain = store.successor_chain(&k0.id).unwrap();
assert_eq!(chain, vec![k0.id.clone(), r1.successor.id.clone(), r2.successor.id.clone()],
"chain must be ordered head -> tail");
let mid = store.successor_chain(&r1.successor.id).unwrap();
assert_eq!(mid, vec![r1.successor.id.clone(), r2.successor.id.clone()]);
let tail = store.successor_chain(&r2.successor.id).unwrap();
assert_eq!(tail, vec![r2.successor.id.clone()]);
cleanup(dir);
}
#[test]
fn valid_keys_at_filters_by_grace_window() {
let (store, dir) = make_store();
let _ = store.generate(true).unwrap();
let result = store
.rotate(None, std::time::Duration::from_secs(3600), true)
.unwrap();
let now = unix_now();
let valid_now = store.valid_keys_at(now).unwrap();
assert_eq!(valid_now.len(), 2, "both predecessor (in grace) and successor should be valid");
let after_grace = unix_now() + 7200;
let valid_after = store.valid_keys_at(after_grace).unwrap();
assert_eq!(valid_after.len(), 1,
"after grace window only successor remains valid");
assert_eq!(valid_after[0].id, result.successor.id);
cleanup(dir);
}
#[test]
fn rotate_cache_reflects_stamped_predecessor_for_retry_safety() {
let (store, dir) = make_store();
let pred = store.generate(true).unwrap();
let _ = store
.rotate(None, std::time::Duration::from_secs(60), true)
.unwrap();
let err = store
.rotate(Some(&pred.id),
std::time::Duration::from_secs(60),
true)
.unwrap_err();
match err {
KeyError::Crypto(msg) => assert!(
msg.contains("already been rotated"),
"cache should reflect stamped predecessor; got: {msg}"
),
other => panic!("expected Crypto error, got {other:?}"),
}
cleanup(dir);
}
#[test]
fn rotated_predecessor_pointing_at_missing_successor_surfaces_clear_error() {
let (store, dir) = make_store();
store.generate(true).unwrap();
let result = store
.rotate(None, std::time::Duration::from_secs(60), true)
.unwrap();
let succ_path = store.entry_path(&result.successor.id);
fs::remove_file(&succ_path).unwrap();
let store2 = Store::open(&dir).unwrap();
let err = store2.successor_chain(&result.predecessor.id).unwrap_err();
match err {
KeyError::NotFound(id) => assert_eq!(id, result.successor.id),
other => panic!("expected NotFound error, got {other:?}"),
}
cleanup(dir);
}
#[test]
fn legacy_entry_without_lifecycle_fields_loads() {
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let path = store.entry_path(&info.id);
let raw = fs::read(&path).unwrap();
let mut json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
let obj = json.as_object_mut().unwrap();
obj.remove("valid_until");
obj.remove("successor_key_id");
fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap();
let store2 = Store::open(&dir).unwrap();
let listed = store2.list().unwrap();
assert_eq!(listed.len(), 1);
assert!(listed[0].valid_until.is_none(),
"missing valid_until must default to None on legacy entry");
assert!(listed[0].successor_key_id.is_none(),
"missing successor_key_id must default to None on legacy entry");
let signer = store2.default_signer().unwrap();
assert_eq!(signer.key_id(), info.id);
cleanup(dir);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
#[cfg(unix)]
fn write_entry_creates_file_with_0600() {
use std::os::unix::fs::PermissionsExt;
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let mode = fs::metadata(store.entry_path(&info.id))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "freshly written key file must be 0600, got {:o}", mode);
cleanup(dir);
}
#[test]
#[cfg(unix)]
fn signer_refuses_world_readable_key() {
use std::os::unix::fs::PermissionsExt;
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let path = store.entry_path(&info.id);
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
match store.signer(&info.id) {
Err(KeyError::InsecureKeyPerms { path: p, mode }) => {
assert_eq!(p, path);
assert_eq!(mode & 0o777, 0o644);
}
other => panic!("expected InsecureKeyPerms, got {:?}", other.map(|_| "ok")),
}
cleanup(dir);
}
#[test]
#[cfg(unix)]
fn signer_bypass_via_env_var() {
use std::os::unix::fs::PermissionsExt;
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let path = store.entry_path(&info.id);
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
std::env::set_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS", "1");
let result = store.signer(&info.id);
std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
assert!(
result.is_ok(),
"bypass env var must allow signing: {:?}",
result.err()
);
cleanup(dir);
}
#[test]
#[cfg(unix)]
fn signer_rejects_post_check_swap() {
use std::os::unix::fs::PermissionsExt;
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let path = store.entry_path(&info.id);
let original_bytes = fs::read(&path).unwrap();
assert!(!original_bytes.is_empty(), "test sanity");
fs::write(&path, &original_bytes).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
match store.signer(&info.id) {
Err(KeyError::InsecureKeyPerms { path: p, mode }) => {
assert_eq!(p, path);
assert_eq!(mode & 0o777, 0o644);
}
Err(other) => panic!(
"expected InsecureKeyPerms from single-open fstat gate, got {:?}",
other
),
Ok(_) => panic!(
"expected InsecureKeyPerms from single-open fstat gate, got ok signer"
),
}
let direct = store.read_entry_with_perm_check(&info.id);
assert!(
matches!(direct, Err(KeyError::InsecureKeyPerms { .. })),
"read_entry_with_perm_check must reject before reading bytes; got {:?}",
direct.map(|_| "ok")
);
cleanup(dir);
}
#[test]
fn concurrent_migration_serializes_correctly() {
use std::sync::Arc;
use std::thread;
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let entry_path = store.entry_path(&info.id);
let v2_entry: EncryptedEntry =
serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
let secret = decrypt_from_disk(
&store.machine_key,
&v2_entry.id,
&v2_entry.public_key,
&v2_entry.enc_priv_key,
&v2_entry.nonce,
)
.unwrap();
let (legacy_blob, legacy_nonce) =
legacy_v1_encrypt(&store.machine_key, &secret).unwrap();
let legacy_entry = EncryptedEntry {
id: v2_entry.id.clone(),
algorithm: v2_entry.algorithm.clone(),
created_at: v2_entry.created_at.clone(),
public_key: v2_entry.public_key.clone(),
enc_priv_key: legacy_blob,
nonce: legacy_nonce,
valid_until: v2_entry.valid_until.clone(),
successor_key_id: v2_entry.successor_key_id.clone(),
};
fs::write(&entry_path, serde_json::to_vec_pretty(&legacy_entry).unwrap()).unwrap();
let dir_a = Arc::new(dir.clone());
let dir_b = Arc::new(dir.clone());
let id_a = info.id.clone();
let id_b = info.id.clone();
let h1 = thread::spawn(move || -> Result<(), String> {
let s = Store::open(&*dir_a).map_err(|e| e.to_string())?;
let _signer = s.signer(&id_a).map_err(|e| e.to_string())?;
Ok(())
});
let h2 = thread::spawn(move || -> Result<(), String> {
let s = Store::open(&*dir_b).map_err(|e| e.to_string())?;
let _signer = s.signer(&id_b).map_err(|e| e.to_string())?;
Ok(())
});
h1.join().unwrap().expect("thread 1 signer load must succeed");
h2.join().unwrap().expect("thread 2 signer load must succeed");
let after: EncryptedEntry =
serde_json::from_slice(&fs::read(&entry_path).unwrap()).unwrap();
assert!(
!is_legacy_v1(&after.enc_priv_key),
"post-concurrent-migration entry must be in v2 format"
);
assert_eq!(after.enc_priv_key[0], KEYSTORE_MAGIC);
assert_eq!(after.enc_priv_key[1], KEYSTORE_VERSION_V2);
let dec = decrypt_v2(
&store.machine_key,
&after.id,
&after.public_key,
&after.enc_priv_key,
)
.expect("v2 entry must decrypt cleanly after concurrent migration");
assert_eq!(dec.len(), 32, "decrypted secret must be a 32-byte ed25519 scalar");
for entry in fs::read_dir(&dir).unwrap() {
let p = entry.unwrap().path();
assert!(
p.extension().is_none_or(|e| e != "tmp"),
"no .tmp fragment must remain after migration, found: {}",
p.display()
);
}
cleanup(dir);
}
#[test]
#[cfg(unix)]
fn atomic_write_leaves_original_intact_on_partial_failure() {
use std::os::unix::fs::PermissionsExt;
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let entry_path = store.entry_path(&info.id);
let original = fs::read(&entry_path).expect("entry file must exist");
assert!(!original.is_empty(), "freshly generated entry must be non-empty");
let orig_dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o500)).unwrap();
let res = write_file_600(&entry_path, b"new junk that must not land");
assert!(res.is_err(), "write_file_600 must fail when dir is read-only");
fs::set_permissions(&dir, fs::Permissions::from_mode(orig_dir_mode)).unwrap();
let after = fs::read(&entry_path).expect("entry file must still exist after failed write");
assert_eq!(
after, original,
"failed atomic write must not corrupt the original file",
);
let store2 = Store::open(&dir).unwrap();
let signer = store2
.signer(&info.id)
.expect("original key must still decrypt after a failed write");
let pae = crate::attestation::pae("text/plain", b"survive");
assert_eq!(signer.sign(&pae).unwrap().len(), 64);
let tmp = entry_path.with_extension("tmp");
assert!(!tmp.exists(), "tmp file must be cleaned up after rename failure");
cleanup(dir);
}
#[test]
#[cfg(unix)]
fn mode_is_600_at_creation() {
use std::os::unix::fs::PermissionsExt;
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let entry_path = store.entry_path(&info.id);
let mode = fs::metadata(&entry_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "entry file must be 0600 at creation, got {:o}", mode);
let tmp = entry_path.with_extension("tmp");
assert!(
!tmp.exists(),
"no .tmp file must be left behind after a successful atomic write"
);
cleanup(dir);
}
#[test]
#[cfg(unix)]
fn fix_perms_repairs_loose_modes() {
use std::os::unix::fs::PermissionsExt;
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let key_path = store.entry_path(&info.id);
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
let changes = store.fix_perms().unwrap();
assert!(
changes.iter().any(|(p, _, _)| p == &dir),
"dir should be repaired"
);
assert!(
changes.iter().any(|(p, _, _)| p == &key_path),
"key file should be repaired"
);
let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
let key_mode = fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(key_mode, 0o600);
store.signer(&info.id).expect("signing must work after fix_perms");
cleanup(dir);
}
#[test]
fn cross_entry_swap_fails_decryption() {
let (store, dir) = make_store();
let a = store.generate(true).unwrap();
let b = store.generate(false).unwrap();
let path_a = store.entry_path(&a.id);
let path_b = store.entry_path(&b.id);
let entry_a: EncryptedEntry =
serde_json::from_slice(&fs::read(&path_a).unwrap()).unwrap();
let entry_b: EncryptedEntry =
serde_json::from_slice(&fs::read(&path_b).unwrap()).unwrap();
assert_eq!(entry_a.enc_priv_key[0], KEYSTORE_MAGIC);
assert_eq!(entry_a.enc_priv_key[1], KEYSTORE_VERSION_V2);
assert_eq!(entry_b.enc_priv_key[0], KEYSTORE_MAGIC);
assert_eq!(entry_b.enc_priv_key[1], KEYSTORE_VERSION_V2);
assert_ne!(
entry_a.enc_priv_key, entry_b.enc_priv_key,
"two freshly-generated entries must have distinct ciphertexts"
);
let mut tampered_a = entry_a.clone();
tampered_a.enc_priv_key = entry_b.enc_priv_key.clone();
fs::write(&path_a, serde_json::to_vec_pretty(&tampered_a).unwrap()).unwrap();
let store2 = Store::open(&dir).unwrap();
let err = match store2.signer(&a.id) {
Ok(_) => panic!(
"swapping B's ciphertext into A's envelope must fail decrypt; \
got Ok which means the signer would silently sign with key B"
),
Err(e) => e,
};
match err {
KeyError::Crypto(msg) => assert!(
msg.contains("MAC verification failed"),
"swap must surface MAC failure; got: {msg}"
),
other => panic!("expected Crypto MAC error, got: {other:?}"),
}
cleanup(dir);
}
#[test]
fn aad_tampered_entry_id_fails_decryption() {
let (store, dir) = make_store();
let info = store.generate(true).unwrap();
let path = store.entry_path(&info.id);
let mut entry: EncryptedEntry =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(entry.id, info.id, "sanity: id matches what generate returned");
entry.id = "key_attacker_substituted_id".to_string();
fs::write(&path, serde_json::to_vec_pretty(&entry).unwrap()).unwrap();
let store2 = Store::open(&dir).unwrap();
let key_buf = store2.machine_key;
let result = decrypt_from_disk(
&key_buf,
&entry.id, &entry.public_key, &entry.enc_priv_key,
&entry.nonce,
);
assert!(
result.is_err(),
"AAD-bound entry id mismatch must fail decrypt; got Ok"
);
cleanup(dir);
}
}