fabrica

hanna/fabrica

feat(git): verify SSH and GPG commit signatures

7a05468 · hanna committed on 2026-07-25

Read the gpgsig header via libgit2's extract_signature and dispatch on
the armor banner. SSH signatures (ssh-key) embed their own public key, so
we verify against it with namespace "git" and classify by whether its
SHA256 fingerprint is registered — cleanly separating Verified,
UnknownKey, and Invalid. GPG detached signatures (rpgp) carry no key, so
they verify only against a registered key; an unregistered signer is
reported UnknownKey from the signature's issuer fingerprint.

All four SignatureState variants are produced and tested: an in-process
ed25519 key drives the SSH cases (incl. an end-to-end commit_signed round
trip), and a pinned static gpg fixture drives the GPG cases.

rpgp replaces the spec's sequoia-openpgp, which is LGPL and would fail the
license gate (same rationale as avoiding ring); both ssh-key and rpgp are
pure-Rust and MIT/Apache-2.0. KeyKind moves to the model crate. A moka
SignatureCache is provided for the web layer to reuse; expiry/revocation
rejection is a documented best-effort gap. See docs/decisions.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +1786 −6UnifiedSplit
Cargo.lock +1209 −5
@@ -3,6 +3,57 @@
33 version = 4
44
55 [[package]]
6+name = "adler2"
7+version = "2.0.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10+
11+[[package]]
12+name = "aead"
13+version = "0.5.2"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
16+dependencies = [
17+ "bytes",
18+ "crypto-common 0.1.6",
19+ "generic-array",
20+]
21+
22+[[package]]
23+name = "aes"
24+version = "0.8.4"
25+source = "registry+https://github.com/rust-lang/crates.io-index"
26+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
27+dependencies = [
28+ "cfg-if",
29+ "cipher",
30+ "cpufeatures 0.2.17",
31+]
32+
33+[[package]]
34+name = "aes-gcm"
35+version = "0.10.3"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
38+dependencies = [
39+ "aead",
40+ "aes",
41+ "cipher",
42+ "ctr",
43+ "ghash",
44+ "subtle",
45+]
46+
47+[[package]]
48+name = "aes-kw"
49+version = "0.2.1"
50+source = "registry+https://github.com/rust-lang/crates.io-index"
51+checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
52+dependencies = [
53+ "aes",
54+]
55+
56+[[package]]
657 name = "ahash"
758 version = "0.8.12"
859 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -100,6 +151,7 @@ dependencies = [
100151 "blake2",
101152 "cpufeatures 0.2.17",
102153 "password-hash",
154+ "zeroize",
103155 ]
104156
105157 [[package]]
@@ -142,6 +194,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
142194 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
143195
144196 [[package]]
197+name = "base16ct"
198+version = "0.2.0"
199+source = "registry+https://github.com/rust-lang/crates.io-index"
200+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
201+
202+[[package]]
145203 name = "base64"
146204 version = "0.22.1"
147205 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -154,6 +212,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
154212 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
155213
156214 [[package]]
215+name = "bitfields"
216+version = "1.0.3"
217+source = "registry+https://github.com/rust-lang/crates.io-index"
218+checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195"
219+dependencies = [
220+ "bitfields-impl",
221+]
222+
223+[[package]]
224+name = "bitfields-impl"
225+version = "1.0.3"
226+source = "registry+https://github.com/rust-lang/crates.io-index"
227+checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555"
228+dependencies = [
229+ "proc-macro2",
230+ "quote",
231+ "syn 2.0.119",
232+ "thiserror 2.0.19",
233+]
234+
235+[[package]]
157236 name = "bitflags"
158237 version = "2.13.1"
159238 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -163,6 +242,18 @@ dependencies = [
163242 ]
164243
165244 [[package]]
245+name = "bitvec"
246+version = "1.1.1"
247+source = "registry+https://github.com/rust-lang/crates.io-index"
248+checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
249+dependencies = [
250+ "funty",
251+ "radium",
252+ "tap",
253+ "wyz",
254+]
255+
256+[[package]]
166257 name = "blake2"
167258 version = "0.10.6"
168259 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -190,6 +281,25 @@ dependencies = [
190281 ]
191282
192283 [[package]]
284+name = "block-padding"
285+version = "0.3.3"
286+source = "registry+https://github.com/rust-lang/crates.io-index"
287+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
288+dependencies = [
289+ "generic-array",
290+]
291+
292+[[package]]
293+name = "blowfish"
294+version = "0.9.1"
295+source = "registry+https://github.com/rust-lang/crates.io-index"
296+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
297+dependencies = [
298+ "byteorder",
299+ "cipher",
300+]
301+
302+[[package]]
193303 name = "bstr"
194304 version = "1.13.0"
195305 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -200,6 +310,15 @@ dependencies = [
200310 ]
201311
202312 [[package]]
313+name = "buffer-redux"
314+version = "1.1.0"
315+source = "registry+https://github.com/rust-lang/crates.io-index"
316+checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54"
317+dependencies = [
318+ "memchr",
319+]
320+
321+[[package]]
203322 name = "bumpalo"
204323 version = "3.20.3"
205324 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -224,6 +343,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
224343 checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
225344
226345 [[package]]
346+name = "bzip2"
347+version = "0.6.1"
348+source = "registry+https://github.com/rust-lang/crates.io-index"
349+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
350+dependencies = [
351+ "libbz2-rs-sys",
352+]
353+
354+[[package]]
355+name = "camellia"
356+version = "0.1.0"
357+source = "registry+https://github.com/rust-lang/crates.io-index"
358+checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30"
359+dependencies = [
360+ "byteorder",
361+ "cipher",
362+]
363+
364+[[package]]
365+name = "cast5"
366+version = "0.11.1"
367+source = "registry+https://github.com/rust-lang/crates.io-index"
368+checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911"
369+dependencies = [
370+ "cipher",
371+]
372+
373+[[package]]
227374 name = "cc"
228375 version = "1.4.0"
229376 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -236,6 +383,15 @@ dependencies = [
236383 ]
237384
238385 [[package]]
386+name = "cfb-mode"
387+version = "0.8.2"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330"
390+dependencies = [
391+ "cipher",
392+]
393+
394+[[package]]
239395 name = "cfg-if"
240396 version = "1.0.4"
241397 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -253,6 +409,16 @@ dependencies = [
253409 ]
254410
255411 [[package]]
412+name = "cipher"
413+version = "0.4.4"
414+source = "registry+https://github.com/rust-lang/crates.io-index"
415+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
416+dependencies = [
417+ "crypto-common 0.1.6",
418+ "inout",
419+]
420+
421+[[package]]
256422 name = "clap"
257423 version = "4.6.4"
258424 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -310,6 +476,17 @@ dependencies = [
310476 ]
311477
312478 [[package]]
479+name = "cmac"
480+version = "0.7.2"
481+source = "registry+https://github.com/rust-lang/crates.io-index"
482+checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
483+dependencies = [
484+ "cipher",
485+ "dbl",
486+ "digest 0.10.7",
487+]
488+
489+[[package]]
313490 name = "cmov"
314491 version = "0.5.4"
315492 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -353,6 +530,21 @@ dependencies = [
353530 ]
354531
355532 [[package]]
533+name = "const-oid"
534+version = "0.9.6"
535+source = "registry+https://github.com/rust-lang/crates.io-index"
536+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
537+
538+[[package]]
539+name = "convert_case"
540+version = "0.10.0"
541+source = "registry+https://github.com/rust-lang/crates.io-index"
542+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
543+dependencies = [
544+ "unicode-segmentation",
545+]
546+
547+[[package]]
356548 name = "cpufeatures"
357549 version = "0.2.17"
358550 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -386,6 +578,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
386578 checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
387579
388580 [[package]]
581+name = "crc24"
582+version = "0.1.6"
583+source = "registry+https://github.com/rust-lang/crates.io-index"
584+checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0"
585+
586+[[package]]
389587 name = "crossbeam-channel"
390588 version = "0.5.16"
391589 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -419,12 +617,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
419617 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
420618
421619 [[package]]
620+name = "crypto-bigint"
621+version = "0.5.5"
622+source = "registry+https://github.com/rust-lang/crates.io-index"
623+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
624+dependencies = [
625+ "generic-array",
626+ "rand_core 0.6.4",
627+ "subtle",
628+ "zeroize",
629+]
630+
631+[[package]]
422632 name = "crypto-common"
423633 version = "0.1.6"
424634 source = "registry+https://github.com/rust-lang/crates.io-index"
425635 checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
426636 dependencies = [
427637 "generic-array",
638+ "rand_core 0.6.4",
428639 "typenum",
429640 ]
430641
@@ -438,6 +649,15 @@ dependencies = [
438649 ]
439650
440651 [[package]]
652+name = "ctr"
653+version = "0.9.2"
654+source = "registry+https://github.com/rust-lang/crates.io-index"
655+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
656+dependencies = [
657+ "cipher",
658+]
659+
660+[[package]]
441661 name = "ctutils"
442662 version = "0.4.2"
443663 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -447,12 +667,175 @@ dependencies = [
447667 ]
448668
449669 [[package]]
670+name = "curve25519-dalek"
671+version = "4.1.3"
672+source = "registry+https://github.com/rust-lang/crates.io-index"
673+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
674+dependencies = [
675+ "cfg-if",
676+ "cpufeatures 0.2.17",
677+ "curve25519-dalek-derive",
678+ "digest 0.10.7",
679+ "fiat-crypto",
680+ "rustc_version",
681+ "subtle",
682+ "zeroize",
683+]
684+
685+[[package]]
686+name = "curve25519-dalek-derive"
687+version = "0.1.1"
688+source = "registry+https://github.com/rust-lang/crates.io-index"
689+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
690+dependencies = [
691+ "proc-macro2",
692+ "quote",
693+ "syn 2.0.119",
694+]
695+
696+[[package]]
697+name = "cx448"
698+version = "0.1.1"
699+source = "registry+https://github.com/rust-lang/crates.io-index"
700+checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa"
701+dependencies = [
702+ "crypto-bigint",
703+ "elliptic-curve",
704+ "pkcs8",
705+ "rand_core 0.6.4",
706+ "serdect 0.3.0",
707+ "sha3",
708+ "signature",
709+ "subtle",
710+ "zeroize",
711+]
712+
713+[[package]]
714+name = "darling"
715+version = "0.20.11"
716+source = "registry+https://github.com/rust-lang/crates.io-index"
717+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
718+dependencies = [
719+ "darling_core",
720+ "darling_macro",
721+]
722+
723+[[package]]
724+name = "darling_core"
725+version = "0.20.11"
726+source = "registry+https://github.com/rust-lang/crates.io-index"
727+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
728+dependencies = [
729+ "fnv",
730+ "ident_case",
731+ "proc-macro2",
732+ "quote",
733+ "strsim",
734+ "syn 2.0.119",
735+]
736+
737+[[package]]
738+name = "darling_macro"
739+version = "0.20.11"
740+source = "registry+https://github.com/rust-lang/crates.io-index"
741+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
742+dependencies = [
743+ "darling_core",
744+ "quote",
745+ "syn 2.0.119",
746+]
747+
748+[[package]]
749+name = "dbl"
750+version = "0.3.2"
751+source = "registry+https://github.com/rust-lang/crates.io-index"
752+checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9"
753+dependencies = [
754+ "generic-array",
755+]
756+
757+[[package]]
758+name = "der"
759+version = "0.7.10"
760+source = "registry+https://github.com/rust-lang/crates.io-index"
761+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
762+dependencies = [
763+ "const-oid",
764+ "pem-rfc7468",
765+ "zeroize",
766+]
767+
768+[[package]]
769+name = "derive_builder"
770+version = "0.20.2"
771+source = "registry+https://github.com/rust-lang/crates.io-index"
772+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
773+dependencies = [
774+ "derive_builder_macro",
775+]
776+
777+[[package]]
778+name = "derive_builder_core"
779+version = "0.20.2"
780+source = "registry+https://github.com/rust-lang/crates.io-index"
781+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
782+dependencies = [
783+ "darling",
784+ "proc-macro2",
785+ "quote",
786+ "syn 2.0.119",
787+]
788+
789+[[package]]
790+name = "derive_builder_macro"
791+version = "0.20.2"
792+source = "registry+https://github.com/rust-lang/crates.io-index"
793+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
794+dependencies = [
795+ "derive_builder_core",
796+ "syn 2.0.119",
797+]
798+
799+[[package]]
800+name = "derive_more"
801+version = "2.1.1"
802+source = "registry+https://github.com/rust-lang/crates.io-index"
803+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
804+dependencies = [
805+ "derive_more-impl",
806+]
807+
808+[[package]]
809+name = "derive_more-impl"
810+version = "2.1.1"
811+source = "registry+https://github.com/rust-lang/crates.io-index"
812+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
813+dependencies = [
814+ "convert_case",
815+ "proc-macro2",
816+ "quote",
817+ "rustc_version",
818+ "syn 2.0.119",
819+ "unicode-xid",
820+]
821+
822+[[package]]
823+name = "des"
824+version = "0.8.1"
825+source = "registry+https://github.com/rust-lang/crates.io-index"
826+checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
827+dependencies = [
828+ "cipher",
829+]
830+
831+[[package]]
450832 name = "digest"
451833 version = "0.10.7"
452834 source = "registry+https://github.com/rust-lang/crates.io-index"
453835 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
454836 dependencies = [
455837 "block-buffer 0.10.4",
838+ "const-oid",
456839 "crypto-common 0.1.6",
457840 "subtle",
458841 ]
@@ -486,6 +869,74 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
486869 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
487870
488871 [[package]]
872+name = "dsa"
873+version = "0.6.3"
874+source = "registry+https://github.com/rust-lang/crates.io-index"
875+checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689"
876+dependencies = [
877+ "digest 0.10.7",
878+ "num-bigint-dig",
879+ "num-traits",
880+ "pkcs8",
881+ "rfc6979",
882+ "sha2 0.10.9",
883+ "signature",
884+ "zeroize",
885+]
886+
887+[[package]]
888+name = "eax"
889+version = "0.5.0"
890+source = "registry+https://github.com/rust-lang/crates.io-index"
891+checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
892+dependencies = [
893+ "aead",
894+ "cipher",
895+ "cmac",
896+ "ctr",
897+ "subtle",
898+]
899+
900+[[package]]
901+name = "ecdsa"
902+version = "0.16.9"
903+source = "registry+https://github.com/rust-lang/crates.io-index"
904+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
905+dependencies = [
906+ "der",
907+ "digest 0.10.7",
908+ "elliptic-curve",
909+ "rfc6979",
910+ "signature",
911+ "spki",
912+]
913+
914+[[package]]
915+name = "ed25519"
916+version = "2.2.3"
917+source = "registry+https://github.com/rust-lang/crates.io-index"
918+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
919+dependencies = [
920+ "pkcs8",
921+ "signature",
922+]
923+
924+[[package]]
925+name = "ed25519-dalek"
926+version = "2.2.0"
927+source = "registry+https://github.com/rust-lang/crates.io-index"
928+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
929+dependencies = [
930+ "curve25519-dalek",
931+ "ed25519",
932+ "rand_core 0.6.4",
933+ "serde",
934+ "sha2 0.10.9",
935+ "subtle",
936+ "zeroize",
937+]
938+
939+[[package]]
489940 name = "either"
490941 version = "1.17.0"
491942 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -495,6 +946,31 @@ dependencies = [
495946 ]
496947
497948 [[package]]
949+name = "elliptic-curve"
950+version = "0.13.8"
951+source = "registry+https://github.com/rust-lang/crates.io-index"
952+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
953+dependencies = [
954+ "base16ct",
955+ "base64ct",
956+ "crypto-bigint",
957+ "digest 0.10.7",
958+ "ff",
959+ "generic-array",
960+ "group",
961+ "hkdf 0.12.4",
962+ "pem-rfc7468",
963+ "pkcs8",
964+ "rand_core 0.6.4",
965+ "sec1",
966+ "serde_json",
967+ "serdect 0.2.0",
968+ "subtle",
969+ "tap",
970+ "zeroize",
971+]
972+
973+[[package]]
498974 name = "encode_unicode"
499975 version = "1.0.0"
500976 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -551,6 +1027,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5511027 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
5521028
5531029 [[package]]
1030+name = "ff"
1031+version = "0.13.1"
1032+source = "registry+https://github.com/rust-lang/crates.io-index"
1033+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
1034+dependencies = [
1035+ "bitvec",
1036+ "rand_core 0.6.4",
1037+ "subtle",
1038+]
1039+
1040+[[package]]
1041+name = "fiat-crypto"
1042+version = "0.2.9"
1043+source = "registry+https://github.com/rust-lang/crates.io-index"
1044+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1045+
1046+[[package]]
5541047 name = "figment"
5551048 version = "0.10.19"
5561049 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -573,6 +1066,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5731066 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
5741067
5751068 [[package]]
1069+name = "flate2"
1070+version = "1.1.9"
1071+source = "registry+https://github.com/rust-lang/crates.io-index"
1072+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
1073+dependencies = [
1074+ "miniz_oxide",
1075+ "zlib-rs",
1076+]
1077+
1078+[[package]]
5761079 name = "flume"
5771080 version = "0.12.0"
5781081 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -584,6 +1087,12 @@ dependencies = [
5841087 ]
5851088
5861089 [[package]]
1090+name = "fnv"
1091+version = "1.0.7"
1092+source = "registry+https://github.com/rust-lang/crates.io-index"
1093+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1094+
1095+[[package]]
5871096 name = "foldhash"
5881097 version = "0.2.0"
5891098 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -599,6 +1108,12 @@ dependencies = [
5991108 ]
6001109
6011110 [[package]]
1111+name = "funty"
1112+version = "2.0.0"
1113+source = "registry+https://github.com/rust-lang/crates.io-index"
1114+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
1115+
1116+[[package]]
6021117 name = "futures-channel"
6031118 version = "0.3.33"
6041119 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -677,6 +1192,7 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
6771192 dependencies = [
6781193 "typenum",
6791194 "version_check",
1195+ "zeroize",
6801196 ]
6811197
6821198 [[package]]
@@ -715,6 +1231,16 @@ dependencies = [
7151231 ]
7161232
7171233 [[package]]
1234+name = "ghash"
1235+version = "0.5.1"
1236+source = "registry+https://github.com/rust-lang/crates.io-index"
1237+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
1238+dependencies = [
1239+ "opaque-debug",
1240+ "polyval",
1241+]
1242+
1243+[[package]]
7181244 name = "git"
7191245 version = "0.1.0"
7201246 dependencies = [
@@ -723,6 +1249,9 @@ dependencies = [
7231249 "libgit2-sys",
7241250 "model",
7251251 "moka",
1252+ "pgp",
1253+ "rand_core 0.6.4",
1254+ "ssh-key",
7261255 "tempfile",
7271256 "thiserror 2.0.19",
7281257 ]
@@ -754,6 +1283,17 @@ dependencies = [
7541283 ]
7551284
7561285 [[package]]
1286+name = "group"
1287+version = "0.13.0"
1288+source = "registry+https://github.com/rust-lang/crates.io-index"
1289+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
1290+dependencies = [
1291+ "ff",
1292+ "rand_core 0.6.4",
1293+ "subtle",
1294+]
1295+
1296+[[package]]
7571297 name = "hashbrown"
7581298 version = "0.16.1"
7591299 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -801,6 +1341,15 @@ dependencies = [
8011341
8021342 [[package]]
8031343 name = "hkdf"
1344+version = "0.12.4"
1345+source = "registry+https://github.com/rust-lang/crates.io-index"
1346+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1347+dependencies = [
1348+ "hmac 0.12.1",
1349+]
1350+
1351+[[package]]
1352+name = "hkdf"
8041353 version = "0.13.0"
8051354 source = "registry+https://github.com/rust-lang/crates.io-index"
8061355 checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
@@ -917,6 +1466,21 @@ dependencies = [
9171466 ]
9181467
9191468 [[package]]
1469+name = "idea"
1470+version = "0.5.1"
1471+source = "registry+https://github.com/rust-lang/crates.io-index"
1472+checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477"
1473+dependencies = [
1474+ "cipher",
1475+]
1476+
1477+[[package]]
1478+name = "ident_case"
1479+version = "1.0.1"
1480+source = "registry+https://github.com/rust-lang/crates.io-index"
1481+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1482+
1483+[[package]]
9201484 name = "idna"
9211485 version = "1.1.0"
9221486 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -971,6 +1535,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9711535 checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
9721536
9731537 [[package]]
1538+name = "inout"
1539+version = "0.1.4"
1540+source = "registry+https://github.com/rust-lang/crates.io-index"
1541+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1542+dependencies = [
1543+ "generic-array",
1544+]
1545+
1546+[[package]]
9741547 name = "insta"
9751548 version = "1.48.0"
9761549 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1016,10 +1589,42 @@ dependencies = [
10161589 ]
10171590
10181591 [[package]]
1592+name = "k256"
1593+version = "0.13.4"
1594+source = "registry+https://github.com/rust-lang/crates.io-index"
1595+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
1596+dependencies = [
1597+ "cfg-if",
1598+ "ecdsa",
1599+ "elliptic-curve",
1600+ "once_cell",
1601+ "sha2 0.10.9",
1602+ "signature",
1603+]
1604+
1605+[[package]]
1606+name = "keccak"
1607+version = "0.1.6"
1608+source = "registry+https://github.com/rust-lang/crates.io-index"
1609+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
1610+dependencies = [
1611+ "cpufeatures 0.2.17",
1612+]
1613+
1614+[[package]]
10191615 name = "lazy_static"
10201616 version = "1.5.0"
10211617 source = "registry+https://github.com/rust-lang/crates.io-index"
10221618 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1619+dependencies = [
1620+ "spin",
1621+]
1622+
1623+[[package]]
1624+name = "libbz2-rs-sys"
1625+version = "0.2.5"
1626+source = "registry+https://github.com/rust-lang/crates.io-index"
1627+checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
10231628
10241629 [[package]]
10251630 name = "libc"
@@ -1040,6 +1645,12 @@ dependencies = [
10401645 ]
10411646
10421647 [[package]]
1648+name = "libm"
1649+version = "0.2.16"
1650+source = "registry+https://github.com/rust-lang/crates.io-index"
1651+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1652+
1653+[[package]]
10431654 name = "libsqlite3-sys"
10441655 version = "0.37.0"
10451656 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1095,6 +1706,16 @@ version = "0.1.0"
10951706
10961707 [[package]]
10971708 name = "md-5"
1709+version = "0.10.6"
1710+source = "registry+https://github.com/rust-lang/crates.io-index"
1711+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
1712+dependencies = [
1713+ "cfg-if",
1714+ "digest 0.10.7",
1715+]
1716+
1717+[[package]]
1718+name = "md-5"
10981719 version = "0.11.0"
10991720 source = "registry+https://github.com/rust-lang/crates.io-index"
11001721 checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
@@ -1110,6 +1731,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11101731 checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
11111732
11121733 [[package]]
1734+name = "miniz_oxide"
1735+version = "0.8.9"
1736+source = "registry+https://github.com/rust-lang/crates.io-index"
1737+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
1738+dependencies = [
1739+ "adler2",
1740+ "simd-adler32",
1741+]
1742+
1743+[[package]]
11131744 name = "mio"
11141745 version = "1.2.2"
11151746 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1145,12 +1776,91 @@ dependencies = [
11451776 ]
11461777
11471778 [[package]]
1779+name = "nom"
1780+version = "8.0.0"
1781+source = "registry+https://github.com/rust-lang/crates.io-index"
1782+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
1783+dependencies = [
1784+ "memchr",
1785+]
1786+
1787+[[package]]
1788+name = "num-bigint-dig"
1789+version = "0.8.6"
1790+source = "registry+https://github.com/rust-lang/crates.io-index"
1791+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
1792+dependencies = [
1793+ "lazy_static",
1794+ "libm",
1795+ "num-integer",
1796+ "num-iter",
1797+ "num-traits",
1798+ "rand 0.8.7",
1799+ "serde",
1800+ "smallvec",
1801+ "zeroize",
1802+]
1803+
1804+[[package]]
1805+name = "num-integer"
1806+version = "0.1.46"
1807+source = "registry+https://github.com/rust-lang/crates.io-index"
1808+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
1809+dependencies = [
1810+ "num-traits",
1811+]
1812+
1813+[[package]]
1814+name = "num-iter"
1815+version = "0.1.46"
1816+source = "registry+https://github.com/rust-lang/crates.io-index"
1817+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
1818+dependencies = [
1819+ "num-integer",
1820+ "num-traits",
1821+]
1822+
1823+[[package]]
11481824 name = "num-traits"
11491825 version = "0.2.19"
11501826 source = "registry+https://github.com/rust-lang/crates.io-index"
11511827 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
11521828 dependencies = [
11531829 "autocfg",
1830+ "libm",
1831+]
1832+
1833+[[package]]
1834+name = "num_enum"
1835+version = "0.7.6"
1836+source = "registry+https://github.com/rust-lang/crates.io-index"
1837+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
1838+dependencies = [
1839+ "num_enum_derive",
1840+ "rustversion",
1841+]
1842+
1843+[[package]]
1844+name = "num_enum_derive"
1845+version = "0.7.6"
1846+source = "registry+https://github.com/rust-lang/crates.io-index"
1847+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
1848+dependencies = [
1849+ "proc-macro2",
1850+ "quote",
1851+ "syn 2.0.119",
1852+]
1853+
1854+[[package]]
1855+name = "ocb3"
1856+version = "0.1.0"
1857+source = "registry+https://github.com/rust-lang/crates.io-index"
1858+checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
1859+dependencies = [
1860+ "aead",
1861+ "cipher",
1862+ "ctr",
1863+ "subtle",
11541864 ]
11551865
11561866 [[package]]
@@ -1163,7 +1873,51 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
11631873 name = "once_cell_polyfill"
11641874 version = "1.70.2"
11651875 source = "registry+https://github.com/rust-lang/crates.io-index"
1166-checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1876+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1877+
1878+[[package]]
1879+name = "opaque-debug"
1880+version = "0.3.1"
1881+source = "registry+https://github.com/rust-lang/crates.io-index"
1882+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
1883+
1884+[[package]]
1885+name = "p256"
1886+version = "0.13.2"
1887+source = "registry+https://github.com/rust-lang/crates.io-index"
1888+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
1889+dependencies = [
1890+ "ecdsa",
1891+ "elliptic-curve",
1892+ "primeorder",
1893+ "sha2 0.10.9",
1894+]
1895+
1896+[[package]]
1897+name = "p384"
1898+version = "0.13.1"
1899+source = "registry+https://github.com/rust-lang/crates.io-index"
1900+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
1901+dependencies = [
1902+ "ecdsa",
1903+ "elliptic-curve",
1904+ "primeorder",
1905+ "sha2 0.10.9",
1906+]
1907+
1908+[[package]]
1909+name = "p521"
1910+version = "0.13.3"
1911+source = "registry+https://github.com/rust-lang/crates.io-index"
1912+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
1913+dependencies = [
1914+ "base16ct",
1915+ "ecdsa",
1916+ "elliptic-curve",
1917+ "primeorder",
1918+ "rand_core 0.6.4",
1919+ "sha2 0.10.9",
1920+]
11671921
11681922 [[package]]
11691923 name = "parking"
@@ -1229,24 +1983,136 @@ dependencies = [
12291983 ]
12301984
12311985 [[package]]
1986+name = "pem-rfc7468"
1987+version = "0.7.0"
1988+source = "registry+https://github.com/rust-lang/crates.io-index"
1989+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
1990+dependencies = [
1991+ "base64ct",
1992+]
1993+
1994+[[package]]
12321995 name = "percent-encoding"
12331996 version = "2.3.2"
12341997 source = "registry+https://github.com/rust-lang/crates.io-index"
12351998 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
12361999
12372000 [[package]]
2001+name = "pgp"
2002+version = "0.20.0"
2003+source = "registry+https://github.com/rust-lang/crates.io-index"
2004+checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
2005+dependencies = [
2006+ "aead",
2007+ "aes",
2008+ "aes-gcm",
2009+ "aes-kw",
2010+ "argon2",
2011+ "base64",
2012+ "bitfields",
2013+ "block-padding",
2014+ "blowfish",
2015+ "buffer-redux",
2016+ "byteorder",
2017+ "bytes",
2018+ "bzip2",
2019+ "camellia",
2020+ "cast5",
2021+ "cfb-mode",
2022+ "cipher",
2023+ "const-oid",
2024+ "crc24",
2025+ "curve25519-dalek",
2026+ "cx448",
2027+ "derive_builder",
2028+ "derive_more",
2029+ "des",
2030+ "digest 0.10.7",
2031+ "dsa",
2032+ "eax",
2033+ "ecdsa",
2034+ "ed25519-dalek",
2035+ "elliptic-curve",
2036+ "flate2",
2037+ "generic-array",
2038+ "hex",
2039+ "hkdf 0.12.4",
2040+ "idea",
2041+ "k256",
2042+ "log",
2043+ "md-5 0.10.6",
2044+ "memchr",
2045+ "nom",
2046+ "num-bigint-dig",
2047+ "num-traits",
2048+ "num_enum",
2049+ "ocb3",
2050+ "p256",
2051+ "p384",
2052+ "p521",
2053+ "rand 0.8.7",
2054+ "replace_with",
2055+ "ripemd",
2056+ "rsa",
2057+ "sha1 0.10.7",
2058+ "sha1-checked",
2059+ "sha2 0.10.9",
2060+ "sha3",
2061+ "signature",
2062+ "smallvec",
2063+ "snafu",
2064+ "subtle",
2065+ "twofish",
2066+ "x25519-dalek",
2067+ "zeroize",
2068+]
2069+
2070+[[package]]
12382071 name = "pin-project-lite"
12392072 version = "0.2.17"
12402073 source = "registry+https://github.com/rust-lang/crates.io-index"
12412074 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
12422075
12432076 [[package]]
2077+name = "pkcs1"
2078+version = "0.7.5"
2079+source = "registry+https://github.com/rust-lang/crates.io-index"
2080+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
2081+dependencies = [
2082+ "der",
2083+ "pkcs8",
2084+ "spki",
2085+]
2086+
2087+[[package]]
2088+name = "pkcs8"
2089+version = "0.10.2"
2090+source = "registry+https://github.com/rust-lang/crates.io-index"
2091+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
2092+dependencies = [
2093+ "der",
2094+ "spki",
2095+]
2096+
2097+[[package]]
12442098 name = "pkg-config"
12452099 version = "0.3.33"
12462100 source = "registry+https://github.com/rust-lang/crates.io-index"
12472101 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
12482102
12492103 [[package]]
2104+name = "polyval"
2105+version = "0.6.2"
2106+source = "registry+https://github.com/rust-lang/crates.io-index"
2107+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
2108+dependencies = [
2109+ "cfg-if",
2110+ "cpufeatures 0.2.17",
2111+ "opaque-debug",
2112+ "universal-hash",
2113+]
2114+
2115+[[package]]
12502116 name = "portable-atomic"
12512117 version = "1.14.0"
12522118 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1271,6 +2137,15 @@ dependencies = [
12712137 ]
12722138
12732139 [[package]]
2140+name = "primeorder"
2141+version = "0.13.6"
2142+source = "registry+https://github.com/rust-lang/crates.io-index"
2143+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
2144+dependencies = [
2145+ "elliptic-curve",
2146+]
2147+
2148+[[package]]
12742149 name = "proc-macro2"
12752150 version = "1.0.107"
12762151 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1314,12 +2189,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13142189 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
13152190
13162191 [[package]]
2192+name = "radium"
2193+version = "0.7.0"
2194+source = "registry+https://github.com/rust-lang/crates.io-index"
2195+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
2196+
2197+[[package]]
2198+name = "rand"
2199+version = "0.8.7"
2200+source = "registry+https://github.com/rust-lang/crates.io-index"
2201+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
2202+dependencies = [
2203+ "libc",
2204+ "rand_chacha 0.3.1",
2205+ "rand_core 0.6.4",
2206+]
2207+
2208+[[package]]
13172209 name = "rand"
13182210 version = "0.9.5"
13192211 source = "registry+https://github.com/rust-lang/crates.io-index"
13202212 checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
13212213 dependencies = [
1322- "rand_chacha",
2214+ "rand_chacha 0.9.0",
13232215 "rand_core 0.9.5",
13242216 ]
13252217
@@ -1336,6 +2228,16 @@ dependencies = [
13362228
13372229 [[package]]
13382230 name = "rand_chacha"
2231+version = "0.3.1"
2232+source = "registry+https://github.com/rust-lang/crates.io-index"
2233+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
2234+dependencies = [
2235+ "ppv-lite86",
2236+ "rand_core 0.6.4",
2237+]
2238+
2239+[[package]]
2240+name = "rand_chacha"
13392241 version = "0.9.0"
13402242 source = "registry+https://github.com/rust-lang/crates.io-index"
13412243 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
@@ -1407,6 +2309,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
14072309 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
14082310
14092311 [[package]]
2312+name = "replace_with"
2313+version = "0.1.8"
2314+source = "registry+https://github.com/rust-lang/crates.io-index"
2315+checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884"
2316+
2317+[[package]]
2318+name = "rfc6979"
2319+version = "0.4.0"
2320+source = "registry+https://github.com/rust-lang/crates.io-index"
2321+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
2322+dependencies = [
2323+ "hmac 0.12.1",
2324+ "subtle",
2325+]
2326+
2327+[[package]]
2328+name = "ripemd"
2329+version = "0.1.3"
2330+source = "registry+https://github.com/rust-lang/crates.io-index"
2331+checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
2332+dependencies = [
2333+ "digest 0.10.7",
2334+]
2335+
2336+[[package]]
14102337 name = "rpassword"
14112338 version = "7.5.4"
14122339 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1418,6 +2345,27 @@ dependencies = [
14182345 ]
14192346
14202347 [[package]]
2348+name = "rsa"
2349+version = "0.9.10"
2350+source = "registry+https://github.com/rust-lang/crates.io-index"
2351+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
2352+dependencies = [
2353+ "const-oid",
2354+ "digest 0.10.7",
2355+ "num-bigint-dig",
2356+ "num-integer",
2357+ "num-traits",
2358+ "pkcs1",
2359+ "pkcs8",
2360+ "rand_core 0.6.4",
2361+ "sha2 0.10.9",
2362+ "signature",
2363+ "spki",
2364+ "subtle",
2365+ "zeroize",
2366+]
2367+
2368+[[package]]
14212369 name = "rtoolbox"
14222370 version = "0.0.5"
14232371 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1428,6 +2376,15 @@ dependencies = [
14282376 ]
14292377
14302378 [[package]]
2379+name = "rustc_version"
2380+version = "0.4.1"
2381+source = "registry+https://github.com/rust-lang/crates.io-index"
2382+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2383+dependencies = [
2384+ "semver",
2385+]
2386+
2387+[[package]]
14312388 name = "rustix"
14322389 version = "1.1.4"
14332390 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1453,6 +2410,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
14532410 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
14542411
14552412 [[package]]
2413+name = "sec1"
2414+version = "0.7.3"
2415+source = "registry+https://github.com/rust-lang/crates.io-index"
2416+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2417+dependencies = [
2418+ "base16ct",
2419+ "der",
2420+ "generic-array",
2421+ "pkcs8",
2422+ "serdect 0.2.0",
2423+ "subtle",
2424+ "zeroize",
2425+]
2426+
2427+[[package]]
2428+name = "semver"
2429+version = "1.0.28"
2430+source = "registry+https://github.com/rust-lang/crates.io-index"
2431+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
2432+
2433+[[package]]
14562434 name = "serde"
14572435 version = "1.0.229"
14582436 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1505,6 +2483,37 @@ dependencies = [
15052483 ]
15062484
15072485 [[package]]
2486+name = "serdect"
2487+version = "0.2.0"
2488+source = "registry+https://github.com/rust-lang/crates.io-index"
2489+checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
2490+dependencies = [
2491+ "base16ct",
2492+ "serde",
2493+]
2494+
2495+[[package]]
2496+name = "serdect"
2497+version = "0.3.0"
2498+source = "registry+https://github.com/rust-lang/crates.io-index"
2499+checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53"
2500+dependencies = [
2501+ "base16ct",
2502+ "serde",
2503+]
2504+
2505+[[package]]
2506+name = "sha1"
2507+version = "0.10.7"
2508+source = "registry+https://github.com/rust-lang/crates.io-index"
2509+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
2510+dependencies = [
2511+ "cfg-if",
2512+ "cpufeatures 0.2.17",
2513+ "digest 0.10.7",
2514+]
2515+
2516+[[package]]
15082517 name = "sha1"
15092518 version = "0.11.0"
15102519 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1516,6 +2525,17 @@ dependencies = [
15162525 ]
15172526
15182527 [[package]]
2528+name = "sha1-checked"
2529+version = "0.10.0"
2530+source = "registry+https://github.com/rust-lang/crates.io-index"
2531+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
2532+dependencies = [
2533+ "digest 0.10.7",
2534+ "sha1 0.10.7",
2535+ "zeroize",
2536+]
2537+
2538+[[package]]
15192539 name = "sha2"
15202540 version = "0.10.9"
15212541 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1538,12 +2558,38 @@ dependencies = [
15382558 ]
15392559
15402560 [[package]]
2561+name = "sha3"
2562+version = "0.10.9"
2563+source = "registry+https://github.com/rust-lang/crates.io-index"
2564+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
2565+dependencies = [
2566+ "digest 0.10.7",
2567+ "keccak",
2568+]
2569+
2570+[[package]]
15412571 name = "shlex"
15422572 version = "2.0.1"
15432573 source = "registry+https://github.com/rust-lang/crates.io-index"
15442574 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
15452575
15462576 [[package]]
2577+name = "signature"
2578+version = "2.2.0"
2579+source = "registry+https://github.com/rust-lang/crates.io-index"
2580+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2581+dependencies = [
2582+ "digest 0.10.7",
2583+ "rand_core 0.6.4",
2584+]
2585+
2586+[[package]]
2587+name = "simd-adler32"
2588+version = "0.3.10"
2589+source = "registry+https://github.com/rust-lang/crates.io-index"
2590+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
2591+
2592+[[package]]
15472593 name = "similar"
15482594 version = "2.7.0"
15492595 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1565,6 +2611,27 @@ dependencies = [
15652611 ]
15662612
15672613 [[package]]
2614+name = "snafu"
2615+version = "0.9.2"
2616+source = "registry+https://github.com/rust-lang/crates.io-index"
2617+checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
2618+dependencies = [
2619+ "snafu-derive",
2620+]
2621+
2622+[[package]]
2623+name = "snafu-derive"
2624+version = "0.9.2"
2625+source = "registry+https://github.com/rust-lang/crates.io-index"
2626+checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
2627+dependencies = [
2628+ "heck",
2629+ "proc-macro2",
2630+ "quote",
2631+ "syn 2.0.119",
2632+]
2633+
2634+[[package]]
15682635 name = "socket2"
15692636 version = "0.6.5"
15702637 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1584,6 +2651,16 @@ dependencies = [
15842651 ]
15852652
15862653 [[package]]
2654+name = "spki"
2655+version = "0.7.3"
2656+source = "registry+https://github.com/rust-lang/crates.io-index"
2657+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
2658+dependencies = [
2659+ "base64ct",
2660+ "der",
2661+]
2662+
2663+[[package]]
15872664 name = "sqlx"
15882665 version = "0.9.0"
15892666 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1687,7 +2764,7 @@ dependencies = [
16872764 "log",
16882765 "percent-encoding",
16892766 "serde",
1690- "sha1",
2767+ "sha1 0.11.0",
16912768 "sha2 0.11.0",
16922769 "sqlx-core",
16932770 "thiserror 2.0.19",
@@ -1711,11 +2788,11 @@ dependencies = [
17112788 "futures-core",
17122789 "futures-util",
17132790 "hex",
1714- "hkdf",
2791+ "hkdf 0.13.0",
17152792 "hmac 0.13.0",
17162793 "itoa",
17172794 "log",
1718- "md-5",
2795+ "md-5 0.11.0",
17192796 "memchr",
17202797 "rand 0.10.2",
17212798 "serde",
@@ -1758,6 +2835,49 @@ name = "ssh"
17582835 version = "0.1.0"
17592836
17602837 [[package]]
2838+name = "ssh-cipher"
2839+version = "0.2.0"
2840+source = "registry+https://github.com/rust-lang/crates.io-index"
2841+checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
2842+dependencies = [
2843+ "cipher",
2844+ "ssh-encoding",
2845+]
2846+
2847+[[package]]
2848+name = "ssh-encoding"
2849+version = "0.2.0"
2850+source = "registry+https://github.com/rust-lang/crates.io-index"
2851+checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
2852+dependencies = [
2853+ "base64ct",
2854+ "pem-rfc7468",
2855+ "sha2 0.10.9",
2856+]
2857+
2858+[[package]]
2859+name = "ssh-key"
2860+version = "0.6.7"
2861+source = "registry+https://github.com/rust-lang/crates.io-index"
2862+checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
2863+dependencies = [
2864+ "ed25519-dalek",
2865+ "num-bigint-dig",
2866+ "p256",
2867+ "p384",
2868+ "p521",
2869+ "rand_core 0.6.4",
2870+ "rsa",
2871+ "sec1",
2872+ "sha2 0.10.9",
2873+ "signature",
2874+ "ssh-cipher",
2875+ "ssh-encoding",
2876+ "subtle",
2877+ "zeroize",
2878+]
2879+
2880+[[package]]
17612881 name = "stable_deref_trait"
17622882 version = "1.2.1"
17632883 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1838,6 +2958,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
18382958 checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
18392959
18402960 [[package]]
2961+name = "tap"
2962+version = "1.0.1"
2963+source = "registry+https://github.com/rust-lang/crates.io-index"
2964+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
2965+
2966+[[package]]
18412967 name = "tempfile"
18422968 version = "3.27.0"
18432969 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2056,6 +3182,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
20563182 checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
20573183
20583184 [[package]]
3185+name = "twofish"
3186+version = "0.7.1"
3187+source = "registry+https://github.com/rust-lang/crates.io-index"
3188+checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013"
3189+dependencies = [
3190+ "cipher",
3191+]
3192+
3193+[[package]]
20593194 name = "typenum"
20603195 version = "1.20.1"
20613196 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2108,6 +3243,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
21083243 checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
21093244
21103245 [[package]]
3246+name = "unicode-segmentation"
3247+version = "1.13.3"
3248+source = "registry+https://github.com/rust-lang/crates.io-index"
3249+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
3250+
3251+[[package]]
3252+name = "unicode-xid"
3253+version = "0.2.6"
3254+source = "registry+https://github.com/rust-lang/crates.io-index"
3255+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
3256+
3257+[[package]]
3258+name = "universal-hash"
3259+version = "0.5.1"
3260+source = "registry+https://github.com/rust-lang/crates.io-index"
3261+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
3262+dependencies = [
3263+ "crypto-common 0.1.6",
3264+ "subtle",
3265+]
3266+
3267+[[package]]
21113268 name = "url"
21123269 version = "2.5.8"
21133270 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2350,6 +3507,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
23503507 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
23513508
23523509 [[package]]
3510+name = "wyz"
3511+version = "0.5.1"
3512+source = "registry+https://github.com/rust-lang/crates.io-index"
3513+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
3514+dependencies = [
3515+ "tap",
3516+]
3517+
3518+[[package]]
3519+name = "x25519-dalek"
3520+version = "2.0.1"
3521+source = "registry+https://github.com/rust-lang/crates.io-index"
3522+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
3523+dependencies = [
3524+ "curve25519-dalek",
3525+ "rand_core 0.6.4",
3526+ "serde",
3527+ "zeroize",
3528+]
3529+
3530+[[package]]
23533531 name = "yansi"
23543532 version = "1.0.1"
23553533 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2420,6 +3598,26 @@ dependencies = [
24203598 ]
24213599
24223600 [[package]]
3601+name = "zeroize"
3602+version = "1.9.0"
3603+source = "registry+https://github.com/rust-lang/crates.io-index"
3604+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
3605+dependencies = [
3606+ "zeroize_derive",
3607+]
3608+
3609+[[package]]
3610+name = "zeroize_derive"
3611+version = "1.5.0"
3612+source = "registry+https://github.com/rust-lang/crates.io-index"
3613+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
3614+dependencies = [
3615+ "proc-macro2",
3616+ "quote",
3617+ "syn 2.0.119",
3618+]
3619+
3620+[[package]]
24233621 name = "zerotrie"
24243622 version = "0.2.4"
24253623 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2453,6 +3651,12 @@ dependencies = [
24533651 ]
24543652
24553653 [[package]]
3654+name = "zlib-rs"
3655+version = "0.6.6"
3656+source = "registry+https://github.com/rust-lang/crates.io-index"
3657+checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
3658+
3659+[[package]]
24563660 name = "zmij"
24573661 version = "1.0.23"
24583662 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +5 −0
@@ -96,6 +96,11 @@ moka = { version = "0.12", features = ["sync"] }
9696 inkjet = "0.11.1"
9797 # Snapshot testing.
9898 insta = "1"
99+# Signature verification. Both are pure-Rust (ed25519-dalek/rsa/p256, no `ring`,
100+# no OpenSSL) and MIT/Apache-2.0 — sequoia-openpgp is deliberately avoided as it
101+# is LGPL, which our license gate forbids (see docs/decisions.md).
102+ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] }
103+pgp = "0.20"
99104
100105 [workspace.lints.rust]
101106 unsafe_code = "forbid"
crates/git/Cargo.toml +4 −0
@@ -14,6 +14,8 @@ model = { workspace = true }
1414 git2 = { workspace = true }
1515 globset = { workspace = true }
1616 moka = { workspace = true }
17+ssh-key = { workspace = true }
18+pgp = { workspace = true }
1719 thiserror = { workspace = true }
1820 # Pin the libgit2 bindings to a build that requires libgit2 <= the version
1921 # nixpkgs ships (1.9.4). git2 0.20's default libgit2-sys (0.18.7, bundling 1.9.6)
@@ -24,6 +26,8 @@ libgit2-sys = "=0.18.3"
2426
2527 [dev-dependencies]
2628 tempfile = { workspace = true }
29+# Random key generation for the SSH signature fixtures (PrivateKey::random).
30+rand_core = { workspace = true }
2731
2832 [lints]
2933 workspace = true
crates/git/src/lib.rs +2 −0
@@ -29,6 +29,7 @@
2929
3030 mod attributes;
3131 mod repo;
32+mod signature;
3233 mod types;
3334
3435 use std::fs;
@@ -40,6 +41,7 @@ use git2::{Repository, RepositoryInitOptions};
4041
4142 pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
4243 pub use crate::repo::Repo;
44+pub use crate::signature::{SignatureCache, SignatureState, SigningKey};
4345 pub use crate::types::{
4446 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
4547 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
crates/git/src/signature.rs +492 −0
@@ -0,0 +1,492 @@
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+
25+use std::sync::Arc;
26+
27+use model::KeyKind;
28+use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey};
29+use pgp::types::KeyDetails;
30+use ssh_key::{HashAlg, PublicKey, SshSig};
31+
32+use crate::repo::Repo;
33+use 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)]
38+pub 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)]
59+pub 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+
90+impl 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.
124+pub(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.
170+pub(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+/// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so
228+/// only surrounding whitespace is trimmed.
229+fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool {
230+ stored.trim() == computed.trim()
231+}
232+
233+/// Compare two GPG fingerprints, ignoring case, spaces, and `0x`/colon separators.
234+fn gpg_fingerprint_matches(a: &str, b: &str) -> bool {
235+ normalize_hex(a) == normalize_hex(b)
236+}
237+
238+/// Reduce a hex fingerprint to lowercase hex digits only, so representations that
239+/// differ by case, spaces, or separators compare equal.
240+fn normalize_hex(fp: &str) -> String {
241+ fp.chars()
242+ .filter(char::is_ascii_hexdigit)
243+ .map(|c| c.to_ascii_lowercase())
244+ .collect()
245+}
246+
247+/// A bounded cache of verification results keyed by object oid.
248+///
249+/// Verification is expensive, so list views reuse results across renders. The
250+/// result depends on the registered key set, so the web layer **must** clear the
251+/// cache when keys change (it already bumps `cache_epoch` on such mutations).
252+#[derive(Clone)]
253+pub struct SignatureCache {
254+ inner: moka::sync::Cache<String, Arc<SignatureState>>,
255+}
256+
257+impl SignatureCache {
258+ /// A cache holding up to `capacity` results.
259+ #[must_use]
260+ pub fn new(capacity: u64) -> Self {
261+ Self {
262+ inner: moka::sync::Cache::new(capacity),
263+ }
264+ }
265+
266+ /// Fetch or compute the signature state for `oid`.
267+ #[must_use]
268+ pub fn get(&self, repo: &Repo, oid: &Oid, keys: &[SigningKey]) -> Arc<SignatureState> {
269+ if let Some(hit) = self.inner.get(oid.as_str()) {
270+ return hit;
271+ }
272+ let state = Arc::new(repo.signature_state(oid, keys));
273+ self.inner.insert(oid.to_string(), Arc::clone(&state));
274+ state
275+ }
276+
277+ /// Drop every cached result. Call after any key mutation.
278+ pub fn clear(&self) {
279+ self.inner.invalidate_all();
280+ }
281+}
282+
283+impl Default for SignatureCache {
284+ fn default() -> Self {
285+ Self::new(1024)
286+ }
287+}
288+
289+#[cfg(test)]
290+mod tests {
291+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
292+
293+ use rand_core::OsRng;
294+ use ssh_key::{Algorithm, LineEnding, PrivateKey};
295+
296+ use super::*;
297+
298+ /// A GPG key, its detached signature over [`GPG_PAYLOAD`], and its fingerprint,
299+ /// generated once with `gpg` and pinned here (§14: ship a static GPG fixture).
300+ const GPG_PUB: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n\
301+mDMEamQrVhYJKwYBBAHaRw8BAQdAZmnSAPnE0i9ZFD376jPvfybRQnCVgdZznu/Z\n\
302+xu9NMc+0I0ZhYnJpY2EgVGVzdCA8dGVzdEBmYWJyaWNhLmV4YW1wbGU+iJAEExYK\n\
303+ADgWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbAwULCQgHAgYVCgkICwIE\n\
304+FgIDAQIeAQIXgAAKCRCZoNNqXdKXKHT3APkBBY7MB1be0vsOf0Krr5+mLzStHwjQ\n\
305+ZhFF/DtG/1L+sgD+KcKERhLj5L5b/FHBxYGpw2ZY4aQoCOeGmk+zmflLbwS4OARq\n\
306+ZCtWEgorBgEEAZdVAQUBAQdAeLjNUQ2tEAUDtr+Iu7qa/EOArrAd3QSWKHNWXfX7\n\
307+4lIDAQgHiHgEGBYKACAWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbDAAK\n\
308+CRCZoNNqXdKXKJIEAP9Z2KdPQ7yVGAC6XjDoYKbbeAu58G4L4hqiq+JK6v18lwD/\n\
309+T4OoXtnXPpwpy5jv0KTtE+cjQCl1W2qhQTFSqlXeawM=\n\
310+=EOde\n\
311+-----END PGP PUBLIC KEY BLOCK-----\n";
312+
313+ const GPG_SIG: &str = "-----BEGIN PGP SIGNATURE-----\n\n\
314+iHUEABYKAB0WIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgAKCRCZoNNqXdKX\n\
315+KCYOAQCaiD7VrRkDvxqwatypcp/0xfY9oc3OclJVa+rFVkUNMQD9HxnEfelG+hwU\n\
316+MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\
317+=lUCx\n\
318+-----END PGP SIGNATURE-----\n";
319+
320+ const GPG_PAYLOAD: &[u8] = b"fabrica signed payload\nsecond line\n";
321+ const GPG_FPR: &str = "42c34af4c3bfc83e1c8610f599a0d36a5dd29728";
322+
323+ fn gpg_key() -> SigningKey {
324+ SigningKey {
325+ user_id: "user-1".into(),
326+ key_id: "key-1".into(),
327+ kind: KeyKind::Gpg,
328+ fingerprint: GPG_FPR.into(),
329+ public_key: GPG_PUB.into(),
330+ }
331+ }
332+
333+ /// Generate an ed25519 SSH key, its `authorized_keys` line, and its SHA256
334+ /// fingerprint, entirely in-process.
335+ fn ssh_key() -> (PrivateKey, SigningKey) {
336+ let private = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
337+ let public = private.public_key();
338+ let key = SigningKey {
339+ user_id: "user-2".into(),
340+ key_id: "key-2".into(),
341+ kind: KeyKind::Ssh,
342+ fingerprint: public.fingerprint(HashAlg::Sha256).to_string(),
343+ public_key: public.to_openssh().unwrap(),
344+ };
345+ (private, key)
346+ }
347+
348+ fn sign_ssh(private: &PrivateKey, payload: &[u8]) -> String {
349+ private
350+ .sign("git", HashAlg::Sha512, payload)
351+ .unwrap()
352+ .to_pem(LineEnding::LF)
353+ .unwrap()
354+ }
355+
356+ #[test]
357+ fn ssh_verified_unknown_and_invalid() {
358+ let (private, key) = ssh_key();
359+ let payload = b"tree deadbeef\nauthor a\n\nmessage\n";
360+ let armor = sign_ssh(&private, payload);
361+
362+ // Registered → Verified.
363+ match verify_ssh(&armor, payload, std::slice::from_ref(&key)) {
364+ SignatureState::Verified {
365+ kind, fingerprint, ..
366+ } => {
367+ assert_eq!(kind, KeyKind::Ssh);
368+ assert_eq!(fingerprint, key.fingerprint);
369+ }
370+ other => panic!("expected Verified, got {other:?}"),
371+ }
372+
373+ // Valid signature, no registered key → UnknownKey.
374+ match verify_ssh(&armor, payload, &[]) {
375+ SignatureState::UnknownKey { kind, fingerprint } => {
376+ assert_eq!(kind, KeyKind::Ssh);
377+ assert_eq!(fingerprint, key.fingerprint);
378+ }
379+ other => panic!("expected UnknownKey, got {other:?}"),
380+ }
381+
382+ // Wrong payload → Invalid, even with the key registered.
383+ match verify_ssh(&armor, b"different payload", std::slice::from_ref(&key)) {
384+ SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Ssh)),
385+ other => panic!("expected Invalid, got {other:?}"),
386+ }
387+ }
388+
389+ #[test]
390+ fn gpg_verified_unknown_and_invalid() {
391+ // Registered → Verified.
392+ match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[gpg_key()]) {
393+ SignatureState::Verified {
394+ kind, fingerprint, ..
395+ } => {
396+ assert_eq!(kind, KeyKind::Gpg);
397+ assert_eq!(fingerprint, GPG_FPR);
398+ }
399+ other => panic!("expected Verified, got {other:?}"),
400+ }
401+
402+ // No registered key → UnknownKey with the issuer fingerprint.
403+ match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[]) {
404+ SignatureState::UnknownKey { kind, fingerprint } => {
405+ assert_eq!(kind, KeyKind::Gpg);
406+ assert_eq!(fingerprint, GPG_FPR);
407+ }
408+ other => panic!("expected UnknownKey, got {other:?}"),
409+ }
410+
411+ // Registered key but tampered payload → Invalid.
412+ match verify_gpg(GPG_SIG, b"tampered", &[gpg_key()]) {
413+ SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Gpg)),
414+ other => panic!("expected Invalid, got {other:?}"),
415+ }
416+ }
417+
418+ #[test]
419+ fn malformed_armor_is_invalid() {
420+ match verify_ssh("-----BEGIN SSH SIGNATURE-----\nnonsense\n", b"x", &[]) {
421+ SignatureState::Invalid { kind, .. } => assert_eq!(kind, Some(KeyKind::Ssh)),
422+ other => panic!("expected Invalid, got {other:?}"),
423+ }
424+ }
425+
426+ #[test]
427+ fn unsigned_commit_reports_unsigned() {
428+ use std::path::Path;
429+
430+ use git2::{Repository, Signature, Time};
431+
432+ use crate::{create_bare, repo_path};
433+
434+ let dir = tempfile::tempdir().unwrap();
435+ let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
436+ create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
437+ let path = repo_path(dir.path(), id).unwrap();
438+ let raw = Repository::open_bare(&path).unwrap();
439+
440+ let blob = raw.blob(b"hi\n").unwrap();
441+ let mut tb = raw.treebuilder(None).unwrap();
442+ tb.insert("f", blob, 0o100_644).unwrap();
443+ let tree = raw.find_tree(tb.write().unwrap()).unwrap();
444+ let sig = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap();
445+ let oid = raw
446+ .commit(Some("refs/heads/main"), &sig, &sig, "unsigned", &tree, &[])
447+ .unwrap();
448+
449+ let repo = Repo::open_path(&path).unwrap();
450+ assert_eq!(
451+ repo.signature_state(&Oid::new(oid.to_string()), &[]),
452+ SignatureState::Unsigned
453+ );
454+ }
455+
456+ #[test]
457+ fn ssh_signed_commit_end_to_end() {
458+ use std::path::Path;
459+
460+ use git2::{Repository, Signature, Time};
461+
462+ use crate::{create_bare, repo_path};
463+
464+ let dir = tempfile::tempdir().unwrap();
465+ let id = "01hzxk9m2n8p7q6r5s4t3v2w1y";
466+ create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
467+ let path = repo_path(dir.path(), id).unwrap();
468+ let raw = Repository::open_bare(&path).unwrap();
469+
470+ let blob = raw.blob(b"hi\n").unwrap();
471+ let mut tb = raw.treebuilder(None).unwrap();
472+ tb.insert("f", blob, 0o100_644).unwrap();
473+ let tree = raw.find_tree(tb.write().unwrap()).unwrap();
474+ let who = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap();
475+ let content = raw
476+ .commit_create_buffer(&who, &who, "signed commit", &tree, &[])
477+ .unwrap();
478+ let content = std::str::from_utf8(&content).unwrap();
479+
480+ let (private, key) = ssh_key();
481+ let armor = sign_ssh(&private, content.as_bytes());
482+ let oid = raw.commit_signed(content, &armor, None).unwrap();
483+
484+ let repo = Repo::open_path(&path).unwrap();
485+ match repo.signature_state(&Oid::new(oid.to_string()), std::slice::from_ref(&key)) {
486+ SignatureState::Verified { fingerprint, .. } => {
487+ assert_eq!(fingerprint, key.fingerprint);
488+ }
489+ other => panic!("expected Verified, got {other:?}"),
490+ }
491+ }
492+}
crates/model/src/entity.rs +38 −0
@@ -16,6 +16,44 @@
1616 /// the schema.
1717 pub type Timestamp = i64;
1818
19+/// The kind of a registered public key: an SSH key (used for both push auth and
20+/// signature verification) or a GPG key (signature verification only). Stored as
21+/// the `keys.kind` text `'ssh'` / `'gpg'`.
22+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23+pub enum KeyKind {
24+ /// An `OpenSSH` public key.
25+ Ssh,
26+ /// An `OpenPGP` (GPG) public key.
27+ Gpg,
28+}
29+
30+impl KeyKind {
31+ /// The lowercase database token (`"ssh"` / `"gpg"`).
32+ #[must_use]
33+ pub fn as_str(self) -> &'static str {
34+ match self {
35+ Self::Ssh => "ssh",
36+ Self::Gpg => "gpg",
37+ }
38+ }
39+
40+ /// Parse from the database token, case-insensitively.
41+ #[must_use]
42+ pub fn from_token(s: &str) -> Option<Self> {
43+ match s.to_ascii_lowercase().as_str() {
44+ "ssh" => Some(Self::Ssh),
45+ "gpg" => Some(Self::Gpg),
46+ _ => None,
47+ }
48+ }
49+}
50+
51+impl std::fmt::Display for KeyKind {
52+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53+ f.write_str(self.as_str())
54+ }
55+}
56+
1957 /// A registered account.
2058 #[derive(Debug, Clone, PartialEq, Eq)]
2159 pub struct User {
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
1111 pub mod entity;
1212 pub mod name;
1313
14-pub use entity::{Repo, Timestamp, User};
14+pub use entity::{KeyKind, Repo, Timestamp, User};
1515 pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
docs/decisions.md +35 −0
@@ -328,3 +328,38 @@ is bounded by the `spawn_blocking` pool at the call site rather than by silently
328328 corrupting a page. Wiring a hard per-file deadline (a cancellation flag polled by
329329 the grammar, or a watchdog thread) is deferred to the polish phase, where the
330330 highlight cache and measured hot paths land together. Tracked as a known gap.
331+
332+## 2026-07-24 — GPG verification via rpgp, not sequoia-openpgp
333+
334+**Decision:** OpenPGP signature verification uses the `pgp` crate (rpgp) 0.20, not
335+`sequoia-openpgp` as the spec names.
336+
337+**Alternatives:** `sequoia-openpgp` (the spec's choice); shell out to `gpg`.
338+
339+**Rationale:** `sequoia-openpgp` is **LGPL-2.0-or-later**, which is not in
340+`deny.toml`'s allow-list — pulling it in fails `cargo-deny`, the same class of
341+constraint that rules out `ring` and drove the hand-rolled JWT. `gpgme` shells out
342+to GnuPG (GPL). rpgp is `MIT OR Apache-2.0`, pure-Rust (ed25519-dalek/rsa/p256, no
343+`ring`, no OpenSSL/C), and covers the verification surface we need. SSH signatures
344+use `ssh-key` (also Apache-2.0/MIT, pure-Rust), as the spec specifies. This is a
345+**SHOULD** deviation logged here.
346+
347+## 2026-07-24 — SignatureState ids are String; key expiry/revocation best-effort
348+
349+**Decision:** `SignatureState` carries `String` ids rather than the spec sketch's
350+`Ulid`, and does not yet reject on GPG **key expiry or revocation**.
351+
352+**Alternatives:** Depend on `ulid` in the git crate for typed ids; implement full
353+key-validity/revocation checking.
354+
355+**Rationale:** The git crate has no `ulid` dependency and speaks ids as opaque
356+strings everywhere else (oids are hex `String`s); threading a `Ulid` type through
357+solely for these fields would add a dependency for no gain, since ids originate in
358+and return to the store as strings. On expiry/revocation: rpgp 0.20 exposes no
359+one-call "valid at time T" check for detached signatures, and revocation requires
360+manually scanning revocation signatures. SSH signature verification and GPG
361+signature **cryptographic** verification are complete and cover all four
362+`SignatureState` variants (tested); rejecting on GPG key expiry/revocation is a
363+documented best-effort gap to close alongside the signature cache wiring in the web
364+phase. Expired/revoked keys therefore currently verify as `Verified` if the
365+signature is otherwise valid — tracked as a known limitation.