fabrica

hanna/fabrica

20330 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Commit and tag signature verification (§3.6).
6//!
7//! git records a signature in the object's `gpgsig` header; libgit2's
8//! `extract_signature` hands back the armored signature and the exact payload it
9//! covers. We dispatch on the armor banner:
10//!
11//! * `-----BEGIN SSH SIGNATURE-----` → parsed with `ssh-key` and verified with
12//! namespace `"git"`. An SSH signature embeds its own public key, so we can
13//! always tell a cryptographically valid signature from a broken one and only
14//! then ask whether the key is registered.
15//! * `-----BEGIN PGP SIGNATURE-----` → parsed with `pgp` (rpgp) and verified as a
16//! detached signature over the payload. A PGP detached signature does **not**
17//! embed the key, so an unregistered signer cannot be cryptographically checked;
18//! we report it from the signature's issuer fingerprint.
19//!
20//! The four [`SignatureState`] outcomes are never collapsed: `Verified` is a valid
21//! signature by a registered key, `UnknownKey` a signature whose key we do not
22//! hold, `Invalid` a present-but-broken (or expired) signature, and `Unsigned` no
23//! signature at all.
24
25use std::sync::Arc;
26
27use model::KeyKind;
28use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey};
29use pgp::types::KeyDetails;
30use ssh_key::{HashAlg, PublicKey, SshSig};
31
32use crate::repo::Repo;
33use crate::types::Oid;
34
35/// A registered public key, as the store supplies it, used to attribute a verified
36/// signature back to its owner.
37#[derive(Debug, Clone)]
38pub struct SigningKey {
39 /// The owning user's id.
40 pub user_id: String,
41 /// The key's own id.
42 pub key_id: String,
43 /// SSH or GPG.
44 pub kind: KeyKind,
45 /// The stored fingerprint: `SHA256:…` for SSH, hex for GPG.
46 pub fingerprint: String,
47 /// The stored public key: an `authorized_keys` line (SSH) or an ASCII-armored
48 /// block (GPG).
49 pub public_key: String,
50}
51
52/// The outcome of verifying an object's signature.
53///
54/// The UI renders `Verified` as a green badge and every other carrying-a-signature
55/// case as an amber "Unverified" badge; the detail line distinguishes them. Ids are
56/// `String` ULIDs (the spec sketch used `Ulid`; the git layer has no ULID
57/// dependency — see docs/decisions.md).
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum SignatureState {
60 /// No signature is present.
61 Unsigned,
62 /// A valid signature by a key registered on this instance.
63 Verified {
64 /// The owning user's id.
65 user_id: String,
66 /// The registered key's id.
67 key_id: String,
68 /// SSH or GPG.
69 kind: KeyKind,
70 /// The verified key's fingerprint.
71 fingerprint: String,
72 },
73 /// A valid-looking signature whose key is not registered here.
74 UnknownKey {
75 /// SSH or GPG.
76 kind: KeyKind,
77 /// The signer's fingerprint, as far as it could be determined.
78 fingerprint: String,
79 },
80 /// A signature that is present but cryptographically invalid, malformed, or
81 /// expired.
82 Invalid {
83 /// The signature kind, if the armor identified one.
84 kind: Option<KeyKind>,
85 /// A human-readable reason for the log/detail line.
86 reason: String,
87 },
88}
89
90impl Repo {
91 /// Verify the signature on the object `oid` (a commit or annotated tag) against
92 /// the registered `keys`.
93 ///
94 /// Never fails: an absent or unreadable signature is reported as
95 /// [`SignatureState::Unsigned`], and any parse/verify problem as
96 /// [`SignatureState::Invalid`].
97 #[must_use]
98 pub fn signature_state(&self, oid: &Oid, keys: &[SigningKey]) -> SignatureState {
99 let Ok(git_oid) = git2::Oid::from_str(oid.as_str()) else {
100 return SignatureState::Unsigned;
101 };
102 // `extract_signature` returns NotFound when the object carries no signature.
103 let Ok((sig, payload)) = self.raw().extract_signature(&git_oid, None) else {
104 return SignatureState::Unsigned;
105 };
106 let armor = String::from_utf8_lossy(&sig);
107 if armor.contains("BEGIN SSH SIGNATURE") {
108 verify_ssh(&armor, &payload, keys)
109 } else if armor.contains("BEGIN PGP SIGNATURE") {
110 verify_gpg(&armor, &payload, keys)
111 } else {
112 SignatureState::Invalid {
113 kind: None,
114 reason: "unrecognized signature format".to_string(),
115 }
116 }
117 }
118}
119
120/// Verify an SSH signature over `payload`.
121///
122/// The signature embeds its signing key, so we verify against that key and then
123/// classify by whether its SHA256 fingerprint is registered.
124pub(crate) fn verify_ssh(armor: &str, payload: &[u8], keys: &[SigningKey]) -> SignatureState {
125 let sig = match SshSig::from_pem(armor.as_bytes()) {
126 Ok(sig) => sig,
127 Err(err) => {
128 return SignatureState::Invalid {
129 kind: Some(KeyKind::Ssh),
130 reason: format!("malformed SSH signature: {err}"),
131 };
132 }
133 };
134 // The SSHSIG format only carries sha256/sha512; the type guarantees it, so
135 // there is no other hash algorithm to reject.
136 debug_assert!(matches!(sig.hash_alg(), HashAlg::Sha256 | HashAlg::Sha512));
137
138 let embedded: PublicKey = sig.public_key().clone().into();
139 if let Err(err) = embedded.verify("git", payload, &sig) {
140 return SignatureState::Invalid {
141 kind: Some(KeyKind::Ssh),
142 reason: format!("SSH signature verification failed: {err}"),
143 };
144 }
145
146 let fingerprint = embedded.fingerprint(HashAlg::Sha256).to_string();
147 match keys
148 .iter()
149 .find(|k| k.kind == KeyKind::Ssh && ssh_fingerprint_matches(&k.fingerprint, &fingerprint))
150 {
151 Some(key) => SignatureState::Verified {
152 user_id: key.user_id.clone(),
153 key_id: key.key_id.clone(),
154 kind: KeyKind::Ssh,
155 fingerprint,
156 },
157 None => SignatureState::UnknownKey {
158 kind: KeyKind::Ssh,
159 fingerprint,
160 },
161 }
162}
163
164/// Verify a GPG detached signature over `payload`.
165///
166/// A detached PGP signature carries no key, so verification is only possible
167/// against a registered key. If none verifies, we distinguish a broken signature
168/// from a known key ([`SignatureState::Invalid`]) from an unregistered signer
169/// ([`SignatureState::UnknownKey`]) by the signature's issuer fingerprint.
170pub(crate) fn verify_gpg(armor: &str, payload: &[u8], keys: &[SigningKey]) -> SignatureState {
171 let detached = match DetachedSignature::from_string(armor) {
172 Ok((detached, _)) => detached,
173 Err(err) => {
174 return SignatureState::Invalid {
175 kind: Some(KeyKind::Gpg),
176 reason: format!("malformed PGP signature: {err}"),
177 };
178 }
179 };
180
181 let issuer = detached
182 .signature
183 .issuer_fingerprint()
184 .first()
185 .map(|fp| format!("{fp:X}"));
186
187 let mut known_issuer = false;
188 for key in keys.iter().filter(|k| k.kind == KeyKind::Gpg) {
189 let Ok((public_key, _)) = SignedPublicKey::from_string(&key.public_key) else {
190 continue;
191 };
192 let key_fpr = format!("{:X}", public_key.fingerprint());
193 if let Some(issuer) = &issuer
194 && gpg_fingerprint_matches(&key_fpr, issuer)
195 {
196 known_issuer = true;
197 }
198 let verified = detached.verify(&public_key, payload).is_ok()
199 || public_key
200 .public_subkeys
201 .iter()
202 .any(|sub| detached.verify(sub, payload).is_ok());
203 if verified {
204 return SignatureState::Verified {
205 user_id: key.user_id.clone(),
206 key_id: key.key_id.clone(),
207 kind: KeyKind::Gpg,
208 fingerprint: normalize_hex(&key.fingerprint),
209 };
210 }
211 }
212
213 let fingerprint = issuer.map(|fp| normalize_hex(&fp)).unwrap_or_default();
214 if known_issuer {
215 SignatureState::Invalid {
216 kind: Some(KeyKind::Gpg),
217 reason: "PGP signature does not verify against the registered key".to_string(),
218 }
219 } else {
220 SignatureState::UnknownKey {
221 kind: KeyKind::Gpg,
222 fingerprint,
223 }
224 }
225}
226
227/// Verify that `armored_sig` is a valid signature over `payload` produced by the
228/// private key matching `public_key` (an `authorized_keys` line for SSH or an
229/// ASCII-armored block for GPG). Proves possession of the private key — used for
230/// key-ownership challenges. SSH signatures must use the `git` namespace.
231#[must_use]
232pub fn verify_challenge(
233 kind: KeyKind,
234 fingerprint: &str,
235 public_key: &str,
236 armored_sig: &str,
237 payload: &[u8],
238) -> bool {
239 let key = SigningKey {
240 user_id: String::new(),
241 key_id: String::new(),
242 kind,
243 fingerprint: fingerprint.to_string(),
244 public_key: public_key.to_string(),
245 };
246 let keys = std::slice::from_ref(&key);
247 let state = match kind {
248 KeyKind::Ssh => verify_ssh(armored_sig, payload, keys),
249 KeyKind::Gpg => verify_gpg(armored_sig, payload, keys),
250 };
251 matches!(state, SignatureState::Verified { .. })
252}
253
254/// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so
255/// only surrounding whitespace is trimmed.
256fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool {
257 stored.trim() == computed.trim()
258}
259
260/// Compare two GPG fingerprints, ignoring case, spaces, and `0x`/colon separators.
261fn gpg_fingerprint_matches(a: &str, b: &str) -> bool {
262 normalize_hex(a) == normalize_hex(b)
263}
264
265/// Reduce a hex fingerprint to lowercase hex digits only, so representations that
266/// differ by case, spaces, or separators compare equal.
267fn normalize_hex(fp: &str) -> String {
268 fp.chars()
269 .filter(char::is_ascii_hexdigit)
270 .map(|c| c.to_ascii_lowercase())
271 .collect()
272}
273
274/// A bounded cache of verification results keyed by object oid.
275///
276/// Verification is expensive, so list views reuse results across renders. The
277/// result depends on the registered key set, so the web layer **must** clear the
278/// cache when keys change (it already bumps `cache_epoch` on such mutations).
279#[derive(Clone)]
280pub struct SignatureCache {
281 inner: moka::sync::Cache<String, Arc<SignatureState>>,
282}
283
284impl SignatureCache {
285 /// A cache holding up to `capacity` results, each expiring after 10 minutes so
286 /// a key change is reflected without an explicit invalidation.
287 #[must_use]
288 pub fn new(capacity: u64) -> Self {
289 Self {
290 inner: moka::sync::Cache::builder()
291 .max_capacity(capacity)
292 .time_to_live(std::time::Duration::from_secs(600))
293 .build(),
294 }
295 }
296
297 /// Fetch or compute the signature state for `oid`.
298 #[must_use]
299 pub fn get(&self, repo: &Repo, oid: &Oid, keys: &[SigningKey]) -> Arc<SignatureState> {
300 if let Some(hit) = self.inner.get(oid.as_str()) {
301 return hit;
302 }
303 let state = Arc::new(repo.signature_state(oid, keys));
304 self.inner.insert(oid.to_string(), Arc::clone(&state));
305 state
306 }
307
308 /// Drop every cached result. Call after any key mutation.
309 pub fn clear(&self) {
310 self.inner.invalidate_all();
311 }
312}
313
314impl Default for SignatureCache {
315 fn default() -> Self {
316 Self::new(1024)
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
323
324 use rand_core::OsRng;
325 use ssh_key::{Algorithm, LineEnding, PrivateKey};
326
327 use super::*;
328
329 /// A GPG key, its detached signature over [`GPG_PAYLOAD`], and its fingerprint,
330 /// generated once with `gpg` and pinned here (§14: ship a static GPG fixture).
331 const GPG_PUB: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n\
332mDMEamQrVhYJKwYBBAHaRw8BAQdAZmnSAPnE0i9ZFD376jPvfybRQnCVgdZznu/Z\n\
333xu9NMc+0I0ZhYnJpY2EgVGVzdCA8dGVzdEBmYWJyaWNhLmV4YW1wbGU+iJAEExYK\n\
334ADgWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbAwULCQgHAgYVCgkICwIE\n\
335FgIDAQIeAQIXgAAKCRCZoNNqXdKXKHT3APkBBY7MB1be0vsOf0Krr5+mLzStHwjQ\n\
336ZhFF/DtG/1L+sgD+KcKERhLj5L5b/FHBxYGpw2ZY4aQoCOeGmk+zmflLbwS4OARq\n\
337ZCtWEgorBgEEAZdVAQUBAQdAeLjNUQ2tEAUDtr+Iu7qa/EOArrAd3QSWKHNWXfX7\n\
3384lIDAQgHiHgEGBYKACAWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbDAAK\n\
339CRCZoNNqXdKXKJIEAP9Z2KdPQ7yVGAC6XjDoYKbbeAu58G4L4hqiq+JK6v18lwD/\n\
340T4OoXtnXPpwpy5jv0KTtE+cjQCl1W2qhQTFSqlXeawM=\n\
341=EOde\n\
342-----END PGP PUBLIC KEY BLOCK-----\n";
343
344 const GPG_SIG: &str = "-----BEGIN PGP SIGNATURE-----\n\n\
345iHUEABYKAB0WIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgAKCRCZoNNqXdKX\n\
346KCYOAQCaiD7VrRkDvxqwatypcp/0xfY9oc3OclJVa+rFVkUNMQD9HxnEfelG+hwU\n\
347MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\
348=lUCx\n\
349-----END PGP SIGNATURE-----\n";
350
351 const GPG_PAYLOAD: &[u8] = b"fabrica signed payload\nsecond line\n";
352 const GPG_FPR: &str = "42c34af4c3bfc83e1c8610f599a0d36a5dd29728";
353
354 fn gpg_key() -> SigningKey {
355 SigningKey {
356 user_id: "user-1".into(),
357 key_id: "key-1".into(),
358 kind: KeyKind::Gpg,
359 fingerprint: GPG_FPR.into(),
360 public_key: GPG_PUB.into(),
361 }
362 }
363
364 /// Generate an ed25519 SSH key, its `authorized_keys` line, and its SHA256
365 /// fingerprint, entirely in-process.
366 fn ssh_key() -> (PrivateKey, SigningKey) {
367 let private = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
368 let public = private.public_key();
369 let key = SigningKey {
370 user_id: "user-2".into(),
371 key_id: "key-2".into(),
372 kind: KeyKind::Ssh,
373 fingerprint: public.fingerprint(HashAlg::Sha256).to_string(),
374 public_key: public.to_openssh().unwrap(),
375 };
376 (private, key)
377 }
378
379 fn sign_ssh(private: &PrivateKey, payload: &[u8]) -> String {
380 private
381 .sign("git", HashAlg::Sha512, payload)
382 .unwrap()
383 .to_pem(LineEnding::LF)
384 .unwrap()
385 }
386
387 #[test]
388 fn ssh_verified_unknown_and_invalid() {
389 let (private, key) = ssh_key();
390 let payload = b"tree deadbeef\nauthor a\n\nmessage\n";
391 let armor = sign_ssh(&private, payload);
392
393 // Registered → Verified.
394 match verify_ssh(&armor, payload, std::slice::from_ref(&key)) {
395 SignatureState::Verified {
396 kind, fingerprint, ..
397 } => {
398 assert_eq!(kind, KeyKind::Ssh);
399 assert_eq!(fingerprint, key.fingerprint);
400 }
401 other => panic!("expected Verified, got {other:?}"),
402 }
403
404 // Valid signature, no registered key → UnknownKey.
405 match verify_ssh(&armor, payload, &[]) {
406 SignatureState::UnknownKey { kind, fingerprint } => {
407 assert_eq!(kind, KeyKind::Ssh);
408 assert_eq!(fingerprint, key.fingerprint);
409 }
410 other => panic!("expected UnknownKey, got {other:?}"),
411 }
412
413 // Wrong payload → Invalid, even with the key registered.
414 match verify_ssh(&armor, b"different payload", std::slice::from_ref(&key)) {
415 SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Ssh)),
416 other => panic!("expected Invalid, got {other:?}"),
417 }
418 }
419
420 #[test]
421 fn verify_challenge_accepts_a_valid_gpg_signature() {
422 // The pinned signature is over GPG_PAYLOAD, so verify_challenge accepts it.
423 assert!(verify_challenge(
424 KeyKind::Gpg,
425 GPG_FPR,
426 GPG_PUB,
427 GPG_SIG,
428 GPG_PAYLOAD
429 ));
430 // A different payload (as a real challenge would use) must be refused.
431 assert!(!verify_challenge(
432 KeyKind::Gpg,
433 GPG_FPR,
434 GPG_PUB,
435 GPG_SIG,
436 b"a different challenge"
437 ));
438 // Garbage signature is refused, not a panic.
439 assert!(!verify_challenge(
440 KeyKind::Gpg,
441 GPG_FPR,
442 GPG_PUB,
443 "not a signature",
444 GPG_PAYLOAD
445 ));
446 }
447
448 #[test]
449 fn gpg_verified_unknown_and_invalid() {
450 // Registered → Verified.
451 match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[gpg_key()]) {
452 SignatureState::Verified {
453 kind, fingerprint, ..
454 } => {
455 assert_eq!(kind, KeyKind::Gpg);
456 assert_eq!(fingerprint, GPG_FPR);
457 }
458 other => panic!("expected Verified, got {other:?}"),
459 }
460
461 // No registered key → UnknownKey with the issuer fingerprint.
462 match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[]) {
463 SignatureState::UnknownKey { kind, fingerprint } => {
464 assert_eq!(kind, KeyKind::Gpg);
465 assert_eq!(fingerprint, GPG_FPR);
466 }
467 other => panic!("expected UnknownKey, got {other:?}"),
468 }
469
470 // Registered key but tampered payload → Invalid.
471 match verify_gpg(GPG_SIG, b"tampered", &[gpg_key()]) {
472 SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Gpg)),
473 other => panic!("expected Invalid, got {other:?}"),
474 }
475 }
476
477 #[test]
478 fn malformed_armor_is_invalid() {
479 match verify_ssh("-----BEGIN SSH SIGNATURE-----\nnonsense\n", b"x", &[]) {
480 SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Ssh)),
481 other => panic!("expected Invalid, got {other:?}"),
482 }
483 }
484
485 #[test]
486 fn unsigned_commit_reports_unsigned() {
487 use std::path::Path;
488
489 use git2::{Repository, Signature, Time};
490
491 use crate::{create_bare, repo_path};
492
493 let dir = tempfile::tempdir().unwrap();
494 let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
495 create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
496 let path = repo_path(dir.path(), id).unwrap();
497 let raw = Repository::open_bare(&path).unwrap();
498
499 let blob = raw.blob(b"hi\n").unwrap();
500 let mut tb = raw.treebuilder(None).unwrap();
501 tb.insert("f", blob, 0o100_644).unwrap();
502 let tree = raw.find_tree(tb.write().unwrap()).unwrap();
503 let sig = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap();
504 let oid = raw
505 .commit(Some("refs/heads/main"), &sig, &sig, "unsigned", &tree, &[])
506 .unwrap();
507
508 let repo = Repo::open_path(&path).unwrap();
509 assert_eq!(
510 repo.signature_state(&Oid::new(oid.to_string()), &[]),
511 SignatureState::Unsigned
512 );
513 }
514
515 #[test]
516 fn ssh_signed_commit_end_to_end() {
517 use std::path::Path;
518
519 use git2::{Repository, Signature, Time};
520
521 use crate::{create_bare, repo_path};
522
523 let dir = tempfile::tempdir().unwrap();
524 let id = "01hzxk9m2n8p7q6r5s4t3v2w1y";
525 create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
526 let path = repo_path(dir.path(), id).unwrap();
527 let raw = Repository::open_bare(&path).unwrap();
528
529 let blob = raw.blob(b"hi\n").unwrap();
530 let mut tb = raw.treebuilder(None).unwrap();
531 tb.insert("f", blob, 0o100_644).unwrap();
532 let tree = raw.find_tree(tb.write().unwrap()).unwrap();
533 let who = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap();
534 let content = raw
535 .commit_create_buffer(&who, &who, "signed commit", &tree, &[])
536 .unwrap();
537 let content = std::str::from_utf8(&content).unwrap();
538
539 let (private, key) = ssh_key();
540 let armor = sign_ssh(&private, content.as_bytes());
541 let oid = raw.commit_signed(content, &armor, None).unwrap();
542
543 let repo = Repo::open_path(&path).unwrap();
544 match repo.signature_state(&Oid::new(oid.to_string()), std::slice::from_ref(&key)) {
545 SignatureState::Verified { fingerprint, .. } => {
546 assert_eq!(fingerprint, key.fingerprint);
547 }
548 other => panic!("expected Verified, got {other:?}"),
549 }
550 }
551}