speccoder_runtime/attest/
sign.rs1use 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
20pub const NAMESPACE: &str = "in-toto";
22
23#[derive(Debug)]
25pub enum Signer {
26 Ssh {
29 program: String,
31 key: PathBuf,
33 _literal: Option<tempfile::NamedTempFile>,
35 },
36 OpenPgp {
38 program: String,
40 key: String,
42 },
43 Sigstore,
45}
46
47impl Signer {
48 #[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
66pub 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
174pub 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
250fn 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}