torii_lib/util/gpg.rs
1//! GPG signing helper for `torii save` and `torii history reauthor`.
2//!
3//! Until 0.7.14, `git.sign_commits = true` was silently accepted by the
4//! config layer but never honoured at commit time — the bug reported by
5//! the user (`commit created without gpgsig header even though config
6//! says gpgsign=true`). This module fixes that by shelling out to the
7//! local `gpg` binary the same way upstream git does.
8//!
9//! We deliberately avoid pulling in an in-process OpenPGP library
10//! (sequoia / rpgp) for now:
11//!
12//! - Keeps gitorii's footprint small (no large crypto dep tree).
13//! - Reuses the user's existing keyring + agent + pinentry flow. If
14//! they sign with `git commit -S` today, `torii save` works
15//! identically without configuring anything new.
16//! - Matches the spec used by hosts (GitHub / GitLab / Codeberg)
17//! exactly — they just verify the ASCII-armored signature attached
18//! as `gpgsig`.
19//!
20//! Tradeoff: requires `gpg` (or `gpg2`) on PATH. Documented in the
21//! error message when missing.
22
23use std::io::Write;
24use std::process::{Command, Stdio};
25
26use crate::error::{Result, ToriiError};
27
28/// What `gpg --verify` reported for a signature. Returned by
29/// [`verify`] so callers (CLI, TUI, log-column renderer) can show a
30/// status-coloured indicator without each one re-parsing `gpg`
31/// stderr.
32#[derive(Debug, Clone, PartialEq)]
33pub enum VerifyStatus {
34 /// `gpg: Good signature` — the key is trusted and the data is
35 /// intact.
36 Good { signer: String },
37 /// Signature is valid but the signing key isn't in the local
38 /// keyring (`NO_PUBKEY` / `Can't check signature`). The data
39 /// could still be authentic; the user just can't prove it
40 /// locally.
41 UnknownKey { key_id: Option<String> },
42 /// `gpg: BAD signature` — payload was tampered with, or the
43 /// signature was made by a different key than what's attached.
44 Bad,
45 /// Anything else gpg might say: expired key, revoked, agent
46 /// errors, …
47 Other(String),
48}
49
50/// Sign the given commit content with GPG, returning the ASCII-armored
51/// detached signature ready to attach as the `gpgsig` header.
52///
53/// - `content`: the raw commit object bytes produced by
54/// `Repository::commit_create_buffer`.
55/// - `key`: the key identifier (long fingerprint or short id) to sign
56/// with. Passed to gpg via `-u`.
57/// - `program`: which gpg binary to invoke (defaults to `gpg`). Useful
58/// on hosts where gpg2 is installed under a different name.
59///
60/// Errors:
61/// - The gpg binary is missing → returns a hint to install it.
62/// - The key is unknown to the local keyring → propagates gpg's stderr
63/// so the user sees the real reason.
64/// - Pinentry failure (locked agent, wrong passphrase) → same.
65pub fn sign_blob(content: &[u8], key: &str, program: Option<&str>) -> Result<String> {
66 let bin = resolve_program(program);
67
68 let mut child = Command::new(&bin)
69 .args([
70 "--detach-sign",
71 "--armor",
72 "--local-user", key,
73 // No status output on stdout — keep the armored signature
74 // alone there so we can read it cleanly.
75 "--no-tty",
76 "--batch",
77 ])
78 .stdin(Stdio::piped())
79 .stdout(Stdio::piped())
80 .stderr(Stdio::piped())
81 .spawn()
82 .map_err(|e| {
83 if e.kind() == std::io::ErrorKind::NotFound {
84 ToriiError::Subprocess { tool: "gpg".into(), message: format!(
85 "gpg binary not found (tried `{}`). Install gpg or \
86 `torii config set git.gpg_program /path/to/gpg2`.",
87 bin
88 ) }
89 } else {
90 ToriiError::Subprocess { tool: "gpg".into(), message: format!("failed to spawn gpg: {}", e) }
91 }
92 })?;
93
94 {
95 let stdin = child.stdin.as_mut()
96 .ok_or_else(|| ToriiError::Subprocess { tool: "gpg".into(), message: "gpg stdin unavailable".into() })?;
97 stdin.write_all(content)
98 .map_err(|e| ToriiError::Subprocess { tool: "gpg".into(), message: format!("writing to gpg stdin: {}", e) })?;
99 }
100
101 let output = child.wait_with_output()
102 .map_err(|e| ToriiError::Subprocess { tool: "gpg".into(), message: format!("waiting for gpg: {}", e) })?;
103
104 if !output.status.success() {
105 let stderr = String::from_utf8_lossy(&output.stderr);
106 return Err(ToriiError::Subprocess { tool: "gpg".into(), message: format!(
107 "gpg signing failed (exit {}). gpg stderr:\n{}",
108 output.status.code().unwrap_or(-1),
109 stderr.trim()
110 ) });
111 }
112
113 String::from_utf8(output.stdout)
114 .map_err(|e| ToriiError::Subprocess { tool: "gpg".into(), message: format!(
115 "gpg output was not valid UTF-8: {}", e
116 ) })
117}
118
119/// Resolve which gpg binary to invoke. Argument (config-supplied)
120/// wins; absent that, fall back to `gpg`.
121pub fn resolve_program(program: Option<&str>) -> String {
122 program
123 .map(str::trim)
124 .filter(|s| !s.is_empty())
125 .unwrap_or("gpg")
126 .to_string()
127}
128
129/// Verify a detached GPG signature against the original signed
130/// payload. Mirrors what hosts (GitHub / GitLab) do server-side and
131/// what `git verify-commit` does locally.
132///
133/// Implementation: dump the armor to a tempfile, pipe the payload on
134/// stdin, and parse gpg's status output for the verdict. Exit codes
135/// alone don't distinguish "bad signature" from "unknown signer" so
136/// we scan the status lines too.
137pub fn verify(armor: &str, payload: &[u8], program: Option<&str>) -> Result<VerifyStatus> {
138 use std::fs::write;
139 let bin = resolve_program(program);
140
141 // gpg's "verify a detached sig" form is `gpg --verify <sig> <data>`.
142 // We get the data via stdin to avoid juggling two tempfiles.
143 let sig_path = std::env::temp_dir().join(format!(
144 "torii-verify-{}.asc",
145 // Cheap unique tag based on the armor itself; no time/random
146 // needed because each verify is short-lived.
147 armor.len()
148 ));
149 write(&sig_path, armor)
150 .map_err(|e| ToriiError::Fs(format!("write sig tempfile: {}", e)))?;
151
152 let mut child = Command::new(&bin)
153 .args([
154 "--status-fd", "1",
155 "--no-tty", "--batch",
156 "--verify", sig_path.to_str().unwrap_or(""),
157 "-",
158 ])
159 .stdin(Stdio::piped())
160 .stdout(Stdio::piped())
161 .stderr(Stdio::piped())
162 .spawn()
163 .map_err(|e| {
164 if e.kind() == std::io::ErrorKind::NotFound {
165 ToriiError::Subprocess { tool: "gpg".into(), message: format!(
166 "gpg binary not found (tried `{}`). Set git.gpg_program in config.",
167 bin
168 ) }
169 } else {
170 ToriiError::Subprocess { tool: "gpg".into(), message: format!("failed to spawn gpg: {}", e) }
171 }
172 })?;
173 {
174 let stdin = child.stdin.as_mut()
175 .ok_or_else(|| ToriiError::Subprocess { tool: "gpg".into(), message: "gpg stdin unavailable".into() })?;
176 stdin.write_all(payload)
177 .map_err(|e| ToriiError::Fs(format!("writing payload: {}", e)))?;
178 }
179 let out = child.wait_with_output()
180 .map_err(|e| ToriiError::Subprocess { tool: "gpg".into(), message: format!("waiting for gpg: {}", e) })?;
181
182 let _ = std::fs::remove_file(&sig_path);
183
184 let status_lines = String::from_utf8_lossy(&out.stdout).to_string();
185 let stderr_lines = String::from_utf8_lossy(&out.stderr).to_string();
186
187 // GPG status-fd lines start with `[GNUPG:] <TAG> …`. We look for
188 // the canonical tags first; if none match we fall back to the
189 // human stderr which is what `git verify-commit` shows anyway.
190 let mut signer: Option<String> = None;
191 let mut bad = false;
192 let mut no_key: Option<String> = None;
193 for line in status_lines.lines() {
194 let l = line.trim_start_matches("[GNUPG:] ").trim();
195 if let Some(rest) = l.strip_prefix("GOODSIG ") {
196 let mut parts = rest.splitn(2, ' ');
197 let _keyid = parts.next();
198 signer = parts.next().map(|s| s.to_string());
199 } else if l.starts_with("BADSIG ") {
200 bad = true;
201 } else if let Some(rest) = l.strip_prefix("NO_PUBKEY ") {
202 no_key = Some(rest.trim().to_string());
203 } else if l.starts_with("ERRSIG ") && no_key.is_none() {
204 // ERRSIG includes the missing-key case too; extract the
205 // long key id from field index 1.
206 let parts: Vec<&str> = l.split_whitespace().collect();
207 if let Some(k) = parts.get(1) {
208 no_key = Some(k.to_string());
209 }
210 }
211 }
212 if bad {
213 return Ok(VerifyStatus::Bad);
214 }
215 if let Some(s) = signer {
216 return Ok(VerifyStatus::Good { signer: s });
217 }
218 if let Some(k) = no_key {
219 return Ok(VerifyStatus::UnknownKey { key_id: Some(k) });
220 }
221 // Fall back to the stderr summary so the user still sees
222 // something useful (e.g. expired key, revoked, broken keyring).
223 Ok(VerifyStatus::Other(stderr_lines.lines().last().unwrap_or("unknown").to_string()))
224}