use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::git;
pub const FORMAT: &str = "amont-attest-v2";
pub const NOTES_REF: &str = "amont-attest";
pub const NAMESPACE: &str = "amont-attest";
pub fn platform() -> String {
format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
}
pub struct Verdict {
pub gates: Option<Vec<String>>,
pub trail: Vec<String>,
}
impl Verdict {
fn nothing(reason: impl Into<String>) -> Self {
Verdict {
gates: None,
trail: vec![reason.into()],
}
}
}
fn push_reason(trail: &mut Vec<String>, reason: String) {
if trail.last() != Some(&reason) {
trail.push(reason);
}
}
struct SigFile(PathBuf);
impl SigFile {
fn new(sig: &str) -> Option<Self> {
use std::io::Write;
let path = std::env::temp_dir().join(format!(
"attest-verify-{}-{:p}.sig",
std::process::id(),
&sig
));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.ok()?;
f.write_all(sig.as_bytes()).ok()?;
f.write_all(b"\n").ok()?;
Some(SigFile(path))
}
}
impl Drop for SigFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn find_principal(sig_file: &Path, allowed_signers: &Path) -> Option<String> {
let out = Command::new("ssh-keygen")
.args(["-Y", "find-principals", "-s"])
.arg(sig_file)
.arg("-f")
.arg(allowed_signers)
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
pub fn verify(
payload: &str,
sig: &str,
allowed_signers: &Path,
principal: Option<&str>,
) -> Result<String, String> {
use std::io::Write;
let Some(sig_file) = SigFile::new(sig) else {
return Err("cannot create a temporary file for the signature".into());
};
let signer = match principal {
Some(p) => p.to_string(),
None => find_principal(&sig_file.0, allowed_signers).ok_or_else(|| {
format!(
"signature was not made by any key in {}",
allowed_signers.display()
)
})?,
};
let ok = (|| {
let mut child = Command::new("ssh-keygen")
.args(["-Y", "verify", "-n", NAMESPACE, "-I", &signer, "-f"])
.arg(allowed_signers)
.arg("-s")
.arg(&sig_file.0)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
child.wait().ok().map(|s| s.success())
})()
.unwrap_or(false);
if ok {
Ok(signer)
} else {
Err(format!(
"signature does not verify as {signer} against {}",
allowed_signers.display()
))
}
}
fn fetch_notes(trail: &mut Vec<String>) {
let refspec = format!("+refs/notes/{NOTES_REF}:refs/notes/{NOTES_REF}");
if git::succeeds(&["fetch", "origin", &refspec]) {
return;
}
let remote_ref = format!("refs/notes/{NOTES_REF}");
if git::exit_code(&["ls-remote", "--exit-code", "origin", &remote_ref]) == Some(2) {
return;
}
if git::succeeds(&["rev-parse", "--verify", "--quiet", &remote_ref]) {
return;
}
push_reason(
trail,
format!(
"cannot fetch refs/notes/{NOTES_REF} from origin and no local copy exists \
(no credentials on the checkout? remote not named origin?)"
),
);
}
pub fn default_signers() -> Option<PathBuf> {
let root = git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
[".forgejo/allowed_signers", ".github/allowed_signers"]
.into_iter()
.map(|rel| match &root {
Some(root) => root.join(rel),
None => PathBuf::from(rel),
})
.find(|p| p.exists())
}
pub fn resolve_signers(given: &str) -> PathBuf {
let path = PathBuf::from(given);
if path.is_absolute() {
return path;
}
match git::stdout(&["rev-parse", "--show-toplevel"]) {
Some(root) => PathBuf::from(root).join(path),
None => path,
}
}
pub fn split_note(body: &str) -> Option<(String, String)> {
let (payload, sig) = body.split_once("\n\n")?;
if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
return None;
}
Some((format!("{payload}\n"), sig.to_string()))
}
fn field<'a>(payload: &'a str, name: &str) -> Option<&'a str> {
payload
.lines()
.find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
.map(str::trim)
}
pub fn evaluate(
signers: &Path,
principal: Option<&str>,
require_platform: Option<&str>,
) -> Verdict {
let mut trail = Vec::new();
if !signers.is_file() {
return Verdict::nothing(format!("{} does not exist", signers.display()));
}
fetch_notes(&mut trail);
let Some(head_tree) = git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
push_reason(
&mut trail,
"cannot resolve HEAD^{tree} — not a git repository?".into(),
);
return Verdict { gates: None, trail };
};
let mut tried = false;
for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
let Some(object) = git::stdout(&["rev-parse", "--verify", "--quiet", candidate]) else {
continue;
};
let Some(body) = git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
continue;
};
tried = true;
let Some((payload, sig)) = split_note(&body) else {
push_reason(
&mut trail,
format!("note on {candidate} carries no signature block"),
);
continue;
};
if payload.lines().next() != Some(FORMAT) {
push_reason(
&mut trail,
format!("note on {candidate} is not {FORMAT} — a newer producer wrote it"),
);
continue;
}
let (Some(tree), Some(gates), Some(ran_on)) = (
field(&payload, "tree"),
field(&payload, "gates"),
field(&payload, "platform"),
) else {
push_reason(
&mut trail,
format!("note on {candidate} is missing a required field"),
);
continue;
};
if tree != head_tree {
push_reason(
&mut trail,
format!("attested tree {tree} is not the checked-out tree {head_tree}"),
);
continue;
}
if gates.is_empty() {
push_reason(
&mut trail,
"attestation lists no gates — a signed way of saying nothing".into(),
);
continue;
}
if let Some(want) = require_platform {
if want != ran_on {
push_reason(
&mut trail,
format!("attested on {ran_on}, this leg is {want}"),
);
continue;
}
}
match verify(&payload, &sig, signers, principal) {
Ok(signer) => {
push_reason(&mut trail, format!("covered by {signer}: {gates}"));
return Verdict {
gates: Some(gates.split_whitespace().map(str::to_string).collect()),
trail,
};
}
Err(why) => push_reason(&mut trail, format!("note on {candidate}: {why}")),
}
}
if !tried {
push_reason(
&mut trail,
format!("no attestation found for tree {head_tree}"),
);
}
Verdict { gates: None, trail }
}