Skip to main content

gn_cli/commands/
signing.rs

1use anyhow::{bail, Context, Result};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use uuid::Uuid;
6
7struct TempFile {
8    path: PathBuf,
9}
10
11impl TempFile {
12    fn new(suffix: &str) -> Result<Self> {
13        let file_name = format!("gn_sig_{}_{}{}", std::process::id(), Uuid::new_v4(), suffix);
14        let path = std::env::temp_dir().join(file_name);
15        Ok(Self { path })
16    }
17
18    fn write(&mut self, content: &[u8]) -> Result<()> {
19        std::fs::write(&self.path, content)?;
20        Ok(())
21    }
22
23    fn path(&self) -> &Path {
24        &self.path
25    }
26}
27
28impl Drop for TempFile {
29    fn drop(&mut self) {
30        if self.path.exists() {
31            let _ = std::fs::remove_file(&self.path);
32        }
33    }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SignatureStatus {
38    Valid,
39    Bad,
40    Unsigned,
41}
42
43#[derive(Debug, Clone)]
44pub struct VerificationResult {
45    pub status: SignatureStatus,
46    pub signer: Option<String>,
47    pub details: Option<String>,
48}
49
50/// Check if cryptographic signing is requested either via CLI flag `--sign`
51/// or git config `git-notes.sign` == true.
52pub fn is_signing_requested(flag: bool) -> bool {
53    if flag {
54        return true;
55    }
56    match Command::new("git")
57        .args(["config", "--bool", "git-notes.sign"])
58        .output()
59    {
60        Ok(out) if out.status.success() => {
61            let val = String::from_utf8_lossy(&out.stdout).trim().to_lowercase();
62            val == "true" || val == "yes" || val == "1"
63        }
64        _ => false,
65    }
66}
67
68/// Sign the payload string using git / gpg / ssh signing configured in git or default gpg.
69pub fn sign_payload(payload: &str) -> Result<String> {
70    let gpg_format = Command::new("git")
71        .args(["config", "gpg.format"])
72        .output()
73        .ok()
74        .and_then(|o| {
75            if o.status.success() {
76                let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
77                if !s.is_empty() {
78                    Some(s)
79                } else {
80                    None
81                }
82            } else {
83                None
84            }
85        })
86        .unwrap_or_else(|| "openpgp".to_string());
87
88    let signing_key = Command::new("git")
89        .args(["config", "user.signingkey"])
90        .output()
91        .ok()
92        .and_then(|o| {
93            if o.status.success() {
94                let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
95                if !s.is_empty() {
96                    Some(s)
97                } else {
98                    None
99                }
100            } else {
101                None
102            }
103        });
104
105    if gpg_format.eq_ignore_ascii_case("ssh") {
106        if let Some(key) = &signing_key {
107            if let Ok(sig) = sign_with_ssh(payload, key) {
108                return Ok(sig);
109            }
110        }
111    }
112
113    // Try gpg with signing_key if specified
114    if let Some(key) = &signing_key {
115        if let Ok(sig) = sign_with_gpg(payload, Some(key)) {
116            return Ok(sig);
117        }
118    }
119
120    // Try default gpg
121    if let Ok(sig) = sign_with_gpg(payload, None) {
122        return Ok(sig);
123    }
124
125    // Fallback: git tag -s or git commit-tree or git var / mock fallback
126    bail!(
127        "Failed to sign note. Ensure gpg or ssh signing is configured (e.g. git config user.signingkey <key> or gpg is installed)."
128    )
129}
130
131fn sign_with_gpg(payload: &str, key: Option<&str>) -> Result<String> {
132    let mut cmd = Command::new("gpg");
133    cmd.args(["--batch", "--armor", "--detach-sign", "--yes"]);
134    if let Some(k) = key {
135        cmd.args(["--default-key", k]);
136    }
137    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
138
139    let mut child = cmd.spawn().context("Failed to spawn gpg")?;
140    if let Some(mut stdin) = child.stdin.take() {
141        stdin.write_all(payload.as_bytes())?;
142    }
143    let output = child.wait_with_output().context("Failed to read gpg output")?;
144    if output.status.success() {
145        let sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
146        if !sig.is_empty() {
147            return Ok(sig);
148        }
149    }
150    bail!(
151        "gpg signing failed: {}",
152        String::from_utf8_lossy(&output.stderr).trim()
153    )
154}
155
156fn sign_with_ssh(payload: &str, key_path: &str) -> Result<String> {
157    let mut temp_file = TempFile::new(".txt")?;
158    temp_file.write(payload.as_bytes())?;
159    let temp_path = temp_file.path().to_path_buf();
160
161    let output = Command::new("ssh-keygen")
162        .args([
163            "-Y",
164            "sign",
165            "-n",
166            "git-notes",
167            "-f",
168            key_path,
169            temp_path.to_str().unwrap(),
170        ])
171        .output()
172        .context("Failed to execute ssh-keygen -Y sign")?;
173
174    if output.status.success() {
175        let sig_path = temp_path.with_extension(format!(
176            "{}.sig",
177            temp_path.extension().and_then(|s| s.to_str()).unwrap_or("")
178        ));
179        if sig_path.exists() {
180            let sig_content = std::fs::read_to_string(&sig_path)?;
181            let _ = std::fs::remove_file(sig_path);
182            return Ok(sig_content.trim().to_string());
183        }
184    }
185    bail!(
186        "ssh signing failed: {}",
187        String::from_utf8_lossy(&output.stderr).trim()
188    )
189}
190
191/// Verify signature against payload
192pub fn verify_signature(payload: &str, signature: Option<&str>, author: &str) -> VerificationResult {
193    let sig = match signature {
194        Some(s) if !s.trim().is_empty() => s.trim(),
195        _ => {
196            return VerificationResult {
197                status: SignatureStatus::Unsigned,
198                signer: None,
199                details: Some("No cryptographic signature present".to_string()),
200            }
201        }
202    };
203
204    // If signature is OpenPGP
205    if sig.contains("-----BEGIN PGP SIGNATURE-----") {
206        return verify_gpg_signature(payload, sig, author);
207    }
208
209    // If signature is SSH
210    if sig.contains("-----BEGIN SSH SIGNATURE-----") {
211        return verify_ssh_signature(payload, sig, author);
212    }
213
214    // Unknown signature format
215    VerificationResult {
216        status: SignatureStatus::Bad,
217        signer: None,
218        details: Some("Unrecognized signature format".to_string()),
219    }
220}
221
222fn verify_gpg_signature(payload: &str, signature: &str, fallback_author: &str) -> VerificationResult {
223    let mut sig_file = match TempFile::new(".sig") {
224        Ok(f) => f,
225        Err(e) => {
226            return VerificationResult {
227                status: SignatureStatus::Bad,
228                signer: None,
229                details: Some(format!("Failed to create temp signature file: {}", e)),
230            }
231        }
232    };
233    if let Err(e) = sig_file.write(signature.as_bytes()) {
234        return VerificationResult {
235            status: SignatureStatus::Bad,
236            signer: None,
237            details: Some(format!("Failed to write temp signature file: {}", e)),
238        };
239    }
240
241    let mut data_file = match TempFile::new(".data") {
242        Ok(f) => f,
243        Err(e) => {
244            return VerificationResult {
245                status: SignatureStatus::Bad,
246                signer: None,
247                details: Some(format!("Failed to create temp data file: {}", e)),
248            }
249        }
250    };
251    if let Err(e) = data_file.write(payload.as_bytes()) {
252        return VerificationResult {
253            status: SignatureStatus::Bad,
254            signer: None,
255            details: Some(format!("Failed to write temp data file: {}", e)),
256        };
257    }
258
259    let output = Command::new("gpg")
260        .args([
261            "--batch",
262            "--verify",
263            sig_file.path().to_str().unwrap(),
264            data_file.path().to_str().unwrap(),
265        ])
266        .output();
267
268    match output {
269        Ok(out) => {
270            let stderr = String::from_utf8_lossy(&out.stderr);
271            if out.status.success() || stderr.contains("Good signature from") {
272                let signer = extract_gpg_signer(&stderr).unwrap_or_else(|| fallback_author.to_string());
273                VerificationResult {
274                    status: SignatureStatus::Valid,
275                    signer: Some(signer),
276                    details: Some("GPG cryptographic signature verified".to_string()),
277                }
278            } else {
279                VerificationResult {
280                    status: SignatureStatus::Bad,
281                    signer: None,
282                    details: Some(stderr.trim().to_string()),
283                }
284            }
285        }
286        Err(e) => VerificationResult {
287            status: SignatureStatus::Bad,
288            signer: None,
289            details: Some(format!("Could not run gpg: {}", e)),
290        },
291    }
292}
293
294fn extract_gpg_signer(stderr: &str) -> Option<String> {
295    for line in stderr.lines() {
296        if let Some(idx) = line.find("Good signature from") {
297            let rest = &line[idx + "Good signature from".len()..];
298            return Some(rest.trim().trim_matches('"').to_string());
299        }
300    }
301    None
302}
303
304fn verify_ssh_signature(payload: &str, signature: &str, fallback_author: &str) -> VerificationResult {
305    let mut sig_file = match TempFile::new(".sig") {
306        Ok(f) => f,
307        Err(e) => {
308            return VerificationResult {
309                status: SignatureStatus::Bad,
310                signer: None,
311                details: Some(format!("Failed to create temp signature file: {}", e)),
312            }
313        }
314    };
315    if let Err(e) = sig_file.write(signature.as_bytes()) {
316        return VerificationResult {
317            status: SignatureStatus::Bad,
318            signer: None,
319            details: Some(format!("Failed to write temp signature file: {}", e)),
320        };
321    }
322
323    let output = Command::new("ssh-keygen")
324        .args([
325            "-Y",
326            "check-novalidate",
327            "-n",
328            "git-notes",
329            "-s",
330            sig_file.path().to_str().unwrap(),
331        ])
332        .stdin(Stdio::piped())
333        .stdout(Stdio::piped())
334        .stderr(Stdio::piped())
335        .spawn();
336
337    match output {
338        Ok(mut child) => {
339            if let Some(mut stdin) = child.stdin.take() {
340                let _ = stdin.write_all(payload.as_bytes());
341            }
342            match child.wait_with_output() {
343                Ok(out) => {
344                    let combined = format!(
345                        "{}\n{}",
346                        String::from_utf8_lossy(&out.stdout),
347                        String::from_utf8_lossy(&out.stderr)
348                    );
349                    if out.status.success() || combined.contains("Good") {
350                        VerificationResult {
351                            status: SignatureStatus::Valid,
352                            signer: Some(fallback_author.to_string()),
353                            details: Some("SSH cryptographic signature verified".to_string()),
354                        }
355                    } else {
356                        VerificationResult {
357                            status: SignatureStatus::Bad,
358                            signer: None,
359                            details: Some(combined.trim().to_string()),
360                        }
361                    }
362                }
363                Err(e) => VerificationResult {
364                    status: SignatureStatus::Bad,
365                    signer: None,
366                    details: Some(format!("Failed to wait for ssh-keygen: {}", e)),
367                },
368            }
369        }
370        Err(e) => VerificationResult {
371            status: SignatureStatus::Bad,
372            signer: None,
373            details: Some(format!("Could not run ssh-keygen: {}", e)),
374        },
375    }
376}