use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
struct SigFile(PathBuf);
impl SigFile {
fn new(sig: &str) -> Option<Self> {
use std::io::Write;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"attest-verify-{}-{:p}-{nonce}.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_principals(sig_file: &Path, allowed_signers: &Path) -> Vec<String> {
let out = Command::new("ssh-keygen")
.args(["-Y", "find-principals", "-s"])
.arg(sig_file)
.arg("-f")
.arg(allowed_signers)
.stderr(Stdio::null())
.output();
let Ok(out) = out else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
TempFile,
UnknownSigner { signers: PathBuf },
DoesNotVerify { signer: String, signers: PathBuf },
PlatformBound {
signer: String,
bound: String,
platform: Option<String>,
},
KeyUnusable,
NotEd25519 { kind: String },
SignFailed,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::TempFile => write!(f, "cannot create a temporary file for the signature"),
Error::UnknownSigner { signers } => write!(
f,
"signature was not made by any key in {}",
signers.display()
),
Error::DoesNotVerify { signer, signers } => write!(
f,
"signature does not verify as {signer} against {}",
signers.display()
),
Error::PlatformBound {
signer,
bound,
platform,
} => match platform {
Some(platform) => write!(
f,
"{signer} may sign only {bound} blocks; this block is {platform}"
),
None => write!(
f,
"{signer} may sign only {bound} blocks; this block names no platform"
),
},
Error::KeyUnusable => write!(
f,
"not a usable unencrypted OpenSSH private key (passphrase-protected? a .pub pasted by mistake?)"
),
Error::NotEd25519 { kind } => {
write!(f, "only ed25519 keys are supported (this one is {kind})")
}
Error::SignFailed => write!(f, "ssh-keygen could not sign"),
}
}
}
impl std::error::Error for Error {}
pub fn verify(
namespace: &str,
payload: &str,
sig: &str,
allowed_signers: &Path,
principal: Option<&str>,
) -> Result<String, Error> {
use std::io::Write;
let Some(sig_file) = SigFile::new(sig) else {
return Err(Error::TempFile);
};
let principals = match principal {
Some(p) => vec![p.to_string()],
None => find_principals(&sig_file.0, allowed_signers),
};
let Some(signer) = principals.first().cloned() else {
return Err(Error::UnknownSigner {
signers: allowed_signers.to_path_buf(),
});
};
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 {
let platform = crate::format::field(payload, "platform");
for candidate in &principals {
if let Some(bound) = crate::format::bound_platform(candidate) {
if platform != Some(bound) {
let bound = bound.to_string();
return Err(Error::PlatformBound {
signer: candidate.clone(),
bound,
platform: platform.map(str::to_string),
});
}
}
}
Ok(signer)
} else {
Err(Error::DoesNotVerify {
signer,
signers: allowed_signers.to_path_buf(),
})
}
}
pub fn public_key(key: &Path) -> Result<String, Error> {
let out = Command::new("ssh-keygen")
.args(["-y", "-P", "", "-f"])
.arg(key)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|_| Error::KeyUnusable)?;
if !out.status.success() {
return Err(Error::KeyUnusable);
}
let public = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !public.starts_with("ssh-ed25519 ") {
let kind = public.split(' ').next().unwrap_or("").to_string();
return Err(Error::NotEd25519 { kind });
}
Ok(public)
}
pub fn sign(namespace: &str, payload: &str, key: &Path) -> Result<String, Error> {
use std::io::Write;
let mut child = Command::new("ssh-keygen")
.args(["-Y", "sign", "-n", namespace, "-f"])
.arg(key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|_| Error::SignFailed)?;
child
.stdin
.take()
.ok_or(Error::SignFailed)?
.write_all(payload.as_bytes())
.map_err(|_| Error::SignFailed)?;
let out = child.wait_with_output().map_err(|_| Error::SignFailed)?;
let sig = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
if out.status.success() && sig.starts_with(crate::format::BEGIN) {
Ok(sig)
} else {
Err(Error::SignFailed)
}
}