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 const MAX_BLOCKS: usize = 32;
const BEGIN: &str = "-----BEGIN SSH SIGNATURE-----";
const END: &str = "-----END SSH SIGNATURE-----";
pub fn platform() -> String {
format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
}
pub struct Verdict {
pub gates: Vec<String>,
pub trail: Vec<String>,
}
impl Verdict {
fn nothing(reason: impl Into<String>) -> Self {
Verdict {
gates: Vec::new(),
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()?;
if !sig.ends_with('\n') {
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,
}
}
#[derive(Debug, PartialEq)]
pub struct Block {
pub payload: String,
pub signature: String,
}
pub fn split_blocks(body: &str) -> (Vec<Block>, bool) {
enum State {
Between,
Payload,
WaitingForSignature,
Signature,
}
let mut blocks = Vec::new();
let mut state = State::Between;
let mut payload = String::new();
let mut signature = String::new();
let close = |blocks: &mut Vec<Block>, payload: &str, signature: &str| -> bool {
if blocks.len() == MAX_BLOCKS {
return true;
}
blocks.push(Block {
payload: payload.to_string(),
signature: signature.to_string(),
});
false
};
for line in body.split('\n') {
match state {
State::Between => {
if line.is_empty() {
continue;
}
payload.clear();
payload.push_str(line);
payload.push('\n');
state = State::Payload;
}
State::Payload => {
if line.is_empty() {
state = State::WaitingForSignature;
} else {
payload.push_str(line);
payload.push('\n');
}
}
State::WaitingForSignature => {
if line.is_empty() {
continue;
}
if line != BEGIN {
return (blocks, false);
}
signature.clear();
signature.push_str(line);
signature.push('\n');
state = State::Signature;
}
State::Signature => {
signature.push_str(line);
signature.push('\n');
if line == END {
if close(&mut blocks, &payload, &signature) {
return (blocks, true);
}
state = State::Between;
}
}
}
}
if let State::Signature = state {
if close(&mut blocks, &payload, &signature) {
return (blocks, true);
}
}
(blocks, false)
}
#[cfg(test)]
pub fn split_note(body: &str) -> Option<(String, String)> {
split_blocks(body)
.0
.into_iter()
.next()
.map(|b| (b.payload, b.signature))
}
pub fn platform_matches(want: Option<&str>, ran_on: &str) -> bool {
match want {
None => true,
Some(want) if want.contains('-') => want == ran_on,
Some(want) => ran_on.rsplit_once('-').is_some_and(|(_, os)| os == want),
}
}
fn union<'a>(into: &mut Vec<String>, names: impl Iterator<Item = &'a str>) {
for name in names {
if !into.iter().any(|g| g == name) {
into.push(name.to_string());
}
}
}
fn field<'a>(payload: &'a str, name: &str) -> Option<&'a str> {
payload
.split('\n')
.find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
.map(|v| v.trim_start_matches(' '))
}
pub fn evaluate(
signers: &Path,
principal: Option<&str>,
require_platform: Option<&str>,
anywhere: &[String],
) -> 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: Vec::new(),
trail,
};
};
let mut covered: Vec<String> = Vec::new();
let mut seen: Vec<String> = Vec::new();
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(listing) = git::stdout(&["notes", "--ref", NOTES_REF, "list", &object]) else {
continue;
};
let note_oid = listing.split_whitespace().next().unwrap_or("").to_string();
if seen.contains(¬e_oid) {
continue;
}
seen.push(note_oid);
let Some(body) = git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
continue;
};
tried = true;
if body.contains('\r') {
push_reason(
&mut trail,
format!("note on {candidate} contains carriage returns; the format is LF-only"),
);
continue;
}
let (blocks, truncated) = split_blocks(&body);
if truncated {
push_reason(
&mut trail,
format!(
"note on {candidate} has more than {MAX_BLOCKS} blocks; the rest were ignored"
),
);
}
if blocks.is_empty() {
push_reason(
&mut trail,
format!("note on {candidate} carries no signature block"),
);
continue;
}
let several = blocks.len() > 1;
for (i, block) in blocks.iter().enumerate() {
let at = if several {
format!("{candidate} block {}", i + 1)
} else {
candidate.to_string()
};
let payload = &block.payload;
if payload.split('\n').next() != Some(FORMAT) {
push_reason(
&mut trail,
format!("note on {at} 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 {at} 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;
}
let signer = match verify(payload, &block.signature, signers, principal) {
Ok(signer) => signer,
Err(why) => {
push_reason(&mut trail, format!("note on {at}: {why}"));
continue;
}
};
if platform_matches(require_platform, ran_on) {
union(&mut covered, gates.split_whitespace());
push_reason(
&mut trail,
format!("covered by {signer} on {ran_on}: {gates}"),
);
continue;
}
let accepted: Vec<&str> = gates
.split_whitespace()
.filter(|g| anywhere.iter().any(|a| a == g))
.collect();
union(&mut covered, accepted.iter().copied());
let want = require_platform.unwrap_or("any");
let mut reason = format!("attested on {ran_on} by {signer}, this leg is {want}");
if !accepted.is_empty() {
reason.push_str("; accepted anywhere: ");
reason.push_str(&accepted.join(" "));
}
push_reason(&mut trail, reason);
}
}
if !tried {
push_reason(
&mut trail,
format!("no attestation found for tree {head_tree}"),
);
}
Verdict {
gates: covered,
trail,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIG: &str = "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----";
fn block(n: u32) -> String {
format!("amont-attest-v2\ntree t{n}\ngates g{n}\nplatform p{n}\n\n{SIG}")
}
#[test]
fn platform_matching_is_exact_or_os_only_never_a_substring() {
assert!(platform_matches(None, "s390x-aix"));
assert!(platform_matches(Some("x86_64-linux"), "x86_64-linux"));
assert!(!platform_matches(Some("x86_64-linux"), "aarch64-linux"));
assert!(platform_matches(Some("linux"), "x86_64-linux"));
assert!(platform_matches(Some("linux"), "aarch64-linux"));
assert!(!platform_matches(Some("inux"), "x86_64-linux"));
assert!(!platform_matches(Some("x86_64"), "x86_64-linux"));
assert!(!platform_matches(Some("linux"), "linux"));
}
#[test]
fn one_block_restores_the_signed_trailing_newline() {
let (blocks, truncated) = split_blocks(&block(1));
assert!(!truncated);
assert_eq!(blocks.len(), 1);
assert_eq!(
blocks[0].payload,
"amont-attest-v2\ntree t1\ngates g1\nplatform p1\n"
);
assert_eq!(blocks[0].signature, format!("{SIG}\n"));
}
#[test]
fn two_blocks_as_git_notes_append_writes_them() {
let body = format!("{}\n\n{}", block(1), block(2));
let (blocks, _) = split_blocks(&body);
assert_eq!(blocks.len(), 2);
assert!(blocks[1].payload.starts_with("amont-attest-v2\ntree t2\n"));
let body = format!("{}\n\n\n\n{}\n", block(1), block(2));
assert_eq!(split_blocks(&body).0.len(), 2);
}
#[test]
fn end_of_input_closes_an_open_signature() {
let body = "amont-attest-v2\ntree abc\n\n-----BEGIN SSH SIGNATURE-----\nx";
let (payload, sig) = split_note(body).unwrap();
assert_eq!(payload, "amont-attest-v2\ntree abc\n");
assert_eq!(sig, "-----BEGIN SSH SIGNATURE-----\nx\n");
}
#[test]
fn framing_errors_stop_parsing_and_keep_what_came_before() {
assert!(split_note("no blank line here").is_none());
assert!(split_note("payload\n\nnot a signature").is_none());
let body = format!("{}\n\npayload2\n\nnot a signature", block(1));
assert_eq!(split_blocks(&body).0.len(), 1);
let body = format!("p\n\n-----BEGIN SSH SIGNATURE-----\nx\n\n{}", block(2));
let (blocks, _) = split_blocks(&body);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].signature.contains("tree t2"));
let body = format!("{}\n\ngarbage", block(1));
assert_eq!(split_blocks(&body).0.len(), 1);
}
#[test]
fn a_crlf_note_has_no_blank_line_and_therefore_no_block() {
let body = block(1).replace('\n', "\r\n");
assert!(split_blocks(&body).0.is_empty());
}
#[test]
fn at_most_max_blocks_are_read() {
let body: Vec<String> = (0..40).map(block).collect();
let (blocks, truncated) = split_blocks(&body.join("\n\n"));
assert_eq!(blocks.len(), MAX_BLOCKS);
assert!(truncated);
let body: Vec<String> = (0..MAX_BLOCKS as u32).map(block).collect();
let (blocks, truncated) = split_blocks(&body.join("\n\n"));
assert_eq!(blocks.len(), MAX_BLOCKS);
assert!(!truncated);
}
#[test]
fn union_keeps_first_appearance_order_without_duplicates() {
let mut v = vec!["a".to_string()];
union(&mut v, "b a c b".split_whitespace());
assert_eq!(v, ["a", "b", "c"]);
}
}