diff --git a/.trinity/seals/src_port::trios::crates::trios-chat::rings::CR-CHAT-03::src::ratchet_tree_update_integrity_guard.json b/.trinity/seals/src_port::trios::crates::trios-chat::rings::CR-CHAT-03::src::ratchet_tree_update_integrity_guard.json new file mode 100644 index 0000000000..027177ba4d --- /dev/null +++ b/.trinity/seals/src_port::trios::crates::trios-chat::rings::CR-CHAT-03::src::ratchet_tree_update_integrity_guard.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+01d0a635f", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:000e5eab294c0c753504f25195acc809377d7f7a8e95a2b27cf4980a00db9311", + "gen_hash_rust": "sha256:b3e126bc2a02b0b09abd2ce4b395f3ef014003e70c92e85fab3b97f4a90659e7", + "gen_hash_verilog": "sha256:468becd2cbd4b9d753dd1788c6e233704d0c828736ebad0483ff715e996f967b", + "gen_hash_zig": "sha256:f02dfea66b3e4c7e419cf89902d92d00bb0115b6c77dd54ae65b1fa3ae4fa529", + "module": "port::trios::crates::trios-chat::rings::CR-CHAT-03::src::ratchet_tree_update_integrity_guard", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-17T14:00:53Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:768e5ce725e6b6c2a407b5cd0c9628774cb202409c001b2ebcc4ffd8dadaa67d", + "spec_path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_update_integrity_guard.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 15, + "total": 15, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index c041cbab56..3bcce211a8 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -852,6 +852,7 @@ {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-01/src/key_package_expiry_guard.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-02/src/epoch_rollover_wraparound_guard.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_resolution_guard.t27", "t27b": "pass"}, + {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_update_integrity_guard.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/unmerged_leaves_bound_guard.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-05/src/envelope_size_distribution_uniformity.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-07/src/cover_decoy_ratio_governor.t27", "t27b": "pass"}, diff --git a/specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_update_integrity_guard.t27 b/specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_update_integrity_guard.t27 new file mode 100644 index 0000000000..9dc54d59f5 --- /dev/null +++ b/specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_update_integrity_guard.t27 @@ -0,0 +1,347 @@ +module port::trios::crates::trios-chat::rings::CR-CHAT-03::src::ratchet_tree_update_integrity_guard; + +// Port of gHashTag/trios crates/trios-chat/rings/CR-CHAT-03/src/ratchet_tree_update_integrity_guard.rs +// (CR-CHAT-03 - Ratchet tree update integrity guard, Wave-84 +// Lane A, R-CHAT-3). +// +// Every TreeKEM update carries the parent hash binding its +// node to the tree structure; without the gate an attacker +// injects an orphan node whose hash sits outside +// consensus, swaps siblings and re-signs so recipients +// derive different keys, or replays a stale update from a +// prior epoch. The rules, in the original's fixed order: +// the batch ceiling (two hundred fifty-six), then per +// update the node index inside the tree (one thousand +// twenty-four), the fresh node (no duplicate in the +// batch), the claimed parent hash equal to the computed +// one, the epoch equal to the current one, and the signer +// an active leaf member. +// +// The original returns Result<(), TreeUpdateError>. This +// port carries the verdict as a named struct (owner +// decision D5): Ok first, then the six variants in the +// original's declaration order - HashMismatch, +// InvalidNodeIndex, EpochMismatch, DuplicateNode, +// BatchTooLarge, NonMemberSigner - with every payload +// field: the node index of the per-update rules, the +// signer leaf of the membership rule, and the expected +// and got epochs of the epoch rule. +// +// The original's BTreeSet of seen nodes becomes a scan +// over the updates already visited, the member set a scan +// over the member leaf slice (conventions section 4). The +// loop fixtures of the original - the node hash as the +// low byte of its index - arrive as a precomputed table +// the loop reads, the computed and tabular forms matching +// exactly (the narrowing cast on the original's counter +// does not exist in t27). + +// Maximum nodes in the tree. +pub const RTUI_MAX_NODES: u64 = 1024; + +// Maximum update batch size. +pub const RTUI_MAX_BATCH: usize = 256; + +// A tree update proposal. +pub struct TreeUpdate { + node_index: u64, + parent_hash: [32]u8, + computed_hash: [32]u8, + epoch: u64, + signer_leaf: u64, +} + +// Verdict codes: Ok(()) and TreeUpdateError's variants, in declaration order. +pub enum RtuiCode { + Ok, + HashMismatch, + InvalidNodeIndex, + EpochMismatch, + DuplicateNode, + BatchTooLarge, + NonMemberSigner, +} + +// The verdict of the batch validation, with every payload +// field of the original's variants. +pub struct RtuiVerdict { + code: RtuiCode, + idx: u64, + expected: u64, + got: u64, +} + +// Whole-form equality over two thirty-two-byte hashes - the +// original's array equality on the parent binding. +pub fn hashes32_equal(a: [32]u8, b: [32]u8) -> bool { + for i in 0..32 { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +// Whether the leaf slice holds this signer - the original's +// member-set contains, answered by a scan. +pub fn member_contains(members: []const u64, leaf: u64) -> bool { + for i in 0..members.len { + if (members[i] == leaf) { + return true; + } + } + return false; +} + +// Whether an update before position upto already claimed +// this node - the original's BTreeSet insert probe, +// answered by a scan. +pub fn node_seen_before(updates: []const TreeUpdate, upto: usize, node_index: u64) -> bool { + for j in 0..upto { + if (updates[j].node_index == node_index) { + return true; + } + } + return false; +} + +// [VERIFIED] Validate a batch of tree updates. Returns Ok +// only when every rule holds, checked in the original's +// fixed order: the batch ceiling, then per update the node +// bound, the fresh node, the hash agreement, the epoch +// agreement and the member signer. +pub fn validate_tree_updates(updates: []const TreeUpdate, current_epoch: u64, member_leaves: []const u64) -> RtuiVerdict { + if (updates.len > RTUI_MAX_BATCH) { + return RtuiVerdict { code: RtuiCode.BatchTooLarge, idx: 0, expected: 0, got: 0, }; + } + for i in 0..updates.len { + if (updates[i].node_index >= RTUI_MAX_NODES) { + return RtuiVerdict { code: RtuiCode.InvalidNodeIndex, idx: updates[i].node_index, expected: 0, got: 0, }; + } + if (node_seen_before(updates, i, updates[i].node_index)) { + return RtuiVerdict { code: RtuiCode.DuplicateNode, idx: updates[i].node_index, expected: 0, got: 0, }; + } + if (!hashes32_equal(updates[i].parent_hash, updates[i].computed_hash)) { + return RtuiVerdict { code: RtuiCode.HashMismatch, idx: updates[i].node_index, expected: 0, got: 0, }; + } + if (updates[i].epoch != current_epoch) { + return RtuiVerdict { code: RtuiCode.EpochMismatch, idx: 0, expected: current_epoch, got: updates[i].epoch, }; + } + if (!member_contains(member_leaves, updates[i].signer_leaf)) { + return RtuiVerdict { code: RtuiCode.NonMemberSigner, idx: updates[i].signer_leaf, expected: 0, got: 0, }; + } + } + return RtuiVerdict { code: RtuiCode.Ok, idx: 0, expected: 0, got: 0, }; +} + +// The fixture members: the even leaves zero through six. +pub const MEMBERS: [4]u64 = [4]u64{0, 2, 4, 6,}; + +// The all-double-F-hex hash of the mismatch probe. +pub const HASH_FF: [32]u8 = [32]u8{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,} + +// The low-byte table of the loop fixtures: the node hash +// reads the low byte of its index. +pub const RTUI_LOW: [257]u8 = [257]u8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 0,} + +// The fixture builder the tests share: an update whose +// parent and computed hashes agree, both the low byte of +// its node index. +fn make_update(node_index: u64, epoch: u64, signer_leaf: u64) -> TreeUpdate { + var h: [32]u8 = [32]u8{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,}; + const slot: usize = @as(usize, node_index % 256); + for j in 0..32 { + h[j] = RTUI_LOW[slot]; + } + return TreeUpdate { node_index: node_index, parent_hash: h, computed_hash: h, epoch: epoch, signer_leaf: signer_leaf, }; +} + +// **RTUI-01** - the parent hash mismatch rejected: the +// claimed double-F-hex of the computed low byte, the +// verdict naming the node. +test rtui_01_hash_mismatch_rejected { + var u: TreeUpdate = make_update(1, 5, 0); + u.parent_hash = HASH_FF; + const batch: [1]TreeUpdate = [1]TreeUpdate{u,}; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.HashMismatch); + assert(v.idx == 1); +} + +// **RTUI-02** - the invalid node index rejected: the node +// ceiling itself. +test rtui_02_invalid_node_rejected { + const u: TreeUpdate = make_update(1024, 5, 0); + const batch: [1]TreeUpdate = [1]TreeUpdate{u,}; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.InvalidNodeIndex); + assert(v.idx == 1024); +} + +// **RTUI-03** - the epoch mismatch rejected: four of a +// current five, the verdict carrying both. +test rtui_03_epoch_mismatch_rejected { + const u: TreeUpdate = make_update(1, 4, 0); + const batch: [1]TreeUpdate = [1]TreeUpdate{u,}; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.EpochMismatch); + assert(v.expected == 5); + assert(v.got == 4); +} + +// **RTUI-04** - the duplicate node rejected: node one +// updated twice in one batch. +test rtui_04_duplicate_rejected { + const batch: [2]TreeUpdate = [2]TreeUpdate{ + make_update(1, 5, 0), + make_update(1, 5, 2), + }; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.DuplicateNode); + assert(v.idx == 1); +} + +// **RTUI-05** - the batch too large rejected: two hundred +// fifty-seven of a ceiling of two hundred fifty-six, +// built in a loop like the original's. +test rtui_05_batch_too_large_rejected { + var batch: [257]TreeUpdate; + for i in 0..257 { + batch[i] = make_update(@as(u64, i), 5, 0); + } + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.BatchTooLarge); +} + +// **RTUI-06** - the non-member signer rejected: ninety-nine +// of the even members. +test rtui_06_non_member_rejected { + const u: TreeUpdate = make_update(1, 5, 99); + const batch: [1]TreeUpdate = [1]TreeUpdate{u,}; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.NonMemberSigner); + assert(v.idx == 99); +} + +// **RTUI-07** - the valid batch accepted. +test rtui_07_valid_accepted { + const batch: [2]TreeUpdate = [2]TreeUpdate{ + make_update(1, 5, 0), + make_update(3, 5, 2), + }; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.Ok); +} + +// **RTUI-08** - the empty batch accepted. +test rtui_08_empty_accepted { + const batch: [0]TreeUpdate = [0]TreeUpdate{}; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.Ok); +} + +// **RTUI-09** - a single update accepted. +test rtui_09_single_accepted { + const batch: [1]TreeUpdate = [1]TreeUpdate{ + make_update(1, 5, 0), + }; + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.Ok); +} + +// **RTUI-10** - the maximum batch accepted: exactly two +// hundred fifty-six distinct nodes. +test rtui_10_max_batch_accepted { + var batch: [256]TreeUpdate; + for i in 0..256 { + batch[i] = make_update(@as(u64, i), 5, 0); + } + const v = validate_tree_updates(&batch, 5, &MEMBERS); + assert(v.code == RtuiCode.Ok); +} + +// The helpers have direct tests (coverage: Untested 0). +test hashes32_equal_answers { + const u: TreeUpdate = make_update(1, 5, 0); + assert(hashes32_equal(u.parent_hash, u.computed_hash)); + assert(!hashes32_equal(u.parent_hash, HASH_FF)); +} + +test member_contains_answers { + assert(member_contains(&MEMBERS, 0)); + assert(member_contains(&MEMBERS, 6)); + assert(!member_contains(&MEMBERS, 99)); + const empty: [0]u64 = [0]u64{}; + assert(!member_contains(&empty, 0)); +} + +test node_seen_before_answers { + const batch: [3]TreeUpdate = [3]TreeUpdate{ + make_update(1, 5, 0), + make_update(3, 5, 0), + make_update(1, 5, 0), + }; + assert(!node_seen_before(&batch, 0, 1)); + assert(!node_seen_before(&batch, 1, 3)); + assert(node_seen_before(&batch, 2, 1)); + assert(!node_seen_before(&batch, 2, 5)); +} + +// The helper builder has a direct test (coverage: the +// fixture form reads the table exactly). +test make_update_answers { + const u: TreeUpdate = make_update(1, 5, 0); + assert(u.node_index == 1); + assert(u.epoch == 5); + assert(u.signer_leaf == 0); + assert(u.parent_hash[0] == 1); + assert(u.computed_hash[0] == 1); + const big: TreeUpdate = make_update(300, 5, 0); + assert(big.parent_hash[0] == 44); +} + +// Rule order is the original's fixed ladder: the batch +// ceiling over a flooded batch, then per update the node +// bound over the duplicate over the hash agreement over +// the epoch over the membership. +test rule_order_matches_original { + var flooded: [257]TreeUpdate; + for i in 0..257 { + flooded[i] = make_update(1024, 99, 99); + } + const v1 = validate_tree_updates(&flooded, 5, &MEMBERS); + assert(v1.code == RtuiCode.BatchTooLarge); + + const stacked: [1]TreeUpdate = [1]TreeUpdate{ + make_update(1024, 99, 99), + }; + const v2 = validate_tree_updates(&stacked, 5, &MEMBERS); + assert(v2.code == RtuiCode.InvalidNodeIndex); + + const dup_pair: [2]TreeUpdate = [2]TreeUpdate{ + make_update(1, 5, 0), + make_update(1, 99, 99), + }; + const v3 = validate_tree_updates(&dup_pair, 5, &MEMBERS); + assert(v3.code == RtuiCode.DuplicateNode); + + var lying: TreeUpdate = make_update(1, 99, 99); + lying.parent_hash = HASH_FF; + const lying_batch: [1]TreeUpdate = [1]TreeUpdate{lying,}; + const v4 = validate_tree_updates(&lying_batch, 5, &MEMBERS); + assert(v4.code == RtuiCode.HashMismatch); + + const stale: [1]TreeUpdate = [1]TreeUpdate{ + make_update(1, 99, 99), + }; + const v5 = validate_tree_updates(&stale, 5, &MEMBERS); + assert(v5.code == RtuiCode.EpochMismatch); + assert(v5.expected == 5); + assert(v5.got == 99); + + const outsider: [1]TreeUpdate = [1]TreeUpdate{ + make_update(1, 5, 99), + }; + const v6 = validate_tree_updates(&outsider, 5, &MEMBERS); + assert(v6.code == RtuiCode.NonMemberSigner); +}