// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Commit and tag signature verification (§3.6). //! //! git records a signature in the object's `gpgsig` header; libgit2's //! `extract_signature` hands back the armored signature and the exact payload it //! covers. We dispatch on the armor banner: //! //! * `-----BEGIN SSH SIGNATURE-----` → parsed with `ssh-key` and verified with //! namespace `"git"`. An SSH signature embeds its own public key, so we can //! always tell a cryptographically valid signature from a broken one and only //! then ask whether the key is registered. //! * `-----BEGIN PGP SIGNATURE-----` → parsed with `pgp` (rpgp) and verified as a //! detached signature over the payload. A PGP detached signature does **not** //! embed the key, so an unregistered signer cannot be cryptographically checked; //! we report it from the signature's issuer fingerprint. //! //! The four [`SignatureState`] outcomes are never collapsed: `Verified` is a valid //! signature by a registered key, `UnknownKey` a signature whose key we do not //! hold, `Invalid` a present-but-broken (or expired) signature, and `Unsigned` no //! signature at all. use std::sync::Arc; use model::KeyKind; use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey}; use pgp::types::KeyDetails; use ssh_key::{HashAlg, PublicKey, SshSig}; use crate::repo::Repo; use crate::types::Oid; /// A registered public key, as the store supplies it, used to attribute a verified /// signature back to its owner. #[derive(Debug, Clone)] pub struct SigningKey { /// The owning user's id. pub user_id: String, /// The key's own id. pub key_id: String, /// SSH or GPG. pub kind: KeyKind, /// The stored fingerprint: `SHA256:…` for SSH, hex for GPG. pub fingerprint: String, /// The stored public key: an `authorized_keys` line (SSH) or an ASCII-armored /// block (GPG). pub public_key: String, } /// The outcome of verifying an object's signature. /// /// The UI renders `Verified` as a green badge and every other carrying-a-signature /// case as an amber "Unverified" badge; the detail line distinguishes them. Ids are /// `String` ULIDs (the spec sketch used `Ulid`; the git layer has no ULID /// dependency — see docs/decisions.md). #[derive(Debug, Clone, PartialEq, Eq)] pub enum SignatureState { /// No signature is present. Unsigned, /// A valid signature by a key registered on this instance. Verified { /// The owning user's id. user_id: String, /// The registered key's id. key_id: String, /// SSH or GPG. kind: KeyKind, /// The verified key's fingerprint. fingerprint: String, }, /// A valid-looking signature whose key is not registered here. UnknownKey { /// SSH or GPG. kind: KeyKind, /// The signer's fingerprint, as far as it could be determined. fingerprint: String, }, /// A signature that is present but cryptographically invalid, malformed, or /// expired. Invalid { /// The signature kind, if the armor identified one. kind: Option, /// A human-readable reason for the log/detail line. reason: String, }, } impl Repo { /// Verify the signature on the object `oid` (a commit or annotated tag) against /// the registered `keys`. /// /// Never fails: an absent or unreadable signature is reported as /// [`SignatureState::Unsigned`], and any parse/verify problem as /// [`SignatureState::Invalid`]. #[must_use] pub fn signature_state(&self, oid: &Oid, keys: &[SigningKey]) -> SignatureState { let Ok(git_oid) = git2::Oid::from_str(oid.as_str()) else { return SignatureState::Unsigned; }; // `extract_signature` returns NotFound when the object carries no signature. let Ok((sig, payload)) = self.raw().extract_signature(&git_oid, None) else { return SignatureState::Unsigned; }; let armor = String::from_utf8_lossy(&sig); if armor.contains("BEGIN SSH SIGNATURE") { verify_ssh(&armor, &payload, keys) } else if armor.contains("BEGIN PGP SIGNATURE") { verify_gpg(&armor, &payload, keys) } else { SignatureState::Invalid { kind: None, reason: "unrecognized signature format".to_string(), } } } } /// Verify an SSH signature over `payload`. /// /// The signature embeds its signing key, so we verify against that key and then /// classify by whether its SHA256 fingerprint is registered. pub(crate) fn verify_ssh(armor: &str, payload: &[u8], keys: &[SigningKey]) -> SignatureState { let sig = match SshSig::from_pem(armor.as_bytes()) { Ok(sig) => sig, Err(err) => { return SignatureState::Invalid { kind: Some(KeyKind::Ssh), reason: format!("malformed SSH signature: {err}"), }; } }; // The SSHSIG format only carries sha256/sha512; the type guarantees it, so // there is no other hash algorithm to reject. debug_assert!(matches!(sig.hash_alg(), HashAlg::Sha256 | HashAlg::Sha512)); let embedded: PublicKey = sig.public_key().clone().into(); if let Err(err) = embedded.verify("git", payload, &sig) { return SignatureState::Invalid { kind: Some(KeyKind::Ssh), reason: format!("SSH signature verification failed: {err}"), }; } let fingerprint = embedded.fingerprint(HashAlg::Sha256).to_string(); match keys .iter() .find(|k| k.kind == KeyKind::Ssh && ssh_fingerprint_matches(&k.fingerprint, &fingerprint)) { Some(key) => SignatureState::Verified { user_id: key.user_id.clone(), key_id: key.key_id.clone(), kind: KeyKind::Ssh, fingerprint, }, None => SignatureState::UnknownKey { kind: KeyKind::Ssh, fingerprint, }, } } /// Verify a GPG detached signature over `payload`. /// /// A detached PGP signature carries no key, so verification is only possible /// against a registered key. If none verifies, we distinguish a broken signature /// from a known key ([`SignatureState::Invalid`]) from an unregistered signer /// ([`SignatureState::UnknownKey`]) by the signature's issuer fingerprint. pub(crate) fn verify_gpg(armor: &str, payload: &[u8], keys: &[SigningKey]) -> SignatureState { let detached = match DetachedSignature::from_string(armor) { Ok((detached, _)) => detached, Err(err) => { return SignatureState::Invalid { kind: Some(KeyKind::Gpg), reason: format!("malformed PGP signature: {err}"), }; } }; let issuer = detached .signature .issuer_fingerprint() .first() .map(|fp| format!("{fp:X}")); let mut known_issuer = false; for key in keys.iter().filter(|k| k.kind == KeyKind::Gpg) { let Ok((public_key, _)) = SignedPublicKey::from_string(&key.public_key) else { continue; }; let key_fpr = format!("{:X}", public_key.fingerprint()); if let Some(issuer) = &issuer && gpg_fingerprint_matches(&key_fpr, issuer) { known_issuer = true; } let verified = detached.verify(&public_key, payload).is_ok() || public_key .public_subkeys .iter() .any(|sub| detached.verify(sub, payload).is_ok()); if verified { return SignatureState::Verified { user_id: key.user_id.clone(), key_id: key.key_id.clone(), kind: KeyKind::Gpg, fingerprint: normalize_hex(&key.fingerprint), }; } } let fingerprint = issuer.map(|fp| normalize_hex(&fp)).unwrap_or_default(); if known_issuer { SignatureState::Invalid { kind: Some(KeyKind::Gpg), reason: "PGP signature does not verify against the registered key".to_string(), } } else { SignatureState::UnknownKey { kind: KeyKind::Gpg, fingerprint, } } } /// Verify that `armored_sig` is a valid signature over `payload` produced by the /// private key matching `public_key` (an `authorized_keys` line for SSH or an /// ASCII-armored block for GPG). Proves possession of the private key — used for /// key-ownership challenges. SSH signatures must use the `git` namespace. #[must_use] pub fn verify_challenge( kind: KeyKind, fingerprint: &str, public_key: &str, armored_sig: &str, payload: &[u8], ) -> bool { let key = SigningKey { user_id: String::new(), key_id: String::new(), kind, fingerprint: fingerprint.to_string(), public_key: public_key.to_string(), }; let keys = std::slice::from_ref(&key); let state = match kind { KeyKind::Ssh => verify_ssh(armored_sig, payload, keys), KeyKind::Gpg => verify_gpg(armored_sig, payload, keys), }; matches!(state, SignatureState::Verified { .. }) } /// Compare two SSH fingerprints. The `SHA256:` body is case-sensitive, so /// only surrounding whitespace is trimmed. fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool { stored.trim() == computed.trim() } /// Compare two GPG fingerprints, ignoring case, spaces, and `0x`/colon separators. fn gpg_fingerprint_matches(a: &str, b: &str) -> bool { normalize_hex(a) == normalize_hex(b) } /// Reduce a hex fingerprint to lowercase hex digits only, so representations that /// differ by case, spaces, or separators compare equal. fn normalize_hex(fp: &str) -> String { fp.chars() .filter(char::is_ascii_hexdigit) .map(|c| c.to_ascii_lowercase()) .collect() } /// A bounded cache of verification results keyed by object oid. /// /// Verification is expensive, so list views reuse results across renders. The /// result depends on the registered key set, so the web layer **must** clear the /// cache when keys change (it already bumps `cache_epoch` on such mutations). #[derive(Clone)] pub struct SignatureCache { inner: moka::sync::Cache>, } impl SignatureCache { /// A cache holding up to `capacity` results, each expiring after 10 minutes so /// a key change is reflected without an explicit invalidation. #[must_use] pub fn new(capacity: u64) -> Self { Self { inner: moka::sync::Cache::builder() .max_capacity(capacity) .time_to_live(std::time::Duration::from_secs(600)) .build(), } } /// Fetch or compute the signature state for `oid`. #[must_use] pub fn get(&self, repo: &Repo, oid: &Oid, keys: &[SigningKey]) -> Arc { if let Some(hit) = self.inner.get(oid.as_str()) { return hit; } let state = Arc::new(repo.signature_state(oid, keys)); self.inner.insert(oid.to_string(), Arc::clone(&state)); state } /// Drop every cached result. Call after any key mutation. pub fn clear(&self) { self.inner.invalidate_all(); } } impl Default for SignatureCache { fn default() -> Self { Self::new(1024) } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use rand_core::OsRng; use ssh_key::{Algorithm, LineEnding, PrivateKey}; use super::*; /// A GPG key, its detached signature over [`GPG_PAYLOAD`], and its fingerprint, /// generated once with `gpg` and pinned here (§14: ship a static GPG fixture). const GPG_PUB: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n\ mDMEamQrVhYJKwYBBAHaRw8BAQdAZmnSAPnE0i9ZFD376jPvfybRQnCVgdZznu/Z\n\ xu9NMc+0I0ZhYnJpY2EgVGVzdCA8dGVzdEBmYWJyaWNhLmV4YW1wbGU+iJAEExYK\n\ ADgWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbAwULCQgHAgYVCgkICwIE\n\ FgIDAQIeAQIXgAAKCRCZoNNqXdKXKHT3APkBBY7MB1be0vsOf0Krr5+mLzStHwjQ\n\ ZhFF/DtG/1L+sgD+KcKERhLj5L5b/FHBxYGpw2ZY4aQoCOeGmk+zmflLbwS4OARq\n\ ZCtWEgorBgEEAZdVAQUBAQdAeLjNUQ2tEAUDtr+Iu7qa/EOArrAd3QSWKHNWXfX7\n\ 4lIDAQgHiHgEGBYKACAWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbDAAK\n\ CRCZoNNqXdKXKJIEAP9Z2KdPQ7yVGAC6XjDoYKbbeAu58G4L4hqiq+JK6v18lwD/\n\ T4OoXtnXPpwpy5jv0KTtE+cjQCl1W2qhQTFSqlXeawM=\n\ =EOde\n\ -----END PGP PUBLIC KEY BLOCK-----\n"; const GPG_SIG: &str = "-----BEGIN PGP SIGNATURE-----\n\n\ iHUEABYKAB0WIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgAKCRCZoNNqXdKX\n\ KCYOAQCaiD7VrRkDvxqwatypcp/0xfY9oc3OclJVa+rFVkUNMQD9HxnEfelG+hwU\n\ MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\ =lUCx\n\ -----END PGP SIGNATURE-----\n"; const GPG_PAYLOAD: &[u8] = b"fabrica signed payload\nsecond line\n"; const GPG_FPR: &str = "42c34af4c3bfc83e1c8610f599a0d36a5dd29728"; fn gpg_key() -> SigningKey { SigningKey { user_id: "user-1".into(), key_id: "key-1".into(), kind: KeyKind::Gpg, fingerprint: GPG_FPR.into(), public_key: GPG_PUB.into(), } } /// Generate an ed25519 SSH key, its `authorized_keys` line, and its SHA256 /// fingerprint, entirely in-process. fn ssh_key() -> (PrivateKey, SigningKey) { let private = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); let public = private.public_key(); let key = SigningKey { user_id: "user-2".into(), key_id: "key-2".into(), kind: KeyKind::Ssh, fingerprint: public.fingerprint(HashAlg::Sha256).to_string(), public_key: public.to_openssh().unwrap(), }; (private, key) } fn sign_ssh(private: &PrivateKey, payload: &[u8]) -> String { private .sign("git", HashAlg::Sha512, payload) .unwrap() .to_pem(LineEnding::LF) .unwrap() } #[test] fn ssh_verified_unknown_and_invalid() { let (private, key) = ssh_key(); let payload = b"tree deadbeef\nauthor a\n\nmessage\n"; let armor = sign_ssh(&private, payload); // Registered → Verified. match verify_ssh(&armor, payload, std::slice::from_ref(&key)) { SignatureState::Verified { kind, fingerprint, .. } => { assert_eq!(kind, KeyKind::Ssh); assert_eq!(fingerprint, key.fingerprint); } other => panic!("expected Verified, got {other:?}"), } // Valid signature, no registered key → UnknownKey. match verify_ssh(&armor, payload, &[]) { SignatureState::UnknownKey { kind, fingerprint } => { assert_eq!(kind, KeyKind::Ssh); assert_eq!(fingerprint, key.fingerprint); } other => panic!("expected UnknownKey, got {other:?}"), } // Wrong payload → Invalid, even with the key registered. match verify_ssh(&armor, b"different payload", std::slice::from_ref(&key)) { SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Ssh)), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn verify_challenge_accepts_a_valid_gpg_signature() { // The pinned signature is over GPG_PAYLOAD, so verify_challenge accepts it. assert!(verify_challenge( KeyKind::Gpg, GPG_FPR, GPG_PUB, GPG_SIG, GPG_PAYLOAD )); // A different payload (as a real challenge would use) must be refused. assert!(!verify_challenge( KeyKind::Gpg, GPG_FPR, GPG_PUB, GPG_SIG, b"a different challenge" )); // Garbage signature is refused, not a panic. assert!(!verify_challenge( KeyKind::Gpg, GPG_FPR, GPG_PUB, "not a signature", GPG_PAYLOAD )); } #[test] fn gpg_verified_unknown_and_invalid() { // Registered → Verified. match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[gpg_key()]) { SignatureState::Verified { kind, fingerprint, .. } => { assert_eq!(kind, KeyKind::Gpg); assert_eq!(fingerprint, GPG_FPR); } other => panic!("expected Verified, got {other:?}"), } // No registered key → UnknownKey with the issuer fingerprint. match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[]) { SignatureState::UnknownKey { kind, fingerprint } => { assert_eq!(kind, KeyKind::Gpg); assert_eq!(fingerprint, GPG_FPR); } other => panic!("expected UnknownKey, got {other:?}"), } // Registered key but tampered payload → Invalid. match verify_gpg(GPG_SIG, b"tampered", &[gpg_key()]) { SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Gpg)), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn malformed_armor_is_invalid() { match verify_ssh("-----BEGIN SSH SIGNATURE-----\nnonsense\n", b"x", &[]) { SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Ssh)), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn unsigned_commit_reports_unsigned() { use std::path::Path; use git2::{Repository, Signature, Time}; use crate::{create_bare, repo_path}; let dir = tempfile::tempdir().unwrap(); let id = "01hzxk9m2n8p7q6r5s4t3v2w1x"; create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap(); let path = repo_path(dir.path(), id).unwrap(); let raw = Repository::open_bare(&path).unwrap(); let blob = raw.blob(b"hi\n").unwrap(); let mut tb = raw.treebuilder(None).unwrap(); tb.insert("f", blob, 0o100_644).unwrap(); let tree = raw.find_tree(tb.write().unwrap()).unwrap(); let sig = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap(); let oid = raw .commit(Some("refs/heads/main"), &sig, &sig, "unsigned", &tree, &[]) .unwrap(); let repo = Repo::open_path(&path).unwrap(); assert_eq!( repo.signature_state(&Oid::new(oid.to_string()), &[]), SignatureState::Unsigned ); } #[test] fn ssh_signed_commit_end_to_end() { use std::path::Path; use git2::{Repository, Signature, Time}; use crate::{create_bare, repo_path}; let dir = tempfile::tempdir().unwrap(); let id = "01hzxk9m2n8p7q6r5s4t3v2w1y"; create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap(); let path = repo_path(dir.path(), id).unwrap(); let raw = Repository::open_bare(&path).unwrap(); let blob = raw.blob(b"hi\n").unwrap(); let mut tb = raw.treebuilder(None).unwrap(); tb.insert("f", blob, 0o100_644).unwrap(); let tree = raw.find_tree(tb.write().unwrap()).unwrap(); let who = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap(); let content = raw .commit_create_buffer(&who, &who, "signed commit", &tree, &[]) .unwrap(); let content = std::str::from_utf8(&content).unwrap(); let (private, key) = ssh_key(); let armor = sign_ssh(&private, content.as_bytes()); let oid = raw.commit_signed(content, &armor, None).unwrap(); let repo = Repo::open_path(&path).unwrap(); match repo.signature_state(&Oid::new(oid.to_string()), std::slice::from_ref(&key)) { SignatureState::Verified { fingerprint, .. } => { assert_eq!(fingerprint, key.fingerprint); } other => panic!("expected Verified, got {other:?}"), } } }