Skip to main content

git_attest/
sign.rs

1//! `ssh-keygen -Y`: the one place a signature is made or checked. The
2//! namespace is a parameter, so a provenance signature and an attestation can
3//! never be confused (SPEC.md, "amont-provenance-v1").
4
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8/// The armored signature as a file, because `ssh-keygen -Y` takes it no other
9/// way. Removed on drop, so no early return below can leak it.
10struct SigFile(PathBuf);
11
12impl SigFile {
13    fn new(sig: &str) -> Option<Self> {
14        use std::io::Write;
15        // The nanosecond varies the name per call, so a squatter on a shared
16        // temp dir can make one call lose a race, never hold every call off.
17        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        // `create_new` refuses an existing path, symlink included, rather than
27        // following it — the temp dir is shared with every other process.
28        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
47/// WHO signed, read from the signature and the file rather than guessed.
48///
49/// `ssh-keygen -Y verify` requires a principal and checks it against the
50/// principal column, so a guessed one rejects a perfectly good signature —
51/// quietly, since every rejection means "run the tests". The templates this
52/// replaced hardcoded `-I you@example.com`; the first release guessed the
53/// file's first entry, which silently uncovered every other signer on a team.
54/// `find-principals` answers from the key that actually signed.
55fn 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/// Why a signature was not accepted. `Display` is the reason the verifier
78/// reports.
79#[derive(Debug, Clone, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum Error {
82    /// The signature could not be handed to `ssh-keygen`.
83    TempFile,
84    /// No key in the signers file made it.
85    UnknownSigner { signers: PathBuf },
86    /// `ssh-keygen -Y verify` refused it.
87    DoesNotVerify { signer: String, signers: PathBuf },
88    /// The signer is bound to one platform and the block claims another.
89    PlatformBound {
90        signer: String,
91        bound: String,
92        platform: Option<String>,
93    },
94    /// `ssh-keygen -y` cannot load the private key unencrypted.
95    KeyUnusable,
96    /// The key loads but is not ed25519, the one type whose signatures are
97    /// deterministic, which is what lets a re-run recognise its own block.
98    NotEd25519 { kind: String },
99    /// `ssh-keygen -Y sign` failed or printed no signature.
100    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
145/// `ssh-keygen -Y verify` over the exact signed bytes, in `namespace`, as
146/// `principal` — or, when none is given, as whoever the signature says. `Ok`
147/// carries the identity the signature verified as. A signer whose principal
148/// is platform-bound (`format::bound_platform`) is refused for a payload whose
149/// `platform` field is absent or differs; with no principal given, every
150/// principal the key answers to is checked and any bound one refuses.
151pub 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
209/// The public half of the private key at `key`, checked to be ed25519.
210/// `-P ''` fails at once on a passphrase-protected key instead of prompting.
211pub 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
230/// `ssh-keygen -Y sign` over `payload` in `namespace` with the private key at
231/// `key`: the armored signature, without its final newline. The payload goes
232/// on stdin, so nothing is written next to it.
233pub 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}