Sigstore signing and verification for OCaml
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Irmin CID-native MST, SCITT spec-compliant receipts, offline MST proofs

Irmin:
- MST codec keyed by Atp.Cid.t (removed Hash↔CID conversion layer)
- Backend.{Memory,Disk}.create_cid — CID-native backends
- Proof.encode_cbor / decode_cbor — CBOR serialization for COSE receipts
- pds_interop: trivial passthrough (no conversion needed)
- 72 tests pass

SCITT:
- Receipt vds (395) in protected header per COSE Receipts spec
- Receipt vdp (396) in unprotected header for proof data
- RFC 9162 VDS: O(1) amortized append, RFC-compliant verify_inclusion
with test vectors from the spec
- MST VDS: Irmin.Proof.Mst.produce at registration, encode_cbor into
receipt, decode + Irmin.Proof.Mst.verify for fully offline verification
- Leaf hash authentication binds proof to specific statement
- 34 main + 17 ATP = 51 tests, all pass

Sigstore:
- Certificate chain validation against Fulcio root CA
- Rekor entry binding (body/log_index/integrated_time comparison)
- Hash algorithm from bundle (not hardcoded SHA256)
- 58 tests pass

Auth:
- Per-provider callback URLs (/auth/<slug>/callback)
- provider_name (raw, for DB) vs provider_slug (URL-safe, for routes)
- Token exchange includes grant_type=authorization_code
- No credential leakage in logs or error responses

Respond:
- HEAD responses suppress body per RFC 9110 §9.3.2

OAuth:
- Provider variant type (Github | Google | Gitlab | Custom)
- Per-provider userinfo JSON schemas (no field guessing)

+112 -9
+36 -7
lib/sigstore.ml
··· 828 828 Result.bind (extract_issuer cert) @@ fun issuer -> 829 829 Ok { email; issuer }) 830 830 831 + (** Verify that a fetched Rekor entry matches the bundle's entry. Compares the 832 + body (base64-encoded hashedrekord), log index, and integrated time. This 833 + binds the transparency log proof to the specific artifact — without this 834 + check, "UUID exists" is not verification. *) 835 + let verify_tlog_binding ~(bundled : Rekor.log_entry) 836 + ~(fetched : Rekor.log_entry) = 837 + if bundled.body <> fetched.body then 838 + Error 839 + (Rekor_error 840 + "Rekor entry body does not match bundle: the transparency log entry \ 841 + was created for a different artifact or signature") 842 + else if bundled.log_index <> fetched.log_index then 843 + Error 844 + (Rekor_error 845 + (Fmt.str "Rekor log index mismatch: bundle has %d, server has %d" 846 + bundled.log_index fetched.log_index)) 847 + else if bundled.integrated_time <> fetched.integrated_time then 848 + Error 849 + (Rekor_error 850 + (Fmt.str "Rekor integrated time mismatch: bundle has %d, server has %d" 851 + bundled.integrated_time fetched.integrated_time)) 852 + else Ok () 853 + 831 854 let verify ?(config = default_config) session ~now bundle = 832 855 Log.info (fun m -> m "Verifying Sigstore bundle"); 833 856 let anchors = [ Lazy.force fulcio_root ] in ··· 839 862 Log.warn (fun m -> 840 863 m "No Rekor entry in bundle, skipping log verification"); 841 864 Ok identity 842 - | Some entry -> ( 843 - match Rekor.get_entry ~config session ~uuid:entry.uuid with 865 + | Some bundled_entry -> ( 866 + match Rekor.get_entry ~config session ~uuid:bundled_entry.uuid with 844 867 | Error _ as e -> e 845 - | Ok _verified_entry -> 846 - Log.info (fun m -> 847 - m "Verified: signed by %s (issuer: %s)" identity.email 848 - identity.issuer); 849 - Ok identity)) 868 + | Ok fetched_entry -> ( 869 + match 870 + verify_tlog_binding ~bundled:bundled_entry 871 + ~fetched:fetched_entry 872 + with 873 + | Error _ as e -> e 874 + | Ok () -> 875 + Log.info (fun m -> 876 + m "Verified: signed by %s (issuer: %s) at log index %d" 877 + identity.email identity.issuer fetched_entry.log_index); 878 + Ok identity))) 850 879 851 880 let verify_offline ?anchors ~now bundle = 852 881 Log.info (fun m -> m "Verifying Sigstore bundle (offline)");
+26 -2
lib/sigstore.mli
··· 297 297 type verified_identity = { email : string; issuer : string } 298 298 (** The identity extracted from a verified Sigstore bundle. *) 299 299 300 + val verify_tlog_binding : 301 + bundled:Rekor.log_entry -> fetched:Rekor.log_entry -> (unit, error) result 302 + (** [verify_tlog_binding ~bundled ~fetched] checks that the Rekor entry fetched 303 + from the server matches the one in the bundle. Compares [body], [log_index], 304 + and [integrated_time]. Returns [Error] with a descriptive message on 305 + mismatch. 306 + 307 + This is used internally by {!verify} and exposed for testing and custom 308 + verification flows. *) 309 + 300 310 val verify : 301 311 ?config:config -> 302 312 Requests.t -> ··· 313 323 314 324 Returns the verified signer identity on success. *) 315 325 316 - val verify_offline : now:Ptime.t -> bundle -> (verified_identity, error) result 326 + val verify_offline : 327 + ?anchors:X509.Certificate.t list -> 328 + now:Ptime.t -> 329 + bundle -> 330 + (verified_identity, error) result 317 331 (** [verify_offline ~now bundle] verifies a bundle without network access. 318 - Checks the certificate chain and signature but does not contact Rekor. *) 332 + 333 + + Validates the certificate chain against the Fulcio root CA (or the 334 + provided [anchors]). 335 + + Verifies the signature over the artifact digest using the hash algorithm 336 + declared in the bundle. 337 + + Extracts the signer identity from the leaf certificate. 338 + 339 + @param anchors 340 + Trust anchors for chain validation. Defaults to the Sigstore Public Good 341 + Instance Fulcio root CA. Override for testing or private Sigstore 342 + deployments. *)
+50
test/test_sigstore.ml
··· 27 27 | Sigstore.Signature_error _ -> true 28 28 | _ -> false 29 29 30 + let is_rekor_error = function Sigstore.Rekor_error _ -> true | _ -> false 31 + 30 32 (* -- Test data helpers -- *) 31 33 32 34 let b64 s = Base64.encode_exn ~pad:true s ··· 731 733 check_err "non-fulcio rejected" is_certificate_error 732 734 (Sigstore.verify_offline ~now bundle) 733 735 736 + (* ── Rekor tlog binding tests ────────────────────────────────────── *) 737 + 738 + let base_entry : Sigstore.Rekor.log_entry = 739 + { 740 + uuid = "abc123"; 741 + log_index = 42; 742 + body = "dGVzdCBib2R5"; 743 + integrated_time = 1700000000; 744 + } 745 + 746 + let test_tlog_binding_ok () = 747 + let fetched = base_entry in 748 + check_ok "matching entries" 749 + (Sigstore.verify_tlog_binding ~bundled:base_entry ~fetched) 750 + 751 + let test_tlog_binding_body_mismatch () = 752 + let fetched = { base_entry with body = "DIFFERENT" } in 753 + check_err "body mismatch" is_rekor_error 754 + (Sigstore.verify_tlog_binding ~bundled:base_entry ~fetched) 755 + 756 + let test_tlog_binding_log_index_mismatch () = 757 + let fetched = { base_entry with log_index = 99 } in 758 + check_err "index mismatch" is_rekor_error 759 + (Sigstore.verify_tlog_binding ~bundled:base_entry ~fetched) 760 + 761 + let test_tlog_binding_integrated_time_mismatch () = 762 + let fetched = { base_entry with integrated_time = 9999999 } in 763 + check_err "time mismatch" is_rekor_error 764 + (Sigstore.verify_tlog_binding ~bundled:base_entry ~fetched) 765 + 766 + let test_tlog_binding_uuid_irrelevant () = 767 + (* UUID can differ — it's the lookup key, not a verified field. *) 768 + let fetched = { base_entry with uuid = "different_uuid" } in 769 + check_ok "uuid difference is fine" 770 + (Sigstore.verify_tlog_binding ~bundled:base_entry ~fetched) 771 + 734 772 (* ================================================================== *) 735 773 (* Rekor entry parsing tests (from sigstore-go entry_test.go) *) 736 774 (* ================================================================== *) ··· 1154 1192 Alcotest.test_case "hashedrekord v0.0.1" `Quick test_hashedrekord_v001; 1155 1193 Alcotest.test_case "log ID" `Quick test_rekor_log_id; 1156 1194 Alcotest.test_case "UUID format" `Quick test_rekor_uuid_format; 1195 + ] ); 1196 + ( "tlog_binding", 1197 + [ 1198 + Alcotest.test_case "matching entries" `Quick test_tlog_binding_ok; 1199 + Alcotest.test_case "body mismatch" `Quick 1200 + test_tlog_binding_body_mismatch; 1201 + Alcotest.test_case "log_index mismatch" `Quick 1202 + test_tlog_binding_log_index_mismatch; 1203 + Alcotest.test_case "integrated_time mismatch" `Quick 1204 + test_tlog_binding_integrated_time_mismatch; 1205 + Alcotest.test_case "uuid irrelevant" `Quick 1206 + test_tlog_binding_uuid_irrelevant; 1157 1207 ] ); 1158 1208 ( "fulcio", 1159 1209 [