1use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8struct SigFile(PathBuf);
11
12impl SigFile {
13 fn new(sig: &str) -> Option<Self> {
14 use std::io::Write;
15 let nonce = std::time::SystemTime::now()
18 .duration_since(std::time::UNIX_EPOCH)
19 .map(|d| d.subsec_nanos())
20 .unwrap_or(0);
21 let path = std::env::temp_dir().join(format!(
22 "attest-verify-{}-{:p}-{nonce}.sig",
23 std::process::id(),
24 &sig
25 ));
26 let mut f = std::fs::OpenOptions::new()
29 .write(true)
30 .create_new(true)
31 .open(&path)
32 .ok()?;
33 f.write_all(sig.as_bytes()).ok()?;
34 if !sig.ends_with('\n') {
35 f.write_all(b"\n").ok()?;
36 }
37 Some(SigFile(path))
38 }
39}
40
41impl Drop for SigFile {
42 fn drop(&mut self) {
43 let _ = std::fs::remove_file(&self.0);
44 }
45}
46
47fn find_principal(sig_file: &Path, allowed_signers: &Path) -> Option<String> {
56 let out = Command::new("ssh-keygen")
57 .args(["-Y", "find-principals", "-s"])
58 .arg(sig_file)
59 .arg("-f")
60 .arg(allowed_signers)
61 .stderr(Stdio::null())
62 .output()
63 .ok()?;
64 if !out.status.success() {
65 return None;
66 }
67 String::from_utf8_lossy(&out.stdout)
68 .lines()
69 .map(str::trim)
70 .find(|l| !l.is_empty())
71 .map(str::to_string)
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum Error {
79 TempFile,
81 UnknownSigner { signers: PathBuf },
83 DoesNotVerify { signer: String, signers: PathBuf },
85 KeyUnusable,
87 NotEd25519 { kind: String },
90 SignFailed,
92}
93
94impl std::fmt::Display for Error {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Error::TempFile => write!(f, "cannot create a temporary file for the signature"),
98 Error::UnknownSigner { signers } => write!(
99 f,
100 "signature was not made by any key in {}",
101 signers.display()
102 ),
103 Error::DoesNotVerify { signer, signers } => write!(
104 f,
105 "signature does not verify as {signer} against {}",
106 signers.display()
107 ),
108 Error::KeyUnusable => write!(
109 f,
110 "not a usable unencrypted OpenSSH private key (passphrase-protected? a .pub pasted by mistake?)"
111 ),
112 Error::NotEd25519 { kind } => {
113 write!(f, "only ed25519 keys are supported (this one is {kind})")
114 }
115 Error::SignFailed => write!(f, "ssh-keygen could not sign"),
116 }
117 }
118}
119
120impl std::error::Error for Error {}
121
122pub fn verify(
126 namespace: &str,
127 payload: &str,
128 sig: &str,
129 allowed_signers: &Path,
130 principal: Option<&str>,
131) -> Result<String, Error> {
132 use std::io::Write;
133 let Some(sig_file) = SigFile::new(sig) else {
134 return Err(Error::TempFile);
135 };
136 let signer = match principal {
137 Some(p) => p.to_string(),
138 None => {
139 find_principal(&sig_file.0, allowed_signers).ok_or_else(|| Error::UnknownSigner {
140 signers: allowed_signers.to_path_buf(),
141 })?
142 }
143 };
144 let ok = (|| {
145 let mut child = Command::new("ssh-keygen")
146 .args(["-Y", "verify", "-n", namespace, "-I", &signer, "-f"])
147 .arg(allowed_signers)
148 .arg("-s")
149 .arg(&sig_file.0)
150 .stdin(Stdio::piped())
151 .stdout(Stdio::null())
152 .stderr(Stdio::null())
153 .spawn()
154 .ok()?;
155 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
156 child.wait().ok().map(|s| s.success())
157 })()
158 .unwrap_or(false);
159 if ok {
160 Ok(signer)
161 } else {
162 Err(Error::DoesNotVerify {
163 signer,
164 signers: allowed_signers.to_path_buf(),
165 })
166 }
167}
168
169pub fn public_key(key: &Path) -> Result<String, Error> {
172 let out = Command::new("ssh-keygen")
173 .args(["-y", "-P", "", "-f"])
174 .arg(key)
175 .stdin(Stdio::null())
176 .stderr(Stdio::null())
177 .output()
178 .map_err(|_| Error::KeyUnusable)?;
179 if !out.status.success() {
180 return Err(Error::KeyUnusable);
181 }
182 let public = String::from_utf8_lossy(&out.stdout).trim().to_string();
183 if !public.starts_with("ssh-ed25519 ") {
184 let kind = public.split(' ').next().unwrap_or("").to_string();
185 return Err(Error::NotEd25519 { kind });
186 }
187 Ok(public)
188}
189
190pub fn sign(namespace: &str, payload: &str, key: &Path) -> Result<String, Error> {
194 use std::io::Write;
195 let mut child = Command::new("ssh-keygen")
196 .args(["-Y", "sign", "-n", namespace, "-f"])
197 .arg(key)
198 .stdin(Stdio::piped())
199 .stdout(Stdio::piped())
200 .stderr(Stdio::null())
201 .spawn()
202 .map_err(|_| Error::SignFailed)?;
203 child
204 .stdin
205 .take()
206 .ok_or(Error::SignFailed)?
207 .write_all(payload.as_bytes())
208 .map_err(|_| Error::SignFailed)?;
209 let out = child.wait_with_output().map_err(|_| Error::SignFailed)?;
210 let sig = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
211 if out.status.success() && sig.starts_with(crate::format::BEGIN) {
212 Ok(sig)
213 } else {
214 Err(Error::SignFailed)
215 }
216}