forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
31.8 KiB
1//! Signed commit badges, and the key material behind them.
2//!
3//! Verification asks git (`forge-git::verify`) what signed a commit, then matches the signing
4//! key to a forge user. A commit is Verified when the key belongs to a user and the committer
5//! email is that user's email (SSH) or one of the key's uid emails (GPG). Results are cached in
6//! `commit_signatures`. SSH keys are checked against `<data_dir>/run/allowed_signers`, and GPG
7//! keys against the forge-managed keyring in `<data_dir>/gnupg`.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::Duration;
14
15use base64::prelude::{BASE64_STANDARD, Engine as _};
16use forge_core::model::Repo;
17use forge_core::store::signatures::{CachedSig, SigKind, SigStatus};
18use forge_git::verify::{RawVerify, committer_email, verify_commit};
19use tokio::time::Instant;
20
21use crate::state::AppState;
22
23/// Most commits one call looks at. The commit list and the commit page show at most this many.
24const MAX_COMMITS: usize = 50;
25/// Total time allowed for uncached verification in one call.
26const BUDGET: Duration = Duration::from_secs(5);
27/// Largest armored key accepted for import.
28const MAX_ARMORED_BYTES: usize = 64 * 1024;
29
30/// What the UI shows for a commit. Unsigned commits have no badge at all.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SigBadge {
33 Verified { signer: String, key: String },
34 Unverified { reason: String },
35}
36
37/// Badges for the given commits of `repo`. Unsigned commits are omitted. Cached results are read
38/// first. Uncached commits are verified inline, within [`BUDGET`] in total, and only the first
39/// [`MAX_COMMITS`] are considered. Commits that are not reached in time are omitted, and they are
40/// checked the next time. This never fails: problems are logged and the commit is left out.
41pub async fn badges(state: &AppState, repo: &Repo, oids: &[String]) -> HashMap<String, SigBadge> {
42 let oids = &oids[..oids.len().min(MAX_COMMITS)];
43 let mut out = HashMap::new();
44 let cached = match state.store.cached_signatures(&repo.id, oids).await {
45 Ok(c) => c,
46 Err(e) => {
47 tracing::warn!(error = %e, repo = %repo.id, "reading cached signatures failed");
48 HashMap::new()
49 }
50 };
51
52 let mut uncached = Vec::new();
53 for oid in oids {
54 match cached.get(oid) {
55 Some(sig) => {
56 if let Some(badge) = badge_for(state, sig).await {
57 out.insert(oid.clone(), badge);
58 }
59 }
60 None => uncached.push(oid.clone()),
61 }
62 }
63 if uncached.is_empty() {
64 return out;
65 }
66
67 let deadline = Instant::now() + BUDGET;
68 let gpg_ok = tokio::time::timeout_at(deadline, gpg_available(state))
69 .await
70 .unwrap_or(false);
71 let signers = allowed_signers_path(state);
72 let gnupg = gnupg_home(state);
73 for oid in uncached {
74 let step = tokio::time::timeout_at(
75 deadline,
76 check_commit(state, repo, &oid, &signers, &gnupg, gpg_ok),
77 );
78 match step.await {
79 Err(_) => break,
80 Ok(Err(e)) => tracing::warn!(error = %e, oid = %oid, "signature check failed"),
81 Ok(Ok((sig, cacheable))) => {
82 if cacheable && let Err(e) = state.store.put_signature(&repo.id, &oid, &sig).await {
83 tracing::warn!(error = %e, oid = %oid, "caching signature failed");
84 }
85 if let Some(badge) = badge_for(state, &sig).await {
86 out.insert(oid, badge);
87 }
88 }
89 }
90 }
91 out
92}
93
94/// The badge for a stored result. `None` for unsigned commits.
95async fn badge_for(state: &AppState, sig: &CachedSig) -> Option<SigBadge> {
96 match sig.status {
97 SigStatus::Unsigned => None,
98 SigStatus::Verified => {
99 let key = sig.key_fingerprint.clone().unwrap_or_default();
100 let signer = match &sig.signer_user_id {
101 Some(id) => state.store.user_by_id(id).await.ok().flatten(),
102 None => None,
103 };
104 Some(match signer {
105 Some(user) => SigBadge::Verified {
106 signer: user.username,
107 key,
108 },
109 None => SigBadge::Unverified {
110 reason: "the signing key's owner no longer exists".into(),
111 },
112 })
113 }
114 SigStatus::Unverified => Some(SigBadge::Unverified {
115 reason: "the committer email does not match the signing key's owner".into(),
116 }),
117 SigStatus::UnknownKey => Some(SigBadge::Unverified {
118 reason: "the signing key is not registered on this forge".into(),
119 }),
120 SigStatus::Bad => Some(SigBadge::Unverified {
121 reason: "the signature does not verify".into(),
122 }),
123 }
124}
125
126/// Verifies one commit. Returns the result and whether it may be cached. A GPG commit is not
127/// cacheable while `gpg` is missing, so it is checked again once gpg is installed.
128async fn check_commit(
129 state: &AppState,
130 repo: &Repo,
131 oid: &str,
132 signers: &Path,
133 gnupg: &Path,
134 gpg_ok: bool,
135) -> Result<(CachedSig, bool), String> {
136 let repo_dir =
137 forge_git::repo::repo_path(&state.git.data_dir, &repo.id).map_err(|e| e.to_string())?;
138 let raw = verify_commit(&state.git, &repo_dir, oid, signers, Some(gnupg))
139 .await
140 .map_err(|e| e.to_string())?;
141 if !raw.signed {
142 return Ok((plain(SigStatus::Unsigned, None), true));
143 }
144 if raw.kind == Some(SigKind::Gpg) && !gpg_ok {
145 return Ok((plain(SigStatus::UnknownKey, None), false));
146 }
147 let email = committer_email(&state.git, &repo_dir, oid)
148 .await
149 .map_err(|e| e.to_string())?;
150 Ok((classify(state, &raw, &email).await?, true))
151}
152
153fn plain(status: SigStatus, kind: Option<SigKind>) -> CachedSig {
154 CachedSig {
155 status,
156 kind,
157 signer_user_id: None,
158 key_fingerprint: None,
159 }
160}
161
162/// Turns git's raw answer into a stored status, matching the key against forge's keys.
163async fn classify(state: &AppState, raw: &RawVerify, committer: &str) -> Result<CachedSig, String> {
164 let kind = raw.kind;
165 if raw.bad {
166 return Ok(CachedSig {
167 key_fingerprint: raw.fingerprint.clone(),
168 ..plain(SigStatus::Bad, kind)
169 });
170 }
171 let Some(fingerprint) = raw.fingerprint.clone() else {
172 return Ok(plain(SigStatus::UnknownKey, kind));
173 };
174 if !raw.good {
175 let status = if raw.missing_key {
176 SigStatus::UnknownKey
177 } else {
178 SigStatus::Bad
179 };
180 return Ok(CachedSig {
181 key_fingerprint: Some(fingerprint),
182 ..plain(status, kind)
183 });
184 }
185
186 let owner = match kind {
187 Some(SigKind::Ssh) => {
188 let key = state
189 .store
190 .ssh_key_by_fingerprint(&fingerprint)
191 .await
192 .map_err(|e| e.to_string())?;
193 let Some(key) = key else {
194 return Ok(CachedSig {
195 key_fingerprint: Some(fingerprint),
196 ..plain(SigStatus::UnknownKey, kind)
197 });
198 };
199 let user = state
200 .store
201 .user_by_id(&key.user_id)
202 .await
203 .map_err(|e| e.to_string())?;
204 let email_ok = user
205 .as_ref()
206 .and_then(|u| u.email.as_deref())
207 .is_some_and(|e| e.eq_ignore_ascii_case(committer));
208 (key.user_id, email_ok)
209 }
210 Some(SigKind::Gpg) => {
211 let key = state
212 .store
213 .gpg_key_by_fingerprint(&fingerprint)
214 .await
215 .map_err(|e| e.to_string())?;
216 let Some(key) = key else {
217 return Ok(CachedSig {
218 key_fingerprint: Some(fingerprint),
219 ..plain(SigStatus::UnknownKey, kind)
220 });
221 };
222 let email_ok = key.emails.iter().any(|e| e.eq_ignore_ascii_case(committer));
223 (key.user_id, email_ok)
224 }
225 None => return Ok(plain(SigStatus::UnknownKey, kind)),
226 };
227
228 let (user_id, email_ok) = owner;
229 Ok(CachedSig {
230 status: if email_ok {
231 SigStatus::Verified
232 } else {
233 SigStatus::Unverified
234 },
235 kind,
236 signer_user_id: Some(user_id),
237 key_fingerprint: Some(fingerprint),
238 })
239}
240
241fn data_dir(state: &AppState) -> &Path {
242 &state.config.data_dir
243}
244
245fn gnupg_home(state: &AppState) -> PathBuf {
246 data_dir(state).join("gnupg")
247}
248
249fn allowed_signers_path(state: &AppState) -> PathBuf {
250 data_dir(state).join("run").join("allowed_signers")
251}
252
253/// A `gpg` command with a cleared environment and `--batch`. Never run through a shell.
254fn gpg_command(state: &AppState) -> tokio::process::Command {
255 let mut cmd = tokio::process::Command::new("gpg");
256 cmd.env_clear()
257 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
258 .env("GNUPGHOME", gnupg_home(state))
259 .env("LANG", "C")
260 .arg("--batch")
261 .stdin(Stdio::null())
262 .kill_on_drop(true);
263 cmd
264}
265
266/// Whether a `gpg` binary runs at all.
267pub async fn gpg_available(state: &AppState) -> bool {
268 match gpg_command(state).arg("--version").output().await {
269 Ok(out) => out.status.success(),
270 Err(_) => false,
271 }
272}
273
274/// Runs `gpg` with `armored` on stdin and returns its stdout on success.
275async fn gpg_with_input(state: &AppState, args: &[&str], armored: &str) -> Result<Vec<u8>, String> {
276 use tokio::io::AsyncWriteExt;
277
278 let mut cmd = gpg_command(state);
279 cmd.args(args)
280 .stdin(Stdio::piped())
281 .stdout(Stdio::piped())
282 .stderr(Stdio::piped());
283 let mut child = cmd
284 .spawn()
285 .map_err(|e| format!("gpg is not available: {e}"))?;
286 if let Some(mut stdin) = child.stdin.take() {
287 stdin
288 .write_all(armored.as_bytes())
289 .await
290 .map_err(|e| e.to_string())?;
291 drop(stdin);
292 }
293 let out = child.wait_with_output().await.map_err(|e| e.to_string())?;
294 if out.status.success() {
295 Ok(out.stdout)
296 } else {
297 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
298 }
299}
300
301/// Creates the forge keyring directory, mode 0700 on unix.
302async fn ensure_gnupg_home(state: &AppState) -> std::io::Result<()> {
303 let home = gnupg_home(state);
304 tokio::fs::create_dir_all(&home).await?;
305 set_private_dir(&home).await
306}
307
308#[cfg(unix)]
309async fn set_private_dir(path: &Path) -> std::io::Result<()> {
310 use std::os::unix::fs::PermissionsExt;
311 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).await
312}
313
314#[cfg(not(unix))]
315async fn set_private_dir(_path: &Path) -> std::io::Result<()> {
316 Ok(())
317}
318
319/// Reads an armored public key, imports it into the forge keyring and returns its primary
320/// fingerprint and uid emails. The error is a message for the user.
321pub async fn import_gpg_key(
322 state: &AppState,
323 armored: &str,
324) -> Result<(String, Vec<String>), String> {
325 let armored = armored.trim();
326 if armored.len() > MAX_ARMORED_BYTES {
327 return Err("That key is too large.".into());
328 }
329 if !armored.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
330 return Err("Paste an ASCII-armored PGP public key, starting with its BEGIN line.".into());
331 }
332
333 ensure_gnupg_home(state)
334 .await
335 .map_err(|e| format!("cannot prepare the GPG keyring: {e}"))?;
336 let listing = gpg_with_input(
337 state,
338 &["--with-colons", "--import-options", "show-only", "--import"],
339 armored,
340 )
341 .await
342 .map_err(|e| format!("gpg could not read this key: {e}"))?;
343 let (fingerprint, emails) = parse_key_listing(&String::from_utf8_lossy(&listing))?;
344
345 gpg_with_input(state, &["--import"], armored)
346 .await
347 .map_err(|e| format!("gpg could not import this key: {e}"))?;
348 Ok((fingerprint, emails))
349}
350
351/// Reads the `--with-colons` listing of exactly one primary key: its fingerprint and uid emails.
352fn parse_key_listing(text: &str) -> Result<(String, Vec<String>), String> {
353 let mut primaries = 0;
354 let mut fingerprint: Option<String> = None;
355 let mut emails = Vec::new();
356 for line in text.lines() {
357 let fields: Vec<&str> = line.split(':').collect();
358 match fields.first().copied() {
359 Some("pub") => primaries += 1,
360 Some("fpr") if fingerprint.is_none() && primaries == 1 => {
361 fingerprint = fields.get(9).map(|f| (*f).to_ascii_uppercase());
362 }
363 Some("uid") => {
364 if let Some(email) = fields.get(9).and_then(|uid| email_in_uid(uid))
365 && !emails.contains(&email)
366 {
367 emails.push(email);
368 }
369 }
370 _ => {}
371 }
372 }
373 if primaries != 1 {
374 return Err("The block must contain exactly one public key.".into());
375 }
376 let fingerprint =
377 fingerprint.ok_or_else(|| "gpg reported no fingerprint for this key.".to_string())?;
378 if !(fingerprint.len() == 40 || fingerprint.len() == 64)
379 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit())
380 {
381 return Err("gpg reported a fingerprint that is not valid.".into());
382 }
383 if emails.is_empty() {
384 return Err(
385 "The key has no email address in its user ID, so it cannot verify commits.".into(),
386 );
387 }
388 Ok((fingerprint, emails))
389}
390
391/// The address inside `<...>` of a uid string, lowercased. `None` when there is none.
392fn email_in_uid(uid: &str) -> Option<String> {
393 // gpg escapes colons and other odd bytes as \xHH inside the colon listing.
394 let uid = uid.replace("\\x3a", ":");
395 let start = uid.find('<')?;
396 let end = uid[start..].find('>')? + start;
397 let email = uid[start + 1..end].trim().to_ascii_lowercase();
398 if email.is_empty() || email.contains(char::is_whitespace) {
399 None
400 } else {
401 Some(email)
402 }
403}
404
405/// Removes a key from the forge keyring. Failures are logged, not returned: the database row is
406/// the record of truth, and a stale keyring entry is harmless because ownership is checked
407/// against the database.
408pub async fn remove_gpg_key(state: &AppState, fingerprint: &str) {
409 let length_ok = fingerprint.len() == 40 || fingerprint.len() == 64;
410 if !length_ok || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
411 tracing::warn!("refusing to remove a gpg key with a malformed fingerprint");
412 return;
413 }
414 let result = gpg_command(state)
415 .args(["--yes", "--delete-keys", fingerprint])
416 .stdout(Stdio::null())
417 .stderr(Stdio::null())
418 .status()
419 .await;
420 match result {
421 Ok(status) if status.success() => {}
422 Ok(status) => tracing::warn!(%status, "gpg could not remove a key from the keyring"),
423 Err(e) => tracing::warn!(error = %e, "gpg could not be run to remove a key"),
424 }
425}
426
427/// Imports every stored GPG key into `<data_dir>/gnupg`. The keyring is not in backups, so this
428/// runs at boot. Does nothing when `gpg` is not installed. Every key is tried, and the error
429/// lists the keys that failed.
430pub async fn rebuild_gpg_keyring(state: &AppState) -> Result<(), String> {
431 if !gpg_available(state).await {
432 tracing::info!("gpg is not installed, the GPG keyring was not rebuilt");
433 return Ok(());
434 }
435 ensure_gnupg_home(state)
436 .await
437 .map_err(|e| format!("cannot prepare the GPG keyring: {e}"))?;
438 let keys = state
439 .store
440 .all_gpg_keys()
441 .await
442 .map_err(|e| e.to_string())?;
443 let mut failed = Vec::new();
444 for key in &keys {
445 if let Err(e) = gpg_with_input(state, &["--import"], &key.armored).await {
446 failed.push(format!("{}: {e}", key.fingerprint));
447 }
448 }
449 if failed.is_empty() {
450 Ok(())
451 } else {
452 Err(format!("gpg could not import keys: {}", failed.join("; ")))
453 }
454}
455
456/// Rewrites `<data_dir>/run/allowed_signers` from every SSH key of every user, atomically
457/// (written to a temporary file, then renamed over the old one).
458pub async fn rebuild_allowed_signers(state: &AppState) -> Result<(), forge_core::Error> {
459 let users = state.store.list_users().await?;
460 let mut lines = String::new();
461 for user in &users {
462 let principal = user.email.clone().unwrap_or_else(|| user.username.clone());
463 for key in state.store.list_ssh_keys(&user.id).await? {
464 lines.push_str(&format!(
465 "{principal} namespaces=\"git\" {} {}\n",
466 key.algorithm,
467 BASE64_STANDARD.encode(&key.key_blob)
468 ));
469 }
470 }
471
472 let run = data_dir(state).join("run");
473 tokio::fs::create_dir_all(&run).await?;
474 let target = run.join("allowed_signers");
475 static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
476 let tmp = run.join(format!(
477 "allowed_signers.{}.{}.tmp",
478 std::process::id(),
479 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
480 ));
481 tokio::fs::write(&tmp, lines.as_bytes()).await?;
482 set_private(&tmp).await?;
483 tokio::fs::rename(&tmp, &target).await?;
484 Ok(())
485}
486
487#[cfg(unix)]
488async fn set_private(path: &Path) -> std::io::Result<()> {
489 use std::os::unix::fs::PermissionsExt;
490 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await
491}
492
493#[cfg(not(unix))]
494async fn set_private(_path: &Path) -> std::io::Result<()> {
495 Ok(())
496}
497
498/// Called after a user's keys were added or removed. Rebuilds the SSH allowed-signers file and
499/// drops the cached verdicts those keys can change.
500pub async fn on_keys_changed(state: &AppState, user_id: &str) {
501 if let Err(e) = rebuild_allowed_signers(state).await {
502 tracing::error!(error = %e, "rebuilding allowed_signers failed");
503 }
504 if let Err(e) = state.store.invalidate_signatures_for_user(user_id).await {
505 tracing::error!(error = %e, "invalidating signature cache failed");
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::test_support::test_state;
513 use forge_core::model::{User, Visibility};
514 use std::process::Command;
515
516 /// A git call for test setup, with no user or system config.
517 fn git(home: &Path, dir: &Path, args: &[&str]) -> String {
518 let out = Command::new("git")
519 .arg("-C")
520 .arg(dir)
521 .args(args)
522 .env_clear()
523 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
524 .env("HOME", home)
525 .env("GIT_CONFIG_NOSYSTEM", "1")
526 .env("GIT_CONFIG_GLOBAL", home.join("gitconfig"))
527 .output()
528 .expect("run git");
529 assert!(
530 out.status.success(),
531 "git {args:?}: {}",
532 String::from_utf8_lossy(&out.stderr)
533 );
534 String::from_utf8_lossy(&out.stdout).trim().to_string()
535 }
536
537 /// Makes an ed25519 key without a passphrase. Returns the `.pub` path.
538 fn ssh_keygen(home: &Path, name: &str) -> PathBuf {
539 let key = home.join(name);
540 let status = Command::new("ssh-keygen")
541 .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"])
542 .arg(&key)
543 .status()
544 .expect("run ssh-keygen");
545 assert!(status.success());
546 home.join(format!("{name}.pub"))
547 }
548
549 /// `(algorithm, base64 blob)` from a `.pub` file.
550 fn pub_parts(pub_path: &Path) -> (String, String) {
551 let text = std::fs::read_to_string(pub_path).unwrap();
552 let mut fields = text.split_whitespace();
553 (
554 fields.next().unwrap().to_string(),
555 fields.next().unwrap().to_string(),
556 )
557 }
558
559 fn ssh_fingerprint(pub_path: &Path) -> String {
560 let out = Command::new("ssh-keygen")
561 .arg("-lf")
562 .arg(pub_path)
563 .output()
564 .unwrap();
565 String::from_utf8_lossy(&out.stdout)
566 .split_whitespace()
567 .nth(1)
568 .unwrap()
569 .to_string()
570 }
571
572 struct Fixture {
573 state: AppState,
574 repo: Repo,
575 alice: User,
576 work: PathBuf,
577 home: PathBuf,
578 /// The alice key's `.pub` file.
579 pub_path: PathBuf,
580 key_id: String,
581 base_oid: String,
582 _dir: tempfile::TempDir,
583 }
584
585 /// A forge with alice (email alice@example.com), an SSH key of hers, a bare repo, and one
586 /// unsigned commit pushed to it.
587 async fn fixture() -> Fixture {
588 let dir = tempfile::tempdir().unwrap();
589 let home = dir.path().to_path_buf();
590 let state = test_state(&home).await;
591 let alice = state
592 .store
593 .create_user("alice", Some("alice@example.com"), "hash", false)
594 .await
595 .unwrap();
596 let repo = state
597 .store
598 .create_repo(&alice.id, "proj", "", Visibility::Public, "main")
599 .await
600 .unwrap();
601 let bare = forge_git::repo::repo_path(&home, &repo.id).unwrap();
602 forge_git::repo::init_bare(&state.git, &bare, "main")
603 .await
604 .unwrap();
605
606 let work = home.join("work");
607 std::fs::create_dir_all(&work).unwrap();
608 git(&home, &work, &["init", "-q", "-b", "main"]);
609 git(&home, &work, &["config", "user.name", "Alice"]);
610 git(&home, &work, &["config", "user.email", "alice@example.com"]);
611 std::fs::write(work.join("a.txt"), "a\n").unwrap();
612 git(&home, &work, &["add", "a.txt"]);
613 git(
614 &home,
615 &work,
616 &["commit", "-q", "--no-gpg-sign", "-m", "base"],
617 );
618 let base_oid = git(&home, &work, &["rev-parse", "HEAD"]);
619 git(
620 &home,
621 &work,
622 &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"],
623 );
624
625 let pub_path = ssh_keygen(&home, "alice");
626 let (algo, blob_b64) = pub_parts(&pub_path);
627 let blob = BASE64_STANDARD.decode(blob_b64).unwrap();
628 let key = state
629 .store
630 .add_ssh_key(
631 &alice.id,
632 "laptop",
633 &algo,
634 &blob,
635 &ssh_fingerprint(&pub_path),
636 )
637 .await
638 .unwrap();
639 on_keys_changed(&state, &alice.id).await;
640
641 Fixture {
642 state,
643 repo,
644 alice,
645 work,
646 home,
647 pub_path,
648 key_id: key.id,
649 base_oid,
650 _dir: dir,
651 }
652 }
653
654 /// Commits a file signed with the alice key, with `committer` as the committer email, and
655 /// pushes it. Returns the commit id.
656 fn signed_commit(f: &Fixture, committer: &str) -> String {
657 let n = git(&f.home, &f.work, &["rev-list", "--count", "HEAD"]);
658 std::fs::write(f.work.join("f.txt"), format!("{n} {committer}\n")).unwrap();
659 git(&f.home, &f.work, &["add", "f.txt"]);
660 let signing = format!("user.signingkey={}", f.pub_path.display());
661 git(
662 &f.home,
663 &f.work,
664 &[
665 "-c",
666 "gpg.format=ssh",
667 "-c",
668 &signing,
669 "-c",
670 "commit.gpgsign=true",
671 "-c",
672 &format!("user.email={committer}"),
673 "commit",
674 "-q",
675 "-m",
676 "signed",
677 ],
678 );
679 let oid = git(&f.home, &f.work, &["rev-parse", "HEAD"]);
680 let bare = forge_git::repo::repo_path(&f.home, &f.repo.id).unwrap();
681 git(
682 &f.home,
683 &f.work,
684 &[
685 "push",
686 "-q",
687 "-f",
688 bare.to_str().unwrap(),
689 "HEAD:refs/heads/main",
690 ],
691 );
692 oid
693 }
694
695 #[tokio::test]
696 async fn ssh_signature_by_a_user_with_matching_email_is_verified() {
697 let f = fixture().await;
698 let oid = signed_commit(&f, "alice@example.com");
699
700 let badges = badges(&f.state, &f.repo, &[f.base_oid.clone(), oid.clone()]).await;
701 assert_eq!(badges.len(), 1, "the unsigned base commit has no badge");
702 assert_eq!(
703 badges.get(&oid),
704 Some(&SigBadge::Verified {
705 signer: "alice".into(),
706 key: ssh_fingerprint(&f.pub_path),
707 })
708 );
709 }
710
711 #[tokio::test]
712 async fn committer_email_mismatch_is_unverified() {
713 let f = fixture().await;
714 let oid = signed_commit(&f, "bob@example.com");
715
716 let badges = badges(&f.state, &f.repo, std::slice::from_ref(&oid)).await;
717 assert!(
718 matches!(badges.get(&oid), Some(SigBadge::Unverified { .. })),
719 "{badges:?}"
720 );
721 }
722
723 #[tokio::test]
724 async fn unsigned_commits_are_omitted() {
725 let f = fixture().await;
726 let badges = badges(&f.state, &f.repo, std::slice::from_ref(&f.base_oid)).await;
727 assert!(badges.is_empty());
728 let cached = f
729 .state
730 .store
731 .cached_signatures(&f.repo.id, std::slice::from_ref(&f.base_oid))
732 .await
733 .unwrap();
734 assert_eq!(cached[&f.base_oid].status, SigStatus::Unsigned);
735 }
736
737 #[tokio::test]
738 async fn results_are_cached_and_invalidated_by_key_changes() {
739 let f = fixture().await;
740 let oid = signed_commit(&f, "alice@example.com");
741 let oids = std::slice::from_ref(&oid);
742
743 let first = badges(&f.state, &f.repo, oids).await;
744 assert!(matches!(first.get(&oid), Some(SigBadge::Verified { .. })));
745 let cached = f
746 .state
747 .store
748 .cached_signatures(&f.repo.id, oids)
749 .await
750 .unwrap();
751 assert_eq!(cached[&oid].status, SigStatus::Verified);
752
753 // With the signers file gone, a fresh check could not verify. A cache hit still answers.
754 std::fs::remove_file(allowed_signers_path(&f.state)).unwrap();
755 let hit = badges(&f.state, &f.repo, oids).await;
756 assert_eq!(hit, first);
757
758 // Removing the key invalidates the signer's rows. Re-checking now finds no key.
759 f.state
760 .store
761 .delete_ssh_key(&f.alice.id, &f.key_id)
762 .await
763 .unwrap();
764 on_keys_changed(&f.state, &f.alice.id).await;
765 let cached = f
766 .state
767 .store
768 .cached_signatures(&f.repo.id, oids)
769 .await
770 .unwrap();
771 assert!(cached.is_empty(), "signer rows were not invalidated");
772
773 let after = badges(&f.state, &f.repo, oids).await;
774 assert!(
775 matches!(after.get(&oid), Some(SigBadge::Unverified { .. })),
776 "{after:?}"
777 );
778 let cached = f
779 .state
780 .store
781 .cached_signatures(&f.repo.id, oids)
782 .await
783 .unwrap();
784 assert_eq!(cached[&oid].status, SigStatus::UnknownKey);
785 }
786
787 #[test]
788 fn key_listing_yields_primary_fingerprint_and_emails() {
789 let listing = "pub:u:255:22:ABCDEF0123456789ABCDEF0123456789ABCDEF01:1700000000::::::23::0:\n\
790fpr:::::::::ABCDEF0123456789ABCDEF0123456789ABCDEF01:\n\
791uid:u::::1700000000::HASH::Alice <Alice@Example.com>::::::::::0:\n\
792uid:u::::1700000000::HASH::Alice Work <work@example.org>::::::::::0:\n\
793sub:u:255:18:1111111111111111111111111111111111111111:1700000000:::::e::::::23:\n\
794fpr:::::::::1111111111111111111111111111111111111111:\n";
795 let (fp, emails) = parse_key_listing(listing).unwrap();
796 assert_eq!(fp, "ABCDEF0123456789ABCDEF0123456789ABCDEF01");
797 assert_eq!(emails, vec!["alice@example.com", "work@example.org"]);
798 }
799
800 #[test]
801 fn key_listing_rejects_two_keys_and_missing_email() {
802 let two = "pub:u:255:22:AAAA:1::::::23::0:\nfpr:::::::::AAAA:\npub:u:255:22:BBBB:1::::::23::0:\nfpr:::::::::BBBB:\n";
803 assert!(parse_key_listing(two).is_err());
804 let no_mail = "pub:u:255:22:AAAA:1::::::23::0:\nfpr:::::::::AAAA:\nuid:u::::1::HASH::Nobody::::::::::0:\n";
805 assert!(parse_key_listing(no_mail).is_err());
806 }
807
808 /// Runs `gpg` against `home` with `--batch`, asserting success. Returns stdout.
809 fn gpg_run(home: &Path, args: &[&str]) -> Vec<u8> {
810 let out = Command::new("gpg")
811 .env("GNUPGHOME", home)
812 .arg("--batch")
813 .args(args)
814 .output()
815 .unwrap();
816 assert!(
817 out.status.success(),
818 "gpg {args:?}: {}",
819 String::from_utf8_lossy(&out.stderr)
820 );
821 out.stdout
822 }
823
824 /// A GPG key stored in the database verifies after the managed keyring is lost and rebuilt.
825 /// Skipped when `gpg` is not installed.
826 #[tokio::test]
827 async fn gpg_signature_verifies_after_the_keyring_is_rebuilt() {
828 if Command::new("gpg").arg("--version").output().is_err() {
829 eprintln!("skipping gpg test: gpg is not installed");
830 return;
831 }
832 let f = fixture().await;
833 let keygen = f.home.join("keygen");
834 std::fs::create_dir_all(&keygen).unwrap();
835 std::fs::set_permissions(&keygen, std::os::unix::fs::PermissionsExt::from_mode(0o700))
836 .unwrap();
837 let email = "alice@example.com";
838 gpg_run(
839 &keygen,
840 &[
841 "--passphrase",
842 "",
843 "--quick-gen-key",
844 email,
845 "default",
846 "default",
847 "never",
848 ],
849 );
850 let listing = gpg_run(&keygen, &["--with-colons", "--list-keys", email]);
851 let fpr = String::from_utf8_lossy(&listing)
852 .lines()
853 .find(|l| l.starts_with("fpr:"))
854 .and_then(|l| l.split(':').nth(9))
855 .unwrap()
856 .to_string();
857 let armored = String::from_utf8(gpg_run(&keygen, &["--armor", "--export", &fpr])).unwrap();
858 f.state
859 .store
860 .add_gpg_key(&f.alice.id, &fpr, &armored, &[email.to_string()])
861 .await
862 .unwrap();
863
864 std::fs::write(f.work.join("gpg.txt"), "gpg\n").unwrap();
865 git(&f.home, &f.work, &["add", "gpg.txt"]);
866 let commit = Command::new("git")
867 .arg("-C")
868 .arg(&f.work)
869 .args([
870 "-c",
871 &format!("user.signingkey={fpr}"),
872 "-c",
873 "commit.gpgsign=true",
874 "-c",
875 &format!("user.email={email}"),
876 "commit",
877 "-q",
878 "-m",
879 "gpg signed",
880 ])
881 .env_clear()
882 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
883 .env("HOME", &f.home)
884 .env("GNUPGHOME", &keygen)
885 .env("GIT_CONFIG_NOSYSTEM", "1")
886 .env("GIT_CONFIG_GLOBAL", f.home.join("gitconfig"))
887 .output()
888 .unwrap();
889 assert!(
890 commit.status.success(),
891 "{}",
892 String::from_utf8_lossy(&commit.stderr)
893 );
894 let oid = git(&f.home, &f.work, &["rev-parse", "HEAD"]);
895 let bare = forge_git::repo::repo_path(&f.home, &f.repo.id).unwrap();
896 git(
897 &f.home,
898 &f.work,
899 &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"],
900 );
901
902 // The managed keyring is lost, as after a restore from a backup without gnupg/.
903 let gnupg = gnupg_home(&f.state);
904 let _ = std::fs::remove_dir_all(&gnupg);
905 assert!(!gnupg.exists());
906 rebuild_gpg_keyring(&f.state).await.unwrap();
907
908 let result = badges(&f.state, &f.repo, std::slice::from_ref(&oid)).await;
909 // Stop the agents that gpg started for both keyrings.
910 for home in [&keygen, &gnupg] {
911 let _ = Command::new("gpgconf")
912 .env("GNUPGHOME", home)
913 .args(["--kill", "gpg-agent"])
914 .output();
915 }
916 assert_eq!(
917 result.get(&oid),
918 Some(&SigBadge::Verified {
919 signer: "alice".into(),
920 key: fpr,
921 }),
922 "{result:?}"
923 );
924 }
925}