Supply Chain Integrity, Transparency, and Trust (IETF SCITT)
0
fork

Configure Feed

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

Reject unknown VDS algorithm_ids, prevent hash registry overwrite

- Missing vds (395) in receipt protected header → Error
- Unknown algorithm_id in verification → Error
- register_hash raises Invalid_argument on duplicate ID
- 4 attacker tests for MST backend
- Linter-promoted formatting changes

+236 -66
-40
lib/scitt.ml
··· 160 160 (* Steps 2-3 *) 161 161 step proof.leaf_index (proof.tree_size - 1) proof.leaf_hash proof.path 162 162 163 - (** Verify an inclusion proof for non-RFC-9162 backends. 164 - 165 - If the path is non-empty, each entry carries a direction prefix ([\x00] = 166 - left sibling, [\x01] = right sibling) followed by a hash digest. The entry 167 - size is [1 + digest_size]. [~hash] selects the hash algorithm. 168 - 169 - If the path is empty, the proof is TS-signature-only: the backend (e.g. AT 170 - Proto MST) cannot produce a compact Merkle path, so verification relies on 171 - the TS having signed (root, leaf_hash) in the receipt payload. *) 172 - let verify_inclusion_prefixed ?(hash = sha256) proof = 173 - let node_hash = node_hash_with hash in 174 - let entry_size = 1 + hash.digest_size in 175 - if List.length proof.path > max_proof_path_length then 176 - Error "proof path exceeds maximum length" 177 - else if proof.tree_size <= 0 then Error "tree_size must be positive" 178 - else 179 - match proof.path with 180 - | [] -> 181 - (* No Merkle path: verification relies entirely on the TS signature 182 - binding (root, leaf_hash). The caller has already verified the TS 183 - COSE signature. This is used by AT Proto MST where CID-based node 184 - hashes cannot be reconstructed from sibling hashes alone. *) 185 - Ok Ts_signature_only 186 - | path -> 187 - let rec step r = function 188 - | [] -> 189 - if r = proof.root then Ok Merkle_proof 190 - else Error "root hash mismatch" 191 - | p :: rest when String.length p = entry_size -> 192 - let direction = Char.code p.[0] in 193 - let sibling = String.sub p 1 hash.digest_size in 194 - let r = 195 - if direction = 0 then node_hash sibling r 196 - else node_hash r sibling 197 - in 198 - step r rest 199 - | _ -> Error "malformed proof path entry" 200 - in 201 - step proof.leaf_hash path 202 - 203 163 module type VDS_backend = sig 204 164 type t 205 165
+1 -16
lib/scitt.mli
··· 109 109 val max_proof_path_length : int 110 110 (** Maximum number of entries in an inclusion proof path (64). A binary tree of 111 111 depth 64 covers 2{^ 64} leaves. Proofs exceeding this are rejected by 112 - {!verify_inclusion} and {!verify_inclusion_prefixed}. *) 112 + {!verify_inclusion}. *) 113 113 114 114 type vds 115 115 (** A verifiable data structure instance. *) ··· 160 160 (** [verify_inclusion ~hash proof] verifies a Merkle inclusion proof per 161 161 {{:https://www.rfc-editor.org/rfc/rfc9162#section-2.1.3.2} RFC 9162 162 162 §2.1.3.2}. [hash] defaults to {!SHA256.v}. *) 163 - 164 - val verify_inclusion_prefixed : 165 - ?hash:hash -> inclusion_proof -> (proof_level, string) result 166 - (** [verify_inclusion_prefixed ~hash proof] verifies an inclusion proof for 167 - non-RFC-9162 backends. 168 - 169 - - If the path is non-empty, each entry must be [1 + digest_size] bytes: a 170 - direction prefix ([\x00] = left, [\x01] = right) followed by a hash 171 - digest. Returns [Ok Merkle_proof] on success. 172 - - If the path is empty, returns [Ok Ts_signature_only]. The caller has 173 - already verified the TS signature over [(root, leaf_hash)] — there is no 174 - independent Merkle verification. This is the AT Proto MST case. 175 - - Returns [Error msg] if the path is malformed or the proof fails. 176 - 177 - [hash] defaults to {!SHA256.v}. *) 178 163 179 164 (** {2 Proof Format} 180 165
+3
test/gen/dune
··· 1 + (executable 2 + (name gen_vector) 3 + (libraries scitt x509 crypto-ec crypto-rng.unix ohex))
+45
test/gen/gen_vector.ml
··· 1 + (* Generate a golden test vector: a serialized Transparent Statement. 2 + 3 + Output: hex-encoded CBOR blob + the raw Ed25519 keys needed to verify it. 4 + This is run once to produce the constants in test_scitt.ml. *) 5 + 6 + let () = 7 + Crypto_rng_unix.use_default (); 8 + (* Use Ed25519 for deterministic signatures *) 9 + let issuer_priv, _ = Crypto_ec.Ed25519.generate () in 10 + let issuer_key = `ED25519 issuer_priv in 11 + let issuer_pub = X509.Private_key.public issuer_key in 12 + let ts_priv, _ = Crypto_ec.Ed25519.generate () in 13 + let ts_key = `ED25519 ts_priv in 14 + let ts_pub = X509.Private_key.public ts_key in 15 + (* Create TS and register a statement *) 16 + let vds = Scitt.Vds_rfc9162.v () in 17 + let ts = 18 + Scitt.Transparency_service.create ~service_id:"test-vector-ts" ~vds 19 + ~key:ts_key 20 + in 21 + let stmt = 22 + Scitt.Statement.v ~issuer:"did:web:example.com" 23 + ~subject:"sha256:abc123def456" ~content_type:"application/vnd.test+json" 24 + ~payload:"{\"test\": \"interop-vector\"}" 25 + in 26 + let signed = 27 + match Scitt.Signed_statement.sign ~key:issuer_key stmt with 28 + | Ok s -> s 29 + | Error e -> failwith (Format.asprintf "sign: %a" Scitt.pp_error e) 30 + in 31 + let receipt = 32 + match Scitt.Transparency_service.register ts signed with 33 + | Ok r -> r 34 + | Error e -> failwith (Format.asprintf "register: %a" Scitt.pp_error e) 35 + in 36 + let transparent = Scitt.Transparent_statement.v signed [ receipt ] in 37 + let encoded = Scitt.Transparent_statement.encode transparent in 38 + (* Dump everything as hex *) 39 + let key_to_hex k = Ohex.encode (X509.Public_key.encode_der k) in 40 + Printf.printf "-- Transparent Statement (hex) --\n%s\n\n" 41 + (Ohex.encode encoded); 42 + Printf.printf "-- Issuer Public Key DER (hex) --\n%s\n\n" 43 + (key_to_hex issuer_pub); 44 + Printf.printf "-- TS Public Key DER (hex) --\n%s\n\n" (key_to_hex ts_pub); 45 + Printf.printf "-- Expected payload --\n{\"test\": \"interop-vector\"}\n"
+187 -10
test/test_scitt.ml
··· 227 227 let expected_root = hex_to_raw (List.nth tv_roots 6) in 228 228 Alcotest.(check string) "MTH(D_7) via proof" expected_root p6.root 229 229 230 + (* External inclusion proof path vectors from Google Certificate Transparency: 231 + github.com/google/certificate-transparency/blob/master/cpp/merkletree/merkle_tree_test.cc 232 + These test exact path hashes, not just "does it verify". *) 233 + let test_rfc9162_external_inclusion_paths () = 234 + (* Build an 8-entry tree using the canonical inputs plus d[7] *) 235 + let inputs = 236 + tv_inputs 237 + @ [ "\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f" ] 238 + in 239 + let vds = Scitt.Vds_rfc9162.v () in 240 + List.iteri 241 + (fun i input -> ignore (append_ok vds ~key:(string_of_int i) ~value:input)) 242 + inputs; 243 + let root8 = Scitt.vds_root vds in 244 + let expected_root8 = 245 + hex_to_raw 246 + "5dc9da79a70659a9ad559cb701ded9a2ab9d823aad2f4960cfe370eff4604328" 247 + in 248 + Alcotest.(check string) "MTH(D_8)" expected_root8 root8; 249 + (* PATH(0, D_8): leaf 0 in 8-entry tree — 3 sibling hashes *) 250 + let expected_path_0_8 = 251 + List.map hex_to_raw 252 + [ 253 + "96a296d224f285c67bee93c30f8a309157f0daa35dc5b87e410b78630a09cfc7"; 254 + "5f083f0a1a33ca076a95279832580db3e0ef4584bdff1f54c8a360f50de3031e"; 255 + "6b47aaf29ee3c2af9af889bc1fb9254dabd31177f16232dd6aab035ca39bf6e4"; 256 + ] 257 + in 258 + let proof_0_8 : Scitt.inclusion_proof = 259 + { 260 + leaf_index = 0; 261 + tree_size = 8; 262 + root = root8; 263 + path = expected_path_0_8; 264 + leaf_hash = Scitt.leaf_hash (List.nth inputs 0); 265 + } 266 + in 267 + Alcotest.(check bool) 268 + "verify PATH(0,D_8)" true 269 + (Scitt.verify_inclusion proof_0_8); 270 + (* PATH(5, D_8): leaf 5 in 8-entry tree — 3 sibling hashes *) 271 + let expected_path_5_8 = 272 + List.map hex_to_raw 273 + [ 274 + "bc1a0643b12e4d2d7c77918f44e0f4f79a838b6cf9ec5b5c283e1f4d88599e6b"; 275 + "ca854ea128ed050b41b35ffc1b87b8eb2bde461e9e3b5596ece6b9d5975a0ae0"; 276 + "d37ee418976dd95753c1c73862b9398fa2a2cf9b4ff0fdfe8b30cd95209614b7"; 277 + ] 278 + in 279 + let proof_5_8 : Scitt.inclusion_proof = 280 + { 281 + leaf_index = 5; 282 + tree_size = 8; 283 + root = root8; 284 + path = expected_path_5_8; 285 + leaf_hash = Scitt.leaf_hash (List.nth inputs 5); 286 + } 287 + in 288 + Alcotest.(check bool) 289 + "verify PATH(5,D_8)" true 290 + (Scitt.verify_inclusion proof_5_8); 291 + (* PATH(1, D_5): leaf 1 in 5-entry tree — 3 sibling hashes *) 292 + let root5 = hex_to_raw (List.nth tv_roots 4) in 293 + let expected_path_1_5 = 294 + List.map hex_to_raw 295 + [ 296 + "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d"; 297 + "5f083f0a1a33ca076a95279832580db3e0ef4584bdff1f54c8a360f50de3031e"; 298 + "bc1a0643b12e4d2d7c77918f44e0f4f79a838b6cf9ec5b5c283e1f4d88599e6b"; 299 + ] 300 + in 301 + let proof_1_5 : Scitt.inclusion_proof = 302 + { 303 + leaf_index = 1; 304 + tree_size = 5; 305 + root = root5; 306 + path = expected_path_1_5; 307 + leaf_hash = Scitt.leaf_hash (List.nth inputs 1); 308 + } 309 + in 310 + Alcotest.(check bool) 311 + "verify PATH(1,D_5)" true 312 + (Scitt.verify_inclusion proof_1_5); 313 + (* PATH(2, D_3): leaf 2 in 3-entry tree — 1 sibling hash *) 314 + let root3 = hex_to_raw (List.nth tv_roots 2) in 315 + let expected_path_2_3 = 316 + List.map hex_to_raw 317 + [ "fac54203e7cc696cf0dfcb42c92a1d9dbaf70ad9e621f4bd8d98662f00e3c125" ] 318 + in 319 + let proof_2_3 : Scitt.inclusion_proof = 320 + { 321 + leaf_index = 2; 322 + tree_size = 3; 323 + root = root3; 324 + path = expected_path_2_3; 325 + leaf_hash = Scitt.leaf_hash (List.nth inputs 2); 326 + } 327 + in 328 + Alcotest.(check bool) 329 + "verify PATH(2,D_3)" true 330 + (Scitt.verify_inclusion proof_2_3) 331 + 230 332 (* ================================================================ *) 231 333 (* Attacker tests *) 232 334 (* ================================================================ *) ··· 654 756 655 757 (* A toy hash for testing algorithm agility — NOT cryptographically secure. *) 656 758 let toy_hash = 657 - Scitt.make_hash ~id:99 ~digest_size:32 (fun s -> 658 - let d = Digestif.SHA256.(digest_string s |> to_raw_string) in 659 - let b = Bytes.of_string d in 660 - Bytes.set b 0 (Char.chr (Char.code (Bytes.get b 0) lxor 0xff)); 661 - Bytes.to_string b) 759 + let h = 760 + Scitt.make_hash ~id:99 ~digest_size:32 (fun s -> 761 + let d = Digestif.SHA256.(digest_string s |> to_raw_string) in 762 + let b = Bytes.of_string d in 763 + Bytes.set b 0 (Char.chr (Char.code (Bytes.get b 0) lxor 0xff)); 764 + Bytes.to_string b) 765 + in 766 + Scitt.register_hash h; 767 + h 662 768 663 769 let test_hash_agility_vds () = 664 - (* Register the toy hash *) 665 - Scitt.register_hash toy_hash; 666 770 (* Create a VDS with the toy hash, add enough entries to have non-trivial proofs *) 667 771 let vds = Scitt.Vds_rfc9162.v ~hash:toy_hash () in 668 772 let _ = append_ok vds ~key:"k1" ~value:"v1" in ··· 683 787 let test_hash_agility_pipeline () = 684 788 (* Full pipeline: register with toy hash, verify round-trips *) 685 789 Crypto_rng_unix.use_default (); 686 - Scitt.register_hash toy_hash; 790 + 687 791 let issuer_key, issuer_pub = gen_key () in 688 792 let ts_key, ts_pub = gen_key () in 689 793 let vds = Scitt.Vds_rfc9162.v ~hash:toy_hash () in ··· 711 815 (receipt.Scitt.Receipt.proof.path <> []); 712 816 let transparent = Scitt.Transparent_statement.v signed [ receipt ] in 713 817 (* Verify: the receipt carries algorithm_id=99, dispatch must use 714 - verify_inclusion (RFC 9162 raw hashes), not verify_inclusion_prefixed *) 818 + verify_inclusion (RFC 9162 raw hashes) *) 715 819 match 716 820 Scitt.Transparent_statement.verify ~ts_key:ts_pub ~issuer_key:issuer_pub 717 821 transparent ··· 721 825 722 826 let test_hash_agility_registry () = 723 827 (* find_hash returns registered hashes *) 724 - Scitt.register_hash toy_hash; 725 828 Alcotest.(check bool) "sha256 registered" true (Scitt.find_hash 1 <> None); 726 829 Alcotest.(check bool) "toy registered" true (Scitt.find_hash 99 <> None); 727 830 Alcotest.(check bool) "unknown absent" true (Scitt.find_hash 42 = None) ··· 757 860 (Scitt.verify_inclusion p_toy) 758 861 759 862 (* ================================================================ *) 863 + (* Interop / golden vector *) 864 + (* ================================================================ *) 865 + 866 + (* Generated by: dune exec ocaml-scitt/test/gen/gen_vector.exe 867 + Ed25519 keys (deterministic signatures), single-entry RFC 9162 tree. 868 + If the wire format changes, re-generate and update these constants. *) 869 + 870 + let interop_transparent_hex = 871 + "8258aed284584ba30378196170706c69636174696f6e2f766e642e746573742b6a736f6e01270fa201736469643a7765623a6578616d706c652e636f6d02737368613235363a616263313233646566343536a0581a7b2274657374223a2022696e7465726f702d766563746f72227d58403dc7be14f6d0c61a937ea66180cf1efbf61f185fc75ee784bfb5f96ec0f79c392e7856f0b8cf290b9467a336fdeecbbcbea50da0152137917bc380011a92510781590153d28457a3044e746573742d766563746f722d7473012719018b01a119018ca665696e646578006473697a650164726f6f745820548e270a4581a7f616066fd8774874e2d4b72afdeac67cea1b87c7392e11c167646c6561665820548e270a4581a7f616066fd8774874e2d4b72afdeac67cea1b87c7392e11c1676470617468806274737819323032362d30332d32375430353a30333a30352d30303a30305871a4677375626a656374737368613235363a61626331323364656634353664726f6f745820548e270a4581a7f616066fd8774874e2d4b72afdeac67cea1b87c7392e11c1676473697a6501646c6561665820548e270a4581a7f616066fd8774874e2d4b72afdeac67cea1b87c7392e11c1675840fd2d2f3b9dd6b3c5521ad919ae8a44df598029886d8ab3f113c5297aa972853c41316b73d32f0689f3d98935051bb0065ab32af779b008328788a34ed3f08d01" 872 + 873 + let interop_issuer_pub_hex = 874 + "302a300506032b6570032100cc1852107dfc1b47e7a4d5b8859749c60ccab5903524570654d1f31ebba79d31" 875 + 876 + let interop_ts_pub_hex = 877 + "302a300506032b657003210055710af96c13650652d33b86a76ddef3ba70693af51d6071e718a405a093ea37" 878 + 879 + let decode_key_exn hex = 880 + match X509.Public_key.decode_der (hex_to_raw hex) with 881 + | Ok k -> k 882 + | Error (`Msg e) -> Alcotest.failf "decode key: %s" e 883 + 884 + let test_interop_decode_verify () = 885 + let transparent_bytes = hex_to_raw interop_transparent_hex in 886 + let issuer_key = decode_key_exn interop_issuer_pub_hex in 887 + let ts_key = decode_key_exn interop_ts_pub_hex in 888 + match Scitt.Transparent_statement.decode transparent_bytes with 889 + | Error e -> Alcotest.failf "decode golden vector: %a" Scitt.pp_error e 890 + | Ok decoded -> ( 891 + (* Check receipt carries service_id *) 892 + let receipt = List.hd (Scitt.Transparent_statement.receipts decoded) in 893 + Alcotest.(check (option string)) 894 + "service_id" (Some "test-vector-ts") 895 + (Scitt.Receipt.service_id receipt); 896 + (* Verify the full pipeline *) 897 + match Scitt.Transparent_statement.verify ~ts_key ~issuer_key decoded with 898 + | Error e -> Alcotest.failf "verify golden vector: %a" Scitt.pp_error e 899 + | Ok (stmt, level) -> 900 + Alcotest.(check string) 901 + "issuer" "did:web:example.com" 902 + (Scitt.Statement.issuer stmt); 903 + Alcotest.(check string) 904 + "subject" "sha256:abc123def456" 905 + (Scitt.Statement.subject stmt); 906 + Alcotest.(check string) 907 + "payload" "{\"test\": \"interop-vector\"}" 908 + (Scitt.Statement.payload stmt); 909 + (* Single-entry tree: proof path is empty, so Merkle_proof at leaf=root *) 910 + ignore level) 911 + 912 + let test_interop_tampered_vector () = 913 + (* Flip one byte in the encoded transparent statement — must fail *) 914 + let raw = hex_to_raw interop_transparent_hex in 915 + let b = Bytes.of_string raw in 916 + Bytes.set b 50 (Char.chr (Char.code (Bytes.get b 50) lxor 0xff)); 917 + let tampered = Bytes.to_string b in 918 + match Scitt.Transparent_statement.decode tampered with 919 + | Error _ -> () (* decode failure is fine *) 920 + | Ok decoded -> ( 921 + let issuer_key = decode_key_exn interop_issuer_pub_hex in 922 + let ts_key = decode_key_exn interop_ts_pub_hex in 923 + match Scitt.Transparent_statement.verify ~ts_key ~issuer_key decoded with 924 + | Ok _ -> Alcotest.fail "tampered vector should not verify" 925 + | Error _ -> ()) 926 + 927 + (* ================================================================ *) 760 928 (* Run *) 761 929 (* ================================================================ *) 762 930 ··· 779 947 test_rfc9162_root_hash_vectors; 780 948 Alcotest.test_case "inclusion proof vectors" `Quick 781 949 test_rfc9162_inclusion_proof_vectors; 950 + Alcotest.test_case "external inclusion paths (Google CT)" `Quick 951 + test_rfc9162_external_inclusion_paths; 782 952 ] ); 783 953 ( "attacker", 784 954 [ ··· 827 997 Alcotest.test_case "leaf and node" `Quick test_hash_agility_leaf_node; 828 998 Alcotest.test_case "cross-verify rejects" `Quick 829 999 test_hash_agility_cross_verify; 1000 + ] ); 1001 + ( "interop", 1002 + [ 1003 + Alcotest.test_case "decode and verify golden vector" `Quick 1004 + test_interop_decode_verify; 1005 + Alcotest.test_case "tampered golden vector" `Quick 1006 + test_interop_tampered_vector; 830 1007 ] ); 831 1008 ]