use std::path::PathBuf;
use anyhow::{Context, bail};
use astrid_core::dirs::AstridHome;
use astrid_crypto::PublicKey;
use super::lock::DistroLock;
use super::sign;
const OFFICIAL_KEYS: &[&str] = &[
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TrustAction {
PinnedMatch,
OfficialPinned,
ToFuTrusted,
NewKeyAccepted,
}
#[derive(Debug)]
pub(crate) struct TrustOutcome {
pub(crate) pubkey: PublicKey,
pub(crate) key_str: String,
pub(crate) action: TrustAction,
}
fn trust_path(home: &AstridHome, distro_id: &str) -> PathBuf {
home.root().join("trust").join(format!("{distro_id}.pub"))
}
fn read_pinned(home: &AstridHome, distro_id: &str) -> anyhow::Result<Option<PublicKey>> {
let path = trust_path(home, distro_id);
match std::fs::read_to_string(&path) {
Ok(s) => {
let line = s.trim();
let pk = sign::parse_pubkey(line)
.with_context(|| format!("corrupt trust file {}", path.display()))?;
Ok(Some(pk))
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
fn pin(home: &AstridHome, distro_id: &str, key_str: &str) -> anyhow::Result<()> {
let path = trust_path(home, distro_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap_or(home.root()))
.context("failed to create temp file for trust pin")?;
std::io::Write::write_all(&mut tmp, format!("{key_str}\n").as_bytes())
.context("failed to write trust pin staging")?;
match std::fs::remove_file(&path) {
Ok(()) => {},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
Err(e) => {
return Err(e).with_context(|| {
format!("failed to replace existing trust pin {}", path.display())
});
},
}
tmp.persist(&path)
.map_err(|e| anyhow::anyhow!("failed to persist {}: {e}", path.display()))?;
Ok(())
}
fn is_official(key_str: &str) -> bool {
OFFICIAL_KEYS.contains(&key_str)
}
pub(crate) fn verify_and_pin(
home: &AstridHome,
distro_id: &str,
manifest_pubkey: &str,
sig_hex: &str,
lock: &DistroLock,
accept_new_key: bool,
) -> anyhow::Result<TrustOutcome> {
let pubkey = sign::parse_pubkey(manifest_pubkey)?;
let key_str = sign::pubkey_to_wire(&pubkey);
sign::verify_lock(lock, sig_hex, &pubkey)
.context("distro signature is invalid — refusing to install")?;
let pinned = read_pinned(home, distro_id)?;
let action = match pinned {
Some(pin_key) if pin_key == pubkey => TrustAction::PinnedMatch,
Some(pin_key) => {
if !accept_new_key {
bail!(
"distro '{distro_id}' is pinned to {} but this artifact is signed by {} — \
refusing. Re-run with --accept-new-key only if you trust the new key.",
sign::pubkey_to_wire(&pin_key),
key_str,
);
}
pin(home, distro_id, &key_str)?;
TrustAction::NewKeyAccepted
},
None if is_official(&key_str) => {
pin(home, distro_id, &key_str)?;
TrustAction::OfficialPinned
},
None => {
pin(home, distro_id, &key_str)?;
TrustAction::ToFuTrusted
},
};
audit_trust(distro_id, &key_str, action);
Ok(TrustOutcome {
pubkey,
key_str,
action,
})
}
fn audit_trust(distro_id: &str, key_str: &str, action: TrustAction) {
tracing::info!(
target: "astrid.audit.distro_trust",
distro = %distro_id,
key = %key_str,
action = ?action,
"distro signing-key trust decision",
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::distro::lock::{DistroLock, DistroLockMeta, LockedCapsule};
use astrid_crypto::KeyPair;
fn home() -> (tempfile::TempDir, AstridHome) {
let dir = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(dir.path());
(dir, home)
}
fn sample_lock() -> DistroLock {
DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "1970-01-01T00:00:00+00:00".into(),
},
capsules: vec![LockedCapsule {
name: "cli".into(),
version: "0.1.0".into(),
source: "@org/cli".into(),
hash: "blake3:abc".into(),
resolved_ref: Some("v0.1.0".into()),
}],
manifest_hash: Some("blake3:def".into()),
}
}
#[test]
fn tofu_pins_on_first_use() {
let (_d, home) = home();
let kp = KeyPair::generate();
let pubkey = sign::pubkey_to_wire(&kp.export_public_key());
let lock = sample_lock();
let sig = sign::sign_lock(&lock, &kp).unwrap();
let out = verify_and_pin(&home, "test", &pubkey, &sig, &lock, false).unwrap();
assert_eq!(out.action, TrustAction::ToFuTrusted);
assert_eq!(
read_pinned(&home, "test").unwrap().unwrap(),
kp.export_public_key()
);
}
#[test]
fn pinned_match_proceeds() {
let (_d, home) = home();
let kp = KeyPair::generate();
let pubkey = sign::pubkey_to_wire(&kp.export_public_key());
let lock = sample_lock();
let sig = sign::sign_lock(&lock, &kp).unwrap();
verify_and_pin(&home, "test", &pubkey, &sig, &lock, false).unwrap();
let out = verify_and_pin(&home, "test", &pubkey, &sig, &lock, false).unwrap();
assert_eq!(out.action, TrustAction::PinnedMatch);
}
#[test]
fn wrong_pinned_key_hard_fails_without_override() {
let (_d, home) = home();
let kp = KeyPair::generate();
let lock = sample_lock();
let sig = sign::sign_lock(&lock, &kp).unwrap();
verify_and_pin(
&home,
"test",
&sign::pubkey_to_wire(&kp.export_public_key()),
&sig,
&lock,
false,
)
.unwrap();
let kp2 = KeyPair::generate();
let sig2 = sign::sign_lock(&lock, &kp2).unwrap();
let err = verify_and_pin(
&home,
"test",
&sign::pubkey_to_wire(&kp2.export_public_key()),
&sig2,
&lock,
false,
)
.unwrap_err();
assert!(err.to_string().contains("pinned"), "got: {err}");
}
#[test]
fn new_key_accepted_with_override() {
let (_d, home) = home();
let kp = KeyPair::generate();
let lock = sample_lock();
let sig = sign::sign_lock(&lock, &kp).unwrap();
verify_and_pin(
&home,
"test",
&sign::pubkey_to_wire(&kp.export_public_key()),
&sig,
&lock,
false,
)
.unwrap();
let kp2 = KeyPair::generate();
let sig2 = sign::sign_lock(&lock, &kp2).unwrap();
let out = verify_and_pin(
&home,
"test",
&sign::pubkey_to_wire(&kp2.export_public_key()),
&sig2,
&lock,
true,
)
.unwrap();
assert_eq!(out.action, TrustAction::NewKeyAccepted);
assert_eq!(
read_pinned(&home, "test").unwrap().unwrap(),
kp2.export_public_key()
);
}
#[test]
fn invalid_signature_fails_regardless() {
let (_d, home) = home();
let kp = KeyPair::generate();
let lock = sample_lock();
let sig = sign::sign_lock(&lock, &kp).unwrap();
let mut tampered = sample_lock();
tampered.capsules[0].hash = "blake3:TAMPERED".into();
let err = verify_and_pin(
&home,
"test",
&sign::pubkey_to_wire(&kp.export_public_key()),
&sig,
&tampered,
true, )
.unwrap_err();
assert!(err.to_string().contains("invalid"), "got: {err}");
}
}