//! Signed commit badges, and the key material behind them. //! //! Verification asks git (`forge-git::verify`) what signed a commit, then matches the signing //! key to a forge user. A commit is Verified when the key belongs to a user and the committer //! email is that user's email (SSH) or one of the key's uid emails (GPG). Results are cached in //! `commit_signatures`. SSH keys are checked against `/run/allowed_signers`, and GPG //! keys against the forge-managed keyring in `/gnupg`. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use base64::prelude::{BASE64_STANDARD, Engine as _}; use forge_core::model::Repo; use forge_core::store::signatures::{CachedSig, SigKind, SigStatus}; use forge_git::verify::{RawVerify, committer_email, verify_commit}; use tokio::time::Instant; use crate::state::AppState; /// Most commits one call looks at. The commit list and the commit page show at most this many. const MAX_COMMITS: usize = 50; /// Total time allowed for uncached verification in one call. const BUDGET: Duration = Duration::from_secs(5); /// Largest armored key accepted for import. const MAX_ARMORED_BYTES: usize = 64 * 1024; /// What the UI shows for a commit. Unsigned commits have no badge at all. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SigBadge { Verified { signer: String, key: String }, Unverified { reason: String }, } /// Badges for the given commits of `repo`. Unsigned commits are omitted. Cached results are read /// first. Uncached commits are verified inline, within [`BUDGET`] in total, and only the first /// [`MAX_COMMITS`] are considered. Commits that are not reached in time are omitted, and they are /// checked the next time. This never fails: problems are logged and the commit is left out. pub async fn badges(state: &AppState, repo: &Repo, oids: &[String]) -> HashMap { let oids = &oids[..oids.len().min(MAX_COMMITS)]; let mut out = HashMap::new(); let cached = match state.store.cached_signatures(&repo.id, oids).await { Ok(c) => c, Err(e) => { tracing::warn!(error = %e, repo = %repo.id, "reading cached signatures failed"); HashMap::new() } }; let mut uncached = Vec::new(); for oid in oids { match cached.get(oid) { Some(sig) => { if let Some(badge) = badge_for(state, sig).await { out.insert(oid.clone(), badge); } } None => uncached.push(oid.clone()), } } if uncached.is_empty() { return out; } let deadline = Instant::now() + BUDGET; let gpg_ok = tokio::time::timeout_at(deadline, gpg_available(state)) .await .unwrap_or(false); let signers = allowed_signers_path(state); let gnupg = gnupg_home(state); for oid in uncached { let step = tokio::time::timeout_at( deadline, check_commit(state, repo, &oid, &signers, &gnupg, gpg_ok), ); match step.await { Err(_) => break, Ok(Err(e)) => tracing::warn!(error = %e, oid = %oid, "signature check failed"), Ok(Ok((sig, cacheable))) => { if cacheable && let Err(e) = state.store.put_signature(&repo.id, &oid, &sig).await { tracing::warn!(error = %e, oid = %oid, "caching signature failed"); } if let Some(badge) = badge_for(state, &sig).await { out.insert(oid, badge); } } } } out } /// The badge for a stored result. `None` for unsigned commits. async fn badge_for(state: &AppState, sig: &CachedSig) -> Option { match sig.status { SigStatus::Unsigned => None, SigStatus::Verified => { let key = sig.key_fingerprint.clone().unwrap_or_default(); let signer = match &sig.signer_user_id { Some(id) => state.store.user_by_id(id).await.ok().flatten(), None => None, }; Some(match signer { Some(user) => SigBadge::Verified { signer: user.username, key, }, None => SigBadge::Unverified { reason: "the signing key's owner no longer exists".into(), }, }) } SigStatus::Unverified => Some(SigBadge::Unverified { reason: "the committer email does not match the signing key's owner".into(), }), SigStatus::UnknownKey => Some(SigBadge::Unverified { reason: "the signing key is not registered on this forge".into(), }), SigStatus::Bad => Some(SigBadge::Unverified { reason: "the signature does not verify".into(), }), } } /// Verifies one commit. Returns the result and whether it may be cached. A GPG commit is not /// cacheable while `gpg` is missing, so it is checked again once gpg is installed. async fn check_commit( state: &AppState, repo: &Repo, oid: &str, signers: &Path, gnupg: &Path, gpg_ok: bool, ) -> Result<(CachedSig, bool), String> { let repo_dir = forge_git::repo::repo_path(&state.git.data_dir, &repo.id).map_err(|e| e.to_string())?; let raw = verify_commit(&state.git, &repo_dir, oid, signers, Some(gnupg)) .await .map_err(|e| e.to_string())?; if !raw.signed { return Ok((plain(SigStatus::Unsigned, None), true)); } if raw.kind == Some(SigKind::Gpg) && !gpg_ok { return Ok((plain(SigStatus::UnknownKey, None), false)); } let email = committer_email(&state.git, &repo_dir, oid) .await .map_err(|e| e.to_string())?; Ok((classify(state, &raw, &email).await?, true)) } fn plain(status: SigStatus, kind: Option) -> CachedSig { CachedSig { status, kind, signer_user_id: None, key_fingerprint: None, } } /// Turns git's raw answer into a stored status, matching the key against forge's keys. async fn classify(state: &AppState, raw: &RawVerify, committer: &str) -> Result { let kind = raw.kind; if raw.bad { return Ok(CachedSig { key_fingerprint: raw.fingerprint.clone(), ..plain(SigStatus::Bad, kind) }); } let Some(fingerprint) = raw.fingerprint.clone() else { return Ok(plain(SigStatus::UnknownKey, kind)); }; if !raw.good { let status = if raw.missing_key { SigStatus::UnknownKey } else { SigStatus::Bad }; return Ok(CachedSig { key_fingerprint: Some(fingerprint), ..plain(status, kind) }); } let owner = match kind { Some(SigKind::Ssh) => { let key = state .store .ssh_key_by_fingerprint(&fingerprint) .await .map_err(|e| e.to_string())?; let Some(key) = key else { return Ok(CachedSig { key_fingerprint: Some(fingerprint), ..plain(SigStatus::UnknownKey, kind) }); }; let user = state .store .user_by_id(&key.user_id) .await .map_err(|e| e.to_string())?; let email_ok = user .as_ref() .and_then(|u| u.email.as_deref()) .is_some_and(|e| e.eq_ignore_ascii_case(committer)); (key.user_id, email_ok) } Some(SigKind::Gpg) => { let key = state .store .gpg_key_by_fingerprint(&fingerprint) .await .map_err(|e| e.to_string())?; let Some(key) = key else { return Ok(CachedSig { key_fingerprint: Some(fingerprint), ..plain(SigStatus::UnknownKey, kind) }); }; let email_ok = key.emails.iter().any(|e| e.eq_ignore_ascii_case(committer)); (key.user_id, email_ok) } None => return Ok(plain(SigStatus::UnknownKey, kind)), }; let (user_id, email_ok) = owner; Ok(CachedSig { status: if email_ok { SigStatus::Verified } else { SigStatus::Unverified }, kind, signer_user_id: Some(user_id), key_fingerprint: Some(fingerprint), }) } fn data_dir(state: &AppState) -> &Path { &state.config.data_dir } fn gnupg_home(state: &AppState) -> PathBuf { data_dir(state).join("gnupg") } fn allowed_signers_path(state: &AppState) -> PathBuf { data_dir(state).join("run").join("allowed_signers") } /// A `gpg` command with a cleared environment and `--batch`. Never run through a shell. fn gpg_command(state: &AppState) -> tokio::process::Command { let mut cmd = tokio::process::Command::new("gpg"); cmd.env_clear() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("GNUPGHOME", gnupg_home(state)) .env("LANG", "C") .arg("--batch") .stdin(Stdio::null()) .kill_on_drop(true); cmd } /// Whether a `gpg` binary runs at all. pub async fn gpg_available(state: &AppState) -> bool { match gpg_command(state).arg("--version").output().await { Ok(out) => out.status.success(), Err(_) => false, } } /// Runs `gpg` with `armored` on stdin and returns its stdout on success. async fn gpg_with_input(state: &AppState, args: &[&str], armored: &str) -> Result, String> { use tokio::io::AsyncWriteExt; let mut cmd = gpg_command(state); cmd.args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd .spawn() .map_err(|e| format!("gpg is not available: {e}"))?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(armored.as_bytes()) .await .map_err(|e| e.to_string())?; drop(stdin); } let out = child.wait_with_output().await.map_err(|e| e.to_string())?; if out.status.success() { Ok(out.stdout) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } } /// Creates the forge keyring directory, mode 0700 on unix. async fn ensure_gnupg_home(state: &AppState) -> std::io::Result<()> { let home = gnupg_home(state); tokio::fs::create_dir_all(&home).await?; set_private_dir(&home).await } #[cfg(unix)] async fn set_private_dir(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).await } #[cfg(not(unix))] async fn set_private_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } /// Reads an armored public key, imports it into the forge keyring and returns its primary /// fingerprint and uid emails. The error is a message for the user. pub async fn import_gpg_key( state: &AppState, armored: &str, ) -> Result<(String, Vec), String> { let armored = armored.trim(); if armored.len() > MAX_ARMORED_BYTES { return Err("That key is too large.".into()); } if !armored.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") { return Err("Paste an ASCII-armored PGP public key, starting with its BEGIN line.".into()); } ensure_gnupg_home(state) .await .map_err(|e| format!("cannot prepare the GPG keyring: {e}"))?; let listing = gpg_with_input( state, &["--with-colons", "--import-options", "show-only", "--import"], armored, ) .await .map_err(|e| format!("gpg could not read this key: {e}"))?; let (fingerprint, emails) = parse_key_listing(&String::from_utf8_lossy(&listing))?; gpg_with_input(state, &["--import"], armored) .await .map_err(|e| format!("gpg could not import this key: {e}"))?; Ok((fingerprint, emails)) } /// Reads the `--with-colons` listing of exactly one primary key: its fingerprint and uid emails. fn parse_key_listing(text: &str) -> Result<(String, Vec), String> { let mut primaries = 0; let mut fingerprint: Option = None; let mut emails = Vec::new(); for line in text.lines() { let fields: Vec<&str> = line.split(':').collect(); match fields.first().copied() { Some("pub") => primaries += 1, Some("fpr") if fingerprint.is_none() && primaries == 1 => { fingerprint = fields.get(9).map(|f| (*f).to_ascii_uppercase()); } Some("uid") => { if let Some(email) = fields.get(9).and_then(|uid| email_in_uid(uid)) && !emails.contains(&email) { emails.push(email); } } _ => {} } } if primaries != 1 { return Err("The block must contain exactly one public key.".into()); } let fingerprint = fingerprint.ok_or_else(|| "gpg reported no fingerprint for this key.".to_string())?; if !(fingerprint.len() == 40 || fingerprint.len() == 64) || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) { return Err("gpg reported a fingerprint that is not valid.".into()); } if emails.is_empty() { return Err( "The key has no email address in its user ID, so it cannot verify commits.".into(), ); } Ok((fingerprint, emails)) } /// The address inside `<...>` of a uid string, lowercased. `None` when there is none. fn email_in_uid(uid: &str) -> Option { // gpg escapes colons and other odd bytes as \xHH inside the colon listing. let uid = uid.replace("\\x3a", ":"); let start = uid.find('<')?; let end = uid[start..].find('>')? + start; let email = uid[start + 1..end].trim().to_ascii_lowercase(); if email.is_empty() || email.contains(char::is_whitespace) { None } else { Some(email) } } /// Removes a key from the forge keyring. Failures are logged, not returned: the database row is /// the record of truth, and a stale keyring entry is harmless because ownership is checked /// against the database. pub async fn remove_gpg_key(state: &AppState, fingerprint: &str) { let length_ok = fingerprint.len() == 40 || fingerprint.len() == 64; if !length_ok || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) { tracing::warn!("refusing to remove a gpg key with a malformed fingerprint"); return; } let result = gpg_command(state) .args(["--yes", "--delete-keys", fingerprint]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await; match result { Ok(status) if status.success() => {} Ok(status) => tracing::warn!(%status, "gpg could not remove a key from the keyring"), Err(e) => tracing::warn!(error = %e, "gpg could not be run to remove a key"), } } /// Imports every stored GPG key into `/gnupg`. The keyring is not in backups, so this /// runs at boot. Does nothing when `gpg` is not installed. Every key is tried, and the error /// lists the keys that failed. pub async fn rebuild_gpg_keyring(state: &AppState) -> Result<(), String> { if !gpg_available(state).await { tracing::info!("gpg is not installed, the GPG keyring was not rebuilt"); return Ok(()); } ensure_gnupg_home(state) .await .map_err(|e| format!("cannot prepare the GPG keyring: {e}"))?; let keys = state .store .all_gpg_keys() .await .map_err(|e| e.to_string())?; let mut failed = Vec::new(); for key in &keys { if let Err(e) = gpg_with_input(state, &["--import"], &key.armored).await { failed.push(format!("{}: {e}", key.fingerprint)); } } if failed.is_empty() { Ok(()) } else { Err(format!("gpg could not import keys: {}", failed.join("; "))) } } /// Rewrites `/run/allowed_signers` from every SSH key of every user, atomically /// (written to a temporary file, then renamed over the old one). pub async fn rebuild_allowed_signers(state: &AppState) -> Result<(), forge_core::Error> { let users = state.store.list_users().await?; let mut lines = String::new(); for user in &users { let principal = user.email.clone().unwrap_or_else(|| user.username.clone()); for key in state.store.list_ssh_keys(&user.id).await? { lines.push_str(&format!( "{principal} namespaces=\"git\" {} {}\n", key.algorithm, BASE64_STANDARD.encode(&key.key_blob) )); } } let run = data_dir(state).join("run"); tokio::fs::create_dir_all(&run).await?; let target = run.join("allowed_signers"); static TMP_SEQ: AtomicU64 = AtomicU64::new(0); let tmp = run.join(format!( "allowed_signers.{}.{}.tmp", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::Relaxed) )); tokio::fs::write(&tmp, lines.as_bytes()).await?; set_private(&tmp).await?; tokio::fs::rename(&tmp, &target).await?; Ok(()) } #[cfg(unix)] async fn set_private(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await } #[cfg(not(unix))] async fn set_private(_path: &Path) -> std::io::Result<()> { Ok(()) } /// Called after a user's keys were added or removed. Rebuilds the SSH allowed-signers file and /// drops the cached verdicts those keys can change. pub async fn on_keys_changed(state: &AppState, user_id: &str) { if let Err(e) = rebuild_allowed_signers(state).await { tracing::error!(error = %e, "rebuilding allowed_signers failed"); } if let Err(e) = state.store.invalidate_signatures_for_user(user_id).await { tracing::error!(error = %e, "invalidating signature cache failed"); } } #[cfg(test)] mod tests { use super::*; use crate::test_support::test_state; use forge_core::model::{User, Visibility}; use std::process::Command; /// A git call for test setup, with no user or system config. fn git(home: &Path, dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .arg("-C") .arg(dir) .args(args) .env_clear() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("HOME", home) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", home.join("gitconfig")) .output() .expect("run git"); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } /// Makes an ed25519 key without a passphrase. Returns the `.pub` path. fn ssh_keygen(home: &Path, name: &str) -> PathBuf { let key = home.join(name); let status = Command::new("ssh-keygen") .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"]) .arg(&key) .status() .expect("run ssh-keygen"); assert!(status.success()); home.join(format!("{name}.pub")) } /// `(algorithm, base64 blob)` from a `.pub` file. fn pub_parts(pub_path: &Path) -> (String, String) { let text = std::fs::read_to_string(pub_path).unwrap(); let mut fields = text.split_whitespace(); ( fields.next().unwrap().to_string(), fields.next().unwrap().to_string(), ) } fn ssh_fingerprint(pub_path: &Path) -> String { let out = Command::new("ssh-keygen") .arg("-lf") .arg(pub_path) .output() .unwrap(); String::from_utf8_lossy(&out.stdout) .split_whitespace() .nth(1) .unwrap() .to_string() } struct Fixture { state: AppState, repo: Repo, alice: User, work: PathBuf, home: PathBuf, /// The alice key's `.pub` file. pub_path: PathBuf, key_id: String, base_oid: String, _dir: tempfile::TempDir, } /// A forge with alice (email alice@example.com), an SSH key of hers, a bare repo, and one /// unsigned commit pushed to it. async fn fixture() -> Fixture { let dir = tempfile::tempdir().unwrap(); let home = dir.path().to_path_buf(); let state = test_state(&home).await; let alice = state .store .create_user("alice", Some("alice@example.com"), "hash", false) .await .unwrap(); let repo = state .store .create_repo(&alice.id, "proj", "", Visibility::Public, "main") .await .unwrap(); let bare = forge_git::repo::repo_path(&home, &repo.id).unwrap(); forge_git::repo::init_bare(&state.git, &bare, "main") .await .unwrap(); let work = home.join("work"); std::fs::create_dir_all(&work).unwrap(); git(&home, &work, &["init", "-q", "-b", "main"]); git(&home, &work, &["config", "user.name", "Alice"]); git(&home, &work, &["config", "user.email", "alice@example.com"]); std::fs::write(work.join("a.txt"), "a\n").unwrap(); git(&home, &work, &["add", "a.txt"]); git( &home, &work, &["commit", "-q", "--no-gpg-sign", "-m", "base"], ); let base_oid = git(&home, &work, &["rev-parse", "HEAD"]); git( &home, &work, &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"], ); let pub_path = ssh_keygen(&home, "alice"); let (algo, blob_b64) = pub_parts(&pub_path); let blob = BASE64_STANDARD.decode(blob_b64).unwrap(); let key = state .store .add_ssh_key( &alice.id, "laptop", &algo, &blob, &ssh_fingerprint(&pub_path), ) .await .unwrap(); on_keys_changed(&state, &alice.id).await; Fixture { state, repo, alice, work, home, pub_path, key_id: key.id, base_oid, _dir: dir, } } /// Commits a file signed with the alice key, with `committer` as the committer email, and /// pushes it. Returns the commit id. fn signed_commit(f: &Fixture, committer: &str) -> String { let n = git(&f.home, &f.work, &["rev-list", "--count", "HEAD"]); std::fs::write(f.work.join("f.txt"), format!("{n} {committer}\n")).unwrap(); git(&f.home, &f.work, &["add", "f.txt"]); let signing = format!("user.signingkey={}", f.pub_path.display()); git( &f.home, &f.work, &[ "-c", "gpg.format=ssh", "-c", &signing, "-c", "commit.gpgsign=true", "-c", &format!("user.email={committer}"), "commit", "-q", "-m", "signed", ], ); let oid = git(&f.home, &f.work, &["rev-parse", "HEAD"]); let bare = forge_git::repo::repo_path(&f.home, &f.repo.id).unwrap(); git( &f.home, &f.work, &[ "push", "-q", "-f", bare.to_str().unwrap(), "HEAD:refs/heads/main", ], ); oid } #[tokio::test] async fn ssh_signature_by_a_user_with_matching_email_is_verified() { let f = fixture().await; let oid = signed_commit(&f, "alice@example.com"); let badges = badges(&f.state, &f.repo, &[f.base_oid.clone(), oid.clone()]).await; assert_eq!(badges.len(), 1, "the unsigned base commit has no badge"); assert_eq!( badges.get(&oid), Some(&SigBadge::Verified { signer: "alice".into(), key: ssh_fingerprint(&f.pub_path), }) ); } #[tokio::test] async fn committer_email_mismatch_is_unverified() { let f = fixture().await; let oid = signed_commit(&f, "bob@example.com"); let badges = badges(&f.state, &f.repo, std::slice::from_ref(&oid)).await; assert!( matches!(badges.get(&oid), Some(SigBadge::Unverified { .. })), "{badges:?}" ); } #[tokio::test] async fn unsigned_commits_are_omitted() { let f = fixture().await; let badges = badges(&f.state, &f.repo, std::slice::from_ref(&f.base_oid)).await; assert!(badges.is_empty()); let cached = f .state .store .cached_signatures(&f.repo.id, std::slice::from_ref(&f.base_oid)) .await .unwrap(); assert_eq!(cached[&f.base_oid].status, SigStatus::Unsigned); } #[tokio::test] async fn results_are_cached_and_invalidated_by_key_changes() { let f = fixture().await; let oid = signed_commit(&f, "alice@example.com"); let oids = std::slice::from_ref(&oid); let first = badges(&f.state, &f.repo, oids).await; assert!(matches!(first.get(&oid), Some(SigBadge::Verified { .. }))); let cached = f .state .store .cached_signatures(&f.repo.id, oids) .await .unwrap(); assert_eq!(cached[&oid].status, SigStatus::Verified); // With the signers file gone, a fresh check could not verify. A cache hit still answers. std::fs::remove_file(allowed_signers_path(&f.state)).unwrap(); let hit = badges(&f.state, &f.repo, oids).await; assert_eq!(hit, first); // Removing the key invalidates the signer's rows. Re-checking now finds no key. f.state .store .delete_ssh_key(&f.alice.id, &f.key_id) .await .unwrap(); on_keys_changed(&f.state, &f.alice.id).await; let cached = f .state .store .cached_signatures(&f.repo.id, oids) .await .unwrap(); assert!(cached.is_empty(), "signer rows were not invalidated"); let after = badges(&f.state, &f.repo, oids).await; assert!( matches!(after.get(&oid), Some(SigBadge::Unverified { .. })), "{after:?}" ); let cached = f .state .store .cached_signatures(&f.repo.id, oids) .await .unwrap(); assert_eq!(cached[&oid].status, SigStatus::UnknownKey); } #[test] fn key_listing_yields_primary_fingerprint_and_emails() { let listing = "pub:u:255:22:ABCDEF0123456789ABCDEF0123456789ABCDEF01:1700000000::::::23::0:\n\ fpr:::::::::ABCDEF0123456789ABCDEF0123456789ABCDEF01:\n\ uid:u::::1700000000::HASH::Alice ::::::::::0:\n\ uid:u::::1700000000::HASH::Alice Work ::::::::::0:\n\ sub:u:255:18:1111111111111111111111111111111111111111:1700000000:::::e::::::23:\n\ fpr:::::::::1111111111111111111111111111111111111111:\n"; let (fp, emails) = parse_key_listing(listing).unwrap(); assert_eq!(fp, "ABCDEF0123456789ABCDEF0123456789ABCDEF01"); assert_eq!(emails, vec!["alice@example.com", "work@example.org"]); } #[test] fn key_listing_rejects_two_keys_and_missing_email() { let two = "pub:u:255:22:AAAA:1::::::23::0:\nfpr:::::::::AAAA:\npub:u:255:22:BBBB:1::::::23::0:\nfpr:::::::::BBBB:\n"; assert!(parse_key_listing(two).is_err()); let no_mail = "pub:u:255:22:AAAA:1::::::23::0:\nfpr:::::::::AAAA:\nuid:u::::1::HASH::Nobody::::::::::0:\n"; assert!(parse_key_listing(no_mail).is_err()); } /// Runs `gpg` against `home` with `--batch`, asserting success. Returns stdout. fn gpg_run(home: &Path, args: &[&str]) -> Vec { let out = Command::new("gpg") .env("GNUPGHOME", home) .arg("--batch") .args(args) .output() .unwrap(); assert!( out.status.success(), "gpg {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); out.stdout } /// A GPG key stored in the database verifies after the managed keyring is lost and rebuilt. /// Skipped when `gpg` is not installed. #[tokio::test] async fn gpg_signature_verifies_after_the_keyring_is_rebuilt() { if Command::new("gpg").arg("--version").output().is_err() { eprintln!("skipping gpg test: gpg is not installed"); return; } let f = fixture().await; let keygen = f.home.join("keygen"); std::fs::create_dir_all(&keygen).unwrap(); std::fs::set_permissions(&keygen, std::os::unix::fs::PermissionsExt::from_mode(0o700)) .unwrap(); let email = "alice@example.com"; gpg_run( &keygen, &[ "--passphrase", "", "--quick-gen-key", email, "default", "default", "never", ], ); let listing = gpg_run(&keygen, &["--with-colons", "--list-keys", email]); let fpr = String::from_utf8_lossy(&listing) .lines() .find(|l| l.starts_with("fpr:")) .and_then(|l| l.split(':').nth(9)) .unwrap() .to_string(); let armored = String::from_utf8(gpg_run(&keygen, &["--armor", "--export", &fpr])).unwrap(); f.state .store .add_gpg_key(&f.alice.id, &fpr, &armored, &[email.to_string()]) .await .unwrap(); std::fs::write(f.work.join("gpg.txt"), "gpg\n").unwrap(); git(&f.home, &f.work, &["add", "gpg.txt"]); let commit = Command::new("git") .arg("-C") .arg(&f.work) .args([ "-c", &format!("user.signingkey={fpr}"), "-c", "commit.gpgsign=true", "-c", &format!("user.email={email}"), "commit", "-q", "-m", "gpg signed", ]) .env_clear() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("HOME", &f.home) .env("GNUPGHOME", &keygen) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", f.home.join("gitconfig")) .output() .unwrap(); assert!( commit.status.success(), "{}", String::from_utf8_lossy(&commit.stderr) ); let oid = git(&f.home, &f.work, &["rev-parse", "HEAD"]); let bare = forge_git::repo::repo_path(&f.home, &f.repo.id).unwrap(); git( &f.home, &f.work, &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"], ); // The managed keyring is lost, as after a restore from a backup without gnupg/. let gnupg = gnupg_home(&f.state); let _ = std::fs::remove_dir_all(&gnupg); assert!(!gnupg.exists()); rebuild_gpg_keyring(&f.state).await.unwrap(); let result = badges(&f.state, &f.repo, std::slice::from_ref(&oid)).await; // Stop the agents that gpg started for both keyrings. for home in [&keygen, &gnupg] { let _ = Command::new("gpgconf") .env("GNUPGHOME", home) .args(["--kill", "gpg-agent"]) .output(); } assert_eq!( result.get(&oid), Some(&SigBadge::Verified { signer: "alice".into(), key: fpr, }), "{result:?}" ); } }