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_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/// Why a signature was not accepted. `Display` is the reason the verifier
75/// reports.
76#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum Error {
79    /// The signature could not be handed to `ssh-keygen`.
80    TempFile,
81    /// No key in the signers file made it.
82    UnknownSigner { signers: PathBuf },
83    /// `ssh-keygen -Y verify` refused it.
84    DoesNotVerify { signer: String, signers: PathBuf },
85    /// `ssh-keygen -y` cannot load the private key unencrypted.
86    KeyUnusable,
87    /// The key loads but is not ed25519, the one type whose signatures are
88    /// deterministic, which is what lets a re-run recognise its own block.
89    NotEd25519 { kind: String },
90    /// `ssh-keygen -Y sign` failed or printed no signature.
91    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
122/// `ssh-keygen -Y verify` over the exact signed bytes, in `namespace`, as
123/// `principal` — or, when none is given, as whoever the signature says. `Ok`
124/// carries the identity the signature verified as.
125pub 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
169/// The public half of the private key at `key`, checked to be ed25519.
170/// `-P ''` fails at once on a passphrase-protected key instead of prompting.
171pub 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
190/// `ssh-keygen -Y sign` over `payload` in `namespace` with the private key at
191/// `key`: the armored signature, without its final newline. The payload goes
192/// on stdin, so nothing is written next to it.
193pub 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}