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_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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
TempFile,
UnknownSigner { signers: PathBuf },
DoesNotVerify { signer: String, signers: PathBuf },
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::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 signer = match principal {
Some(p) => p.to_string(),
None => {
find_principal(&sig_file.0, allowed_signers).ok_or_else(|| 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 {
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)
}
}