| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | use std::collections::HashMap; |
| 10 | use std::path::{Path, PathBuf}; |
| 11 | use std::process::Stdio; |
| 12 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 13 | use std::time::Duration; |
| 14 | |
| 15 | use base64::prelude::{BASE64_STANDARD, Engine as _}; |
| 16 | use forge_core::model::Repo; |
| 17 | use forge_core::store::signatures::{CachedSig, SigKind, SigStatus}; |
| 18 | use forge_git::verify::{RawVerify, committer_email, verify_commit}; |
| 19 | use tokio::time::Instant; |
| 20 | |
| 21 | use crate::state::AppState; |
| 22 | |
| 23 | |
| 24 | const MAX_COMMITS: usize = 50; |
| 25 | |
| 26 | const BUDGET: Duration = Duration::from_secs(5); |
| 27 | |
| 28 | const MAX_ARMORED_BYTES: usize = 64 * 1024; |
| 29 | |
| 30 | |
| 31 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 32 | pub enum SigBadge { |
| 33 | Verified { signer: String, key: String }, |
| 34 | Unverified { reason: String }, |
| 35 | } |
| 36 | |
| 37 | |
| 38 | |
| 39 | |
| 40 | |
| 41 | pub 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 | |
| 95 | async 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 | |
| 127 | |
| 128 | async 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 | |
| 153 | fn 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 | |
| 163 | async 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 | |
| 241 | fn data_dir(state: &AppState) -> &Path { |
| 242 | &state.config.data_dir |
| 243 | } |
| 244 | |
| 245 | fn gnupg_home(state: &AppState) -> PathBuf { |
| 246 | data_dir(state).join("gnupg") |
| 247 | } |
| 248 | |
| 249 | fn allowed_signers_path(state: &AppState) -> PathBuf { |
| 250 | data_dir(state).join("run").join("allowed_signers") |
| 251 | } |
| 252 | |
| 253 | |
| 254 | fn 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 | |
| 267 | pub 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 | |
| 275 | async 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 | |
| 302 | async 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)] |
| 309 | async 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))] |
| 315 | async fn set_private_dir(_path: &Path) -> std::io::Result<()> { |
| 316 | Ok(()) |
| 317 | } |
| 318 | |
| 319 | |
| 320 | |
| 321 | pub 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 | |
| 352 | fn 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 | |
| 392 | fn email_in_uid(uid: &str) -> Option<String> { |
| 393 | |
| 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 | |
| 406 | |
| 407 | |
| 408 | pub 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 | |
| 428 | |
| 429 | |
| 430 | pub 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 | |
| 457 | |
| 458 | pub 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)] |
| 488 | async 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))] |
| 494 | async fn set_private(_path: &Path) -> std::io::Result<()> { |
| 495 | Ok(()) |
| 496 | } |
| 497 | |
| 498 | |
| 499 | |
| 500 | pub 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)] |
| 510 | mod 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 | |
| 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 | |
| 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 | |
| 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 | |
| 579 | pub_path: PathBuf, |
| 580 | key_id: String, |
| 581 | base_oid: String, |
| 582 | _dir: tempfile::TempDir, |
| 583 | } |
| 584 | |
| 585 | |
| 586 | |
| 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 | |
| 655 | |
| 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 | |
| 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 | |
| 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\ |
| 790 | fpr:::::::::ABCDEF0123456789ABCDEF0123456789ABCDEF01:\n\ |
| 791 | uid:u::::1700000000::HASH::Alice <Alice@Example.com>::::::::::0:\n\ |
| 792 | uid:u::::1700000000::HASH::Alice Work <work@example.org>::::::::::0:\n\ |
| 793 | sub:u:255:18:1111111111111111111111111111111111111111:1700000000:::::e::::::23:\n\ |
| 794 | fpr:::::::::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 | |
| 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 | |
| 825 | |
| 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 | |
| 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 | |
| 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 | } |