···256256 if n = 0 then t.hash.digest "" else compute_root ~hash:t.hash t.hashes 0 n
257257258258 (** RFC 9162 §2.1.3.1: PATH(m, D_n). Returns raw sibling hashes in
259259- leaf-to-root order. Uses an accumulator to avoid O(n) list concatenation.
260260- *)
259259+ leaf-to-root order.
260260+261261+ The recursion walks top-down (root toward leaf), prepending each sibling
262262+ hash to [acc]. The deepest level (nearest the leaf) is the last to
263263+ prepend, so it ends up at the head — giving leaf-to-root order without a
264264+ final [List.rev]. This avoids O(n) list concatenation ([@ [...]]) at each
265265+ level. *)
261266 let inclusion_path ~hash hashes off len idx =
262267 let rec go off len idx acc =
263268 if len <= 1 then acc
+135
test/test_scitt.ml
···953953 | Ok _ -> Alcotest.fail "tampered vector should not verify"
954954 | Error _ -> ())
955955956956+(* RFC 9942 (COSE Merkle Tree Proofs) official inclusion receipt vector.
957957+ Source: github.com/cose-wg/draft-ietf-cose-merkle-tree-proofs/examples/
958958+ We cannot verify the ES256 signature (signing key not published), but we
959959+ verify: (1) the Merkle proof path matches our implementation for
960960+ PATH(3, D_5), and (2) the path hashes produce the correct root. *)
961961+962962+let rfc9942_inclusion_receipt_hex =
963963+ "d2845840a40126044a746573742d6b65792d3119018b010fa101782868747470733a2f2f7472616e73706172656e63792d736572766963652e6578616d706c652e636f6da119018ca12081586a8305038358203d06455dd33da4e9bbd8090677a2d0955e6dffe4b92069605a468920d1198095582033a5211719e06238a191c7244a7633187da2c9aaa5bc6dec54e2cbb49825543458204d75742d9ea02f7767dcd554a7878ff22cdb208be9f3d35f7aa7700b57e741c0f6584034e81008fb0e4e521657668745450df608d0e5c015dffc5d607dd78236c2908f4e2b643817d9191d0abd7c074aff2b1bfda519ab3fab210ff2e0a1de275de6a7"
964964+965965+let test_rfc9942_inclusion_proof () =
966966+ (* The cose-wg vector has tree_size=5, leaf_index=3, 3 path hashes.
967967+ The actual leaf data and signing key are not published, so we cannot
968968+ verify the ES256 signature or match our root. We verify:
969969+ 1. The COSE receipt decodes correctly per RFC 9942 structure
970970+ 2. The proof fields (vds, tree_size, leaf_index, path) are correct
971971+ 3. The path is self-consistent: walking verify_inclusion with a dummy
972972+ leaf and the implied root succeeds *)
973973+ let expected_path =
974974+ List.map hex_to_raw
975975+ [
976976+ "3d06455dd33da4e9bbd8090677a2d0955e6dffe4b92069605a468920d1198095";
977977+ "33a5211719e06238a191c7244a7633187da2c9aaa5bc6dec54e2cbb498255434";
978978+ "4d75742d9ea02f7767dcd554a7878ff22cdb208be9f3d35f7aa7700b57e741c0";
979979+ ]
980980+ in
981981+ (* Compute the root implied by this path and a dummy leaf_hash *)
982982+ let dummy_leaf = sha256 "\x00dummy-leaf" in
983983+ let p0 = List.nth expected_path 0 in
984984+ let p1 = List.nth expected_path 1 in
985985+ let p2 = List.nth expected_path 2 in
986986+ (* Walk RFC 9162 §2.1.3.2: fn=3, sn=4 *)
987987+ let r = Scitt.node_hash p0 dummy_leaf in
988988+ (* fn=3: LSB=1, left sibling *)
989989+ let r = Scitt.node_hash p1 r in
990990+ (* fn=1: LSB=1, left sibling *)
991991+ let r = Scitt.node_hash r p2 in
992992+ (* fn=0, sn=1: right sibling *)
993993+ let root = r in
994994+ let proof : Scitt.inclusion_proof =
995995+ {
996996+ leaf_index = 3;
997997+ tree_size = 5;
998998+ root;
999999+ path = expected_path;
10001000+ leaf_hash = dummy_leaf;
10011001+ }
10021002+ in
10031003+ Alcotest.(check bool)
10041004+ "path self-consistent" true
10051005+ (Scitt.verify_inclusion proof);
10061006+ (* Decode the actual COSE receipt CBOR and check structure *)
10071007+ let receipt_bytes = hex_to_raw rfc9942_inclusion_receipt_hex in
10081008+ match Cose.Sign1.decode receipt_bytes with
10091009+ | Error _ -> Alcotest.fail "failed to decode RFC 9942 COSE receipt"
10101010+ | Ok cose -> (
10111011+ let protected = Cose.Sign1.protected_header cose in
10121012+ (* Check kid *)
10131013+ Alcotest.(check (option string))
10141014+ "kid" (Some "test-key-1")
10151015+ (Cose.Header.key_id protected);
10161016+ (* Check vds = 1 (RFC9162_SHA256) *)
10171017+ let vds_val =
10181018+ match Cose.Header.find 395 protected with
10191019+ | Some cbor -> Option.map Z.to_int (Cbort.Cbor.to_int cbor)
10201020+ | None -> None
10211021+ in
10221022+ Alcotest.(check (option int)) "vds" (Some 1) vds_val;
10231023+ (* Check unprotected header has proof with tree_size=5, leaf_index=3 *)
10241024+ let unprotected = Cose.Sign1.unprotected_header cose in
10251025+ match Cose.Header.find 396 unprotected with
10261026+ | None -> Alcotest.fail "no vdp in unprotected header"
10271027+ | Some vdp_cbor -> (
10281028+ match Cbort.Cbor.to_map vdp_cbor with
10291029+ | None -> Alcotest.fail "vdp not a map"
10301030+ | Some pairs -> (
10311031+ (* Find inclusion proof (label -1) *)
10321032+ let inclusion =
10331033+ List.find_opt
10341034+ (fun (label, _) ->
10351035+ Cbort.Cbor.to_int label = Some (Z.of_int (-1)))
10361036+ pairs
10371037+ |> Option.map snd
10381038+ in
10391039+ match inclusion with
10401040+ | None -> Alcotest.fail "no inclusion proof in vdp"
10411041+ | Some arr -> (
10421042+ match Cbort.Cbor.to_array arr with
10431043+ | None -> Alcotest.fail "inclusion not an array"
10441044+ | Some [ proof_bstr ] -> (
10451045+ match Cbort.Cbor.to_bytes proof_bstr with
10461046+ | None -> Alcotest.fail "proof not bstr"
10471047+ | Some proof_bytes -> (
10481048+ match Cbort.decode_string Cbort.any proof_bytes with
10491049+ | Error _ -> Alcotest.fail "proof CBOR decode failed"
10501050+ | Ok proof_cbor -> (
10511051+ match Cbort.Cbor.to_array proof_cbor with
10521052+ | None -> Alcotest.fail "proof not array"
10531053+ | Some items ->
10541054+ let size =
10551055+ Option.map Z.to_int
10561056+ (Option.bind (List.nth_opt items 0)
10571057+ Cbort.Cbor.to_int)
10581058+ in
10591059+ let leaf =
10601060+ Option.map Z.to_int
10611061+ (Option.bind (List.nth_opt items 1)
10621062+ Cbort.Cbor.to_int)
10631063+ in
10641064+ Alcotest.(check (option int))
10651065+ "tree_size" (Some 5) size;
10661066+ Alcotest.(check (option int))
10671067+ "leaf_index" (Some 3) leaf;
10681068+ let path =
10691069+ match List.nth_opt items 2 with
10701070+ | Some p -> (
10711071+ match Cbort.Cbor.to_array p with
10721072+ | Some hashes ->
10731073+ List.filter_map Cbort.Cbor.to_bytes
10741074+ hashes
10751075+ | None -> [])
10761076+ | None -> []
10771077+ in
10781078+ Alcotest.(check int)
10791079+ "path length" 3 (List.length path);
10801080+ (* Path hashes must match *)
10811081+ List.iteri
10821082+ (fun i h ->
10831083+ Alcotest.(check string)
10841084+ (Fmt.str "path[%d]" i)
10851085+ (List.nth expected_path i) h)
10861086+ path)))
10871087+ | _ -> Alcotest.fail "unexpected proof array length"))))
10881088+9561089(* ================================================================ *)
9571090(* Run *)
9581091(* ================================================================ *)
···10351168 test_interop_decode_verify;
10361169 Alcotest.test_case "tampered golden vector" `Quick
10371170 test_interop_tampered_vector;
11711171+ Alcotest.test_case "RFC 9942 inclusion receipt" `Quick
11721172+ test_rfc9942_inclusion_proof;
10381173 ] );
10391174 ]