Skip to main content

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}