Skip to main content

speccoder_runtime/attest/
sign.rs

1//! Signing statements with the person's git signing key, as git would:
2//! `ssh-keygen -Y sign` for `gpg.format = ssh`, `gpg --detach-sign` for
3//! OpenPGP; `cosign` for opt-in Sigstore. The namespace `in-toto` keeps a
4//! statement signature from ever passing as a commit signature.
5//!
6//! Signing never fails a tick: whatever goes wrong leaves the statement
7//! unsigned, with the reason (`attest sign` can sign it later).
8
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13use base64::Engine as _;
14use base64::engine::general_purpose::STANDARD;
15
16use super::dsse::Signature;
17use crate::git::Git;
18use crate::substrate::run_config::{AttestConfig, Signer as Configured};
19
20/// The SSH signature namespace for statements.
21pub const NAMESPACE: &str = "in-toto";
22
23/// How statements get signed here.
24#[derive(Debug)]
25pub enum Signer {
26    /// `ssh-keygen -Y sign` with this key file (a private key, or a public
27    /// key whose private half is in the agent).
28    Ssh {
29        /// `gpg.ssh.program`, default `ssh-keygen`.
30        program: String,
31        /// The key file.
32        key: PathBuf,
33        /// Holds a `key::` literal's temporary file alive.
34        _literal: Option<tempfile::NamedTempFile>,
35    },
36    /// `gpg --detach-sign --armor`, with this key (the default key when empty).
37    OpenPgp {
38        /// `gpg.openpgp.program`, `gpg.program`, default `gpg`.
39        program: String,
40        /// `user.signingKey`.
41        key: String,
42    },
43    /// `cosign sign-blob`, keyless.
44    Sigstore,
45}
46
47impl Signer {
48    /// `ssh`, `openpgp` or `sigstore`.
49    #[must_use]
50    pub const fn kind(&self) -> &'static str {
51        match self {
52            Self::Ssh { .. } => "ssh",
53            Self::OpenPgp { .. } => "openpgp",
54            Self::Sigstore => "sigstore",
55        }
56    }
57}
58
59fn config(repo: &Path, key: &str) -> Option<String> {
60    Git::at(repo)
61        .line(&["config", key])
62        .ok()
63        .filter(|v| !v.trim().is_empty())
64}
65
66/// The signer `[attest]` and git's configuration in `repo` (the person's
67/// checkout) describe, or why statements stay unsigned.
68pub fn signer(repo: &Path, attest: &AttestConfig) -> Result<Signer, String> {
69    match attest.signer {
70        Configured::None => Err("[attest] signer = \"none\"".to_owned()),
71        Configured::Sigstore => {
72            if which("cosign") {
73                Ok(Signer::Sigstore)
74            } else {
75                Err("[attest] signer = \"sigstore\", but cosign is not installed".to_owned())
76            }
77        }
78        Configured::Git => match config(repo, "gpg.format").as_deref() {
79            Some("ssh") => ssh(repo),
80            Some("x509") => Err("x509 signing keys are not supported".to_owned()),
81            _ => {
82                let key = config(repo, "user.signingKey").unwrap_or_default();
83                if key.is_empty() && config(repo, "commit.gpgSign").is_none() {
84                    return Err("no signing key is configured in git (user.signingKey)".to_owned());
85                }
86                Ok(Signer::OpenPgp {
87                    program: config(repo, "gpg.openpgp.program")
88                        .or_else(|| config(repo, "gpg.program"))
89                        .unwrap_or_else(|| "gpg".to_owned()),
90                    key,
91                })
92            }
93        },
94    }
95}
96
97fn ssh(repo: &Path) -> Result<Signer, String> {
98    let program = config(repo, "gpg.ssh.program").unwrap_or_else(|| "ssh-keygen".to_owned());
99    let key = match config(repo, "user.signingKey") {
100        Some(k) => k,
101        None => {
102            let Some(cmd) = config(repo, "gpg.ssh.defaultKeyCommand") else {
103                return Err("no SSH signing key is configured in git (user.signingKey)".to_owned());
104            };
105            let out = Command::new("sh")
106                .args(["-c", &cmd])
107                .current_dir(repo)
108                .output()
109                .map_err(|e| format!("gpg.ssh.defaultKeyCommand: {e}"))?;
110            String::from_utf8_lossy(&out.stdout)
111                .lines()
112                .next()
113                .unwrap_or_default()
114                .to_owned()
115        }
116    };
117    if let Some(literal) = key.strip_prefix("key::") {
118        let mut file = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
119        writeln!(file, "{literal}").map_err(|e| e.to_string())?;
120        return Ok(Signer::Ssh {
121            program,
122            key: file.path().to_path_buf(),
123            _literal: Some(file),
124        });
125    }
126    let key = expand_home(&key);
127    if !key.exists() {
128        return Err(format!(
129            "the SSH signing key {} does not exist",
130            key.display()
131        ));
132    }
133    Ok(Signer::Ssh {
134        program,
135        key,
136        _literal: None,
137    })
138}
139
140fn expand_home(p: &str) -> PathBuf {
141    match (p.strip_prefix("~/"), std::env::var_os("HOME")) {
142        (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
143        _ => PathBuf::from(p),
144    }
145}
146
147fn which(program: &str) -> bool {
148    Command::new(program)
149        .arg("version")
150        .stdout(Stdio::null())
151        .stderr(Stdio::null())
152        .status()
153        .is_ok()
154}
155
156fn piped(mut cmd: Command, input: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
157    let mut child = cmd
158        .stdin(Stdio::piped())
159        .stdout(Stdio::piped())
160        .stderr(Stdio::piped())
161        .spawn()
162        .map_err(|e| e.to_string())?;
163    if let Some(mut stdin) = child.stdin.take() {
164        stdin.write_all(input).map_err(|e| e.to_string())?;
165    }
166    let out = child.wait_with_output().map_err(|e| e.to_string())?;
167    if out.status.success() {
168        Ok((out.stdout, out.stderr))
169    } else {
170        Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
171    }
172}
173
174/// Signs `bytes` (a statement's PAE). The signature is base64 of the
175/// signer's own armored output; `keyid` is the key's fingerprint.
176pub fn sign(
177    signer: &Signer,
178    bytes: &[u8],
179) -> Result<(Signature, Option<serde_json::Value>), String> {
180    match signer {
181        Signer::Ssh { program, key, .. } => {
182            let mut cmd = Command::new(program);
183            cmd.args(["-Y", "sign", "-n", NAMESPACE, "-f"]).arg(key);
184            let (armored, _) = piped(cmd, bytes).map_err(|e| format!("ssh-keygen: {e}"))?;
185            Ok((
186                Signature {
187                    keyid: ssh_fingerprint(program, key)?,
188                    sig: STANDARD.encode(armored),
189                },
190                None,
191            ))
192        }
193        Signer::OpenPgp { program, key } => {
194            let mut cmd = Command::new(program);
195            cmd.args(["--batch", "--status-fd", "2", "--detach-sign", "--armor"]);
196            if !key.is_empty() {
197                cmd.args(["-u", key]);
198            }
199            let (armored, status) = piped(cmd, bytes).map_err(|e| format!("gpg: {e}"))?;
200            let fingerprint = String::from_utf8_lossy(&status)
201                .lines()
202                .find_map(|l| l.strip_prefix("[GNUPG:] SIG_CREATED "))
203                .and_then(|l| l.split_whitespace().last())
204                .map(str::to_owned)
205                .unwrap_or_else(|| key.clone());
206            Ok((
207                Signature {
208                    keyid: fingerprint,
209                    sig: STANDARD.encode(armored),
210                },
211                None,
212            ))
213        }
214        Signer::Sigstore => {
215            let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
216            let blob = dir.path().join("statement.pae");
217            let bundle = dir.path().join("statement.sigstore.json");
218            std::fs::write(&blob, bytes).map_err(|e| e.to_string())?;
219            let out = Command::new("cosign")
220                .args(["sign-blob", "--yes", "--bundle"])
221                .arg(&bundle)
222                .arg(&blob)
223                .output()
224                .map_err(|e| format!("cosign: {e}"))?;
225            if !out.status.success() {
226                return Err(format!(
227                    "cosign: {}",
228                    String::from_utf8_lossy(&out.stderr).trim()
229                ));
230            }
231            let material: serde_json::Value =
232                serde_json::from_slice(&std::fs::read(&bundle).map_err(|e| e.to_string())?)
233                    .map_err(|e| format!("cosign bundle: {e}"))?;
234            let sig = material["messageSignature"]["signature"]
235                .as_str()
236                .or_else(|| material["base64Signature"].as_str())
237                .unwrap_or_default()
238                .to_owned();
239            Ok((
240                Signature {
241                    keyid: "sigstore".to_owned(),
242                    sig,
243                },
244                Some(material),
245            ))
246        }
247    }
248}
249
250/// `SHA256:…` of an SSH key file.
251fn ssh_fingerprint(program: &str, key: &Path) -> Result<String, String> {
252    let out = Command::new(program)
253        .args(["-l", "-E", "sha256", "-f"])
254        .arg(key)
255        .output()
256        .map_err(|e| e.to_string())?;
257    String::from_utf8_lossy(&out.stdout)
258        .split_whitespace()
259        .nth(1)
260        .map(str::to_owned)
261        .ok_or_else(|| "ssh-keygen -l printed no fingerprint".to_owned())
262}