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_principals(sig_file: &Path, allowed_signers: &Path) -> Vec<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 let Ok(out) = out else {
64 return Vec::new();
65 };
66 if !out.status.success() {
67 return Vec::new();
68 }
69 String::from_utf8_lossy(&out.stdout)
70 .lines()
71 .map(str::trim)
72 .filter(|l| !l.is_empty())
73 .map(str::to_string)
74 .collect()
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum Error {
82 TempFile,
84 UnknownSigner { signers: PathBuf },
86 DoesNotVerify { signer: String, signers: PathBuf },
88 PlatformBound {
90 signer: String,
91 bound: String,
92 platform: Option<String>,
93 },
94 KeyUnusable,
96 NotEd25519 { kind: String },
99 SignFailed,
101}
102
103impl std::fmt::Display for Error {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 Error::TempFile => write!(f, "cannot create a temporary file for the signature"),
107 Error::UnknownSigner { signers } => write!(
108 f,
109 "signature was not made by any key in {}",
110 signers.display()
111 ),
112 Error::DoesNotVerify { signer, signers } => write!(
113 f,
114 "signature does not verify as {signer} against {}",
115 signers.display()
116 ),
117 Error::PlatformBound {
118 signer,
119 bound,
120 platform,
121 } => match platform {
122 Some(platform) => write!(
123 f,
124 "{signer} may sign only {bound} blocks; this block is {platform}"
125 ),
126 None => write!(
127 f,
128 "{signer} may sign only {bound} blocks; this block names no platform"
129 ),
130 },
131 Error::KeyUnusable => write!(
132 f,
133 "not a usable unencrypted OpenSSH private key (passphrase-protected? a .pub pasted by mistake?)"
134 ),
135 Error::NotEd25519 { kind } => {
136 write!(f, "only ed25519 keys are supported (this one is {kind})")
137 }
138 Error::SignFailed => write!(f, "ssh-keygen could not sign"),
139 }
140 }
141}
142
143impl std::error::Error for Error {}
144
145pub fn verify(
152 namespace: &str,
153 payload: &str,
154 sig: &str,
155 allowed_signers: &Path,
156 principal: Option<&str>,
157) -> Result<String, Error> {
158 use std::io::Write;
159 let Some(sig_file) = SigFile::new(sig) else {
160 return Err(Error::TempFile);
161 };
162 let principals = match principal {
163 Some(p) => vec![p.to_string()],
164 None => find_principals(&sig_file.0, allowed_signers),
165 };
166 let Some(signer) = principals.first().cloned() else {
167 return Err(Error::UnknownSigner {
168 signers: allowed_signers.to_path_buf(),
169 });
170 };
171 let ok = (|| {
172 let mut child = Command::new("ssh-keygen")
173 .args(["-Y", "verify", "-n", namespace, "-I", &signer, "-f"])
174 .arg(allowed_signers)
175 .arg("-s")
176 .arg(&sig_file.0)
177 .stdin(Stdio::piped())
178 .stdout(Stdio::null())
179 .stderr(Stdio::null())
180 .spawn()
181 .ok()?;
182 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
183 child.wait().ok().map(|s| s.success())
184 })()
185 .unwrap_or(false);
186 if ok {
187 let platform = crate::format::field(payload, "platform");
188 for candidate in &principals {
189 if let Some(bound) = crate::format::bound_platform(candidate) {
190 if platform != Some(bound) {
191 let bound = bound.to_string();
192 return Err(Error::PlatformBound {
193 signer: candidate.clone(),
194 bound,
195 platform: platform.map(str::to_string),
196 });
197 }
198 }
199 }
200 Ok(signer)
201 } else {
202 Err(Error::DoesNotVerify {
203 signer,
204 signers: allowed_signers.to_path_buf(),
205 })
206 }
207}
208
209pub fn public_key(key: &Path) -> Result<String, Error> {
212 let out = Command::new("ssh-keygen")
213 .args(["-y", "-P", "", "-f"])
214 .arg(key)
215 .stdin(Stdio::null())
216 .stderr(Stdio::null())
217 .output()
218 .map_err(|_| Error::KeyUnusable)?;
219 if !out.status.success() {
220 return Err(Error::KeyUnusable);
221 }
222 let public = String::from_utf8_lossy(&out.stdout).trim().to_string();
223 if !public.starts_with("ssh-ed25519 ") {
224 let kind = public.split(' ').next().unwrap_or("").to_string();
225 return Err(Error::NotEd25519 { kind });
226 }
227 Ok(public)
228}
229
230pub fn sign(namespace: &str, payload: &str, key: &Path) -> Result<String, Error> {
234 use std::io::Write;
235 let mut child = Command::new("ssh-keygen")
236 .args(["-Y", "sign", "-n", namespace, "-f"])
237 .arg(key)
238 .stdin(Stdio::piped())
239 .stdout(Stdio::piped())
240 .stderr(Stdio::null())
241 .spawn()
242 .map_err(|_| Error::SignFailed)?;
243 child
244 .stdin
245 .take()
246 .ok_or(Error::SignFailed)?
247 .write_all(payload.as_bytes())
248 .map_err(|_| Error::SignFailed)?;
249 let out = child.wait_with_output().map_err(|_| Error::SignFailed)?;
250 let sig = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
251 if out.status.success() && sig.starts_with(crate::format::BEGIN) {
252 Ok(sig)
253 } else {
254 Err(Error::SignFailed)
255 }
256}