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
50pub 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
68pub 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 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 if let Ok(sig) = sign_with_gpg(payload, None) {
122 return Ok(sig);
123 }
124
125 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
191pub 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 sig.contains("-----BEGIN PGP SIGNATURE-----") {
206 return verify_gpg_signature(payload, sig, author);
207 }
208
209 if sig.contains("-----BEGIN SSH SIGNATURE-----") {
211 return verify_ssh_signature(payload, sig, author);
212 }
213
214 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}