diff --git a/.trinity/seals/src_port::trios::crates::trios-chat::rings::CR-CHAT-03::src::commit_secret_export_collision.json b/.trinity/seals/src_port::trios::crates::trios-chat::rings::CR-CHAT-03::src::commit_secret_export_collision.json new file mode 100644 index 0000000000..7ddfe11c0b --- /dev/null +++ b/.trinity/seals/src_port::trios::crates::trios-chat::rings::CR-CHAT-03::src::commit_secret_export_collision.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:fc2470c4a69974fc2ad988d099340af362fdcd04201d87da6f5e8898b7beda0e", + "gen_hash_rust": "sha256:c35a375c6c951000a60b09e1ad975187a41b7ae19acb5f7f2725d80f30a4b6eb", + "gen_hash_verilog": "sha256:38e959ca974dc4527d13d160f02fdb50c37d648a02a714a2340a22bb5935aa85", + "gen_hash_zig": "sha256:1bdf14291f70202c098888c39834eb413d8ad6542c483ca0299ccff4ec28730d", + "module": "port::trios::crates::trios-chat::rings::CR-CHAT-03::src::commit_secret_export_collision", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-17T08:50:35Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:a88a1c3145a9063879cf3633422969ca87517b5f6602adbcd39628b6d3293a4b", + "spec_path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/commit_secret_export_collision.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 cfbb74965e..8dcfebf24b 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -857,6 +857,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-02/src/skipped_message_key_gap_bound_guard.t27", "t27b": "pass"}, + {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/commit_secret_export_collision.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/confirmation_tag_chain.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/external_commit.t27", "t27b": "pass"}, {"path": "specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/external_commit_resumption_psk_misbinding.t27", "t27b": "pass"}, diff --git a/specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/commit_secret_export_collision.t27 b/specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/commit_secret_export_collision.t27 new file mode 100644 index 0000000000..12f5cda2b1 --- /dev/null +++ b/specs/port/trios/crates/trios-chat/rings/CR-CHAT-03/src/commit_secret_export_collision.t27 @@ -0,0 +1,387 @@ +module port::trios::crates::trios-chat::rings::CR-CHAT-03::src::commit_secret_export_collision; + +// Port of gHashTag/trios crates/trios-chat/rings/CR-CHAT-03/src/commit_secret_export_collision.rs +// (CR-CHAT-03 - Commit-secret export collision, Wave-33, +// L-CHAT-3-csec, R-CHAT-11, RFC 9420 8.4/9). +// +// After each Commit the deriving member exports a fresh +// commit secret from the KDF tree, bound to the group, the +// epoch and the commit transcript hash. A hostile member +// splices across groups or epochs, replays a published +// secret, binds to a phantom transcript or submits the +// zero sentinel; a single deny wins. The rules, in the +// original's fixed order: the canonical secret length +// (thirty-two), the non-empty transcript hash, the +// transcript present in the local ledger, the group equal to +// the expected one, the epoch equal to the current one, the +// (transcript, secret) pair fresh against every prior +// export, and the secret not the all-zero sentinel. +// +// The original returns Result<(), CommitSecretError>. This +// port carries the verdict as a named struct (owner decision +// D5): Ok first, then the seven variants in the original's +// declaration order - NonCanonicalCommitSecretLength, +// EmptyTranscriptHash, UnknownTranscriptHash, +// CrossGroupCommitSecret, StaleEpochCommitSecret, +// CommitSecretReplay, ZeroCommitSecret. Every original +// variant carries no payload, so the code is the whole +// verdict. +// +// The original's export and view structs become flat +// parameters, the byte vectors arriving as plain slices. +// The vector of known transcript hashes becomes a slice of +// fixed-capacity records (sixty-four bytes, the original's +// generous transcript upper bound), and the vector of +// exported pairs becomes a slice of records holding the +// transcript and the secret with their lengths - the +// original's Vec contains and tuple contains become scans +// (conventions section 4), each comparison a length gate +// then a byte walk. + +// Canonical commit_secret length (32 bytes). +pub const COMMIT_SECRET_LEN: usize = 32; + +// Maximum transcript_hash byte length accepted. +pub const COMMIT_TRANSCRIPT_HASH_MAX_LEN: usize = 64; + +// A transcript hash the local commit ledger has observed. +pub struct TranscriptRef { + bytes: [64]u8, + hash_len: usize, +} + +// A (transcript_hash, commit_secret) pair already consumed +// by a prior commit export. +pub struct ExportedPair { + hash: [64]u8, + hash_len: usize, + secret: [64]u8, + secret_len: usize, +} + +// Verdict codes: Ok(()) and CommitSecretError's variants, in declaration order. +pub enum CsecCode { + Ok, + NonCanonicalCommitSecretLength, + EmptyTranscriptHash, + UnknownTranscriptHash, + CrossGroupCommitSecret, + StaleEpochCommitSecret, + CommitSecretReplay, + ZeroCommitSecret, +} + +// The verdict of the commit-secret export validation - the +// original's variants carry no payload, so the code alone +// carries it. +pub struct CsecVerdict { + code: CsecCode, +} + +// Content equality over two byte slices - the original's +// Vec equality on the group identifiers. +pub fn group_ids_equal(a: []const u8, b: []const u8) -> bool { + if (a.len != b.len) { + return false; + } + for i in 0..a.len { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +// Whether the ledger knows this transcript hash - the +// original's Vec contains over the known hashes, answered by +// a scan over the fixed-capacity records. +pub fn transcript_known(known: []const TranscriptRef, hash: []const u8) -> bool { + for i in 0..known.len { + if (known[i].hash_len == hash.len) { + var same: bool = true; + for j in 0..hash.len { + if (known[i].bytes[j] != hash[j]) { + same = false; + } + } + if (same) { + return true; + } + } + } + return false; +} + +// Whether this (transcript, secret) pair was already +// exported - the original's tuple contains, answered by a +// scan over the pair records. +pub fn pair_exported(pairs: []const ExportedPair, hash: []const u8, secret: []const u8) -> bool { + for i in 0..pairs.len { + if (pairs[i].hash_len == hash.len) { + if (pairs[i].secret_len == secret.len) { + var same: bool = true; + for j in 0..hash.len { + if (pairs[i].hash[j] != hash[j]) { + same = false; + } + } + for j in 0..secret.len { + if (pairs[i].secret[j] != secret[j]) { + same = false; + } + } + if (same) { + return true; + } + } + } + } + return false; +} + +// Whether every byte is zero - the original's all-zero +// sentinel probe. +pub fn all_zero_bytes(bytes: []const u8) -> bool { + for i in 0..bytes.len { + if (bytes[i] != 0) { + return false; + } + } + return true; +} + +// [VERIFIED] Validate an exported commit secret. Returns Ok +// only when every rule holds, checked in the original's +// fixed order: the canonical length, the non-empty +// transcript, the known transcript, the expected group, the +// current epoch, the fresh pair and the non-zero secret. +pub fn validate_commit_secret_export(commit_secret: []const u8, transcript_hash: []const u8, group_id: []const u8, commit_epoch: u64, expected_group_id: []const u8, current_epoch: u64, known_hashes: []const TranscriptRef, exported_pairs: []const ExportedPair) -> CsecVerdict { + if (commit_secret.len != COMMIT_SECRET_LEN) { + return CsecVerdict { code: CsecCode.NonCanonicalCommitSecretLength, }; + } + if (transcript_hash.len == 0) { + return CsecVerdict { code: CsecCode.EmptyTranscriptHash, }; + } + if (!transcript_known(known_hashes, transcript_hash)) { + return CsecVerdict { code: CsecCode.UnknownTranscriptHash, }; + } + if (!group_ids_equal(group_id, expected_group_id)) { + return CsecVerdict { code: CsecCode.CrossGroupCommitSecret, }; + } + if (commit_epoch != current_epoch) { + return CsecVerdict { code: CsecCode.StaleEpochCommitSecret, }; + } + if (pair_exported(exported_pairs, transcript_hash, commit_secret)) { + return CsecVerdict { code: CsecCode.CommitSecretReplay, }; + } + if (all_zero_bytes(commit_secret)) { + return CsecVerdict { code: CsecCode.ZeroCommitSecret, }; + } + return CsecVerdict { code: CsecCode.Ok, }; +} + +// The fixture bytes: the canonical secret of forty-four-hex, +// the zero sentinel, the fresh distinct secret of +// double-C-hex, the transcripts and the group identifiers of +// the original's fixtures. +pub const CANONICAL_SECRET: [32]u8 = [32]u8{68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68,} +pub const ZERO_SECRET: [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,} +pub const FRESH_SECRET: [32]u8 = [32]u8{204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204,} +pub const PAIR_CANONICAL: [64]u8 = [64]u8{68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 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,} +pub const PAIR_FRESH: [64]u8 = [64]u8{204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 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,} +pub const PAIR_ZERO: [64]u8 = [64]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, 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,} +pub const TRANS_A: [12]u8 = [12]u8{116, 114, 97, 110, 115, 99, 114, 105, 112, 116, 45, 65,} +pub const TRANS_B: [12]u8 = [12]u8{116, 114, 97, 110, 115, 99, 114, 105, 112, 116, 45, 66,} +pub const PHANTOM: [18]u8 = [18]u8{112, 104, 97, 110, 116, 111, 109, 45, 116, 114, 97, 110, 115, 99, 114, 105, 112, 116,} +pub const GOOD_GROUP: [16]u8 = [16]u8{116, 114, 105, 110, 105, 116, 121, 45, 103, 114, 111, 117, 112, 45, 51, 51,} +pub const OTHER_GROUP: [19]u8 = [19]u8{116, 114, 105, 110, 105, 116, 121, 45, 103, 114, 111, 117, 112, 45, 79, 84, 72, 69, 82,} + +// The canonical view's known transcripts. +pub const KNOWN_HASHES: [2]TranscriptRef = [2]TranscriptRef{ + TranscriptRef { bytes: [64]u8{116, 114, 97, 110, 115, 99, 114, 105, 112, 116, 45, 65, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, }, hash_len: 12, }, + TranscriptRef { bytes: [64]u8{116, 114, 97, 110, 115, 99, 114, 105, 112, 116, 45, 66, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, }, hash_len: 12, }, +}; + +// The 64-capacity forms of the pair-record secrets: the +// record capacity is sixty-four, the canonical length rides +// the length field. + +// **CSEC-01** - the sixteen-byte commit secret rejected. +test csec_01_short_commit_secret_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + var short_secret: [16]u8; + for j in 0..16 { + short_secret[j] = 68; + } + const v = validate_commit_secret_export(&short_secret, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.NonCanonicalCommitSecretLength); +} + +// **CSEC-02** - the sixty-four-byte commit secret rejected. +test csec_02_over_long_commit_secret_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + var long_secret: [64]u8; + for j in 0..64 { + long_secret[j] = 68; + } + const v = validate_commit_secret_export(&long_secret, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.NonCanonicalCommitSecretLength); +} + +// **CSEC-03** - the empty transcript hash rejected. +test csec_03_empty_transcript_hash_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + const empty_hash: [0]u8 = [0]u8{}; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &empty_hash, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.EmptyTranscriptHash); +} + +// **CSEC-04** - the phantom transcript hash rejected: not in +// the ledger. +test csec_04_unknown_transcript_hash_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &PHANTOM, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.UnknownTranscriptHash); +} + +// **CSEC-05** - the cross-group commit secret rejected. +test csec_05_cross_group_commit_secret_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &OTHER_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.CrossGroupCommitSecret); +} + +// **CSEC-06** - the stale-epoch commit secret rejected: +// eleven of a current twelve. +test csec_06_stale_epoch_commit_secret_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &GOOD_GROUP, 11, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.StaleEpochCommitSecret); +} + +// **CSEC-07** - the replayed (transcript, secret) pair +// rejected: this very pair was exported before. +test csec_07_replayed_commit_secret_rejected { + var hash_buf: [64]u8; + for j in 0..64 { + hash_buf[j] = 0; + } + for j in 0..12 { + hash_buf[j] = TRANS_A[j]; + } + const pairs: [1]ExportedPair = [1]ExportedPair{ + ExportedPair { hash: hash_buf, hash_len: 12, secret: PAIR_CANONICAL, secret_len: 32, }, + }; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &pairs); + assert(v.code == CsecCode.CommitSecretReplay); +} + +// **CSEC-08** - the all-zero commit secret rejected. +test csec_08_zero_commit_secret_rejected { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + const v = validate_commit_secret_export(&ZERO_SECRET, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.ZeroCommitSecret); +} + +// **CSEC-09** - the canonical commit secret accepted. +test csec_09_canonical_commit_secret_accepted { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v.code == CsecCode.Ok); +} + +// **CSEC-10** - the same transcript with a fresh distinct +// secret accepted: the replay rule reads the pair, never the +// hash alone. +test csec_10_distinct_fresh_commit_secret_accepted { + var hash_buf: [64]u8; + for j in 0..64 { + hash_buf[j] = 0; + } + for j in 0..12 { + hash_buf[j] = TRANS_A[j]; + } + const pairs: [1]ExportedPair = [1]ExportedPair{ + ExportedPair { hash: hash_buf, hash_len: 12, secret: PAIR_FRESH, secret_len: 32, }, + }; + const v = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &pairs); + assert(v.code == CsecCode.Ok); +} + +// The helpers have direct tests (coverage: Untested 0). +test group_ids_equal_answers { + assert(group_ids_equal(&GOOD_GROUP, &GOOD_GROUP)); + assert(!group_ids_equal(&GOOD_GROUP, &OTHER_GROUP)); +} + +test transcript_known_answers { + assert(transcript_known(&KNOWN_HASHES, &TRANS_A)); + assert(transcript_known(&KNOWN_HASHES, &TRANS_B)); + assert(!transcript_known(&KNOWN_HASHES, &PHANTOM)); +} + +test pair_exported_answers { + var hash_buf: [64]u8; + for j in 0..64 { + hash_buf[j] = 0; + } + for j in 0..12 { + hash_buf[j] = TRANS_A[j]; + } + const pairs: [2]ExportedPair = [2]ExportedPair{ + ExportedPair { hash: hash_buf, hash_len: 12, secret: PAIR_CANONICAL, secret_len: 32, }, + ExportedPair { hash: hash_buf, hash_len: 12, secret: PAIR_FRESH, secret_len: 32, }, + }; + assert(pair_exported(&pairs, &TRANS_A, &CANONICAL_SECRET)); + assert(!pair_exported(&pairs, &TRANS_A, &ZERO_SECRET)); + assert(!pair_exported(&pairs, &PHANTOM, &CANONICAL_SECRET)); +} + +test all_zero_bytes_answers { + assert(all_zero_bytes(&ZERO_SECRET)); + assert(!all_zero_bytes(&CANONICAL_SECRET)); +} + +// Rule order is the original's fixed ladder, each probe +// stacking the earlier rule's violation on the later one: a +// short secret over an empty transcript, the empty +// transcript over the phantom, the phantom over the wrong +// group, the wrong group over the stale epoch, the stale +// epoch over the replayed pair, and the replayed pair over +// the zero sentinel. +test rule_order_matches_original { + const no_pairs: [0]ExportedPair = [0]ExportedPair{}; + var short_secret: [16]u8; + for j in 0..16 { + short_secret[j] = 68; + } + const empty_hash: [0]u8 = [0]u8{}; + + const v1 = validate_commit_secret_export(&short_secret, &empty_hash, &OTHER_GROUP, 11, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v1.code == CsecCode.NonCanonicalCommitSecretLength); + + const v2 = validate_commit_secret_export(&CANONICAL_SECRET, &empty_hash, &OTHER_GROUP, 11, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v2.code == CsecCode.EmptyTranscriptHash); + + const v3 = validate_commit_secret_export(&CANONICAL_SECRET, &PHANTOM, &OTHER_GROUP, 11, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v3.code == CsecCode.UnknownTranscriptHash); + + const v4 = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &OTHER_GROUP, 11, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v4.code == CsecCode.CrossGroupCommitSecret); + + const v5 = validate_commit_secret_export(&CANONICAL_SECRET, &TRANS_A, &GOOD_GROUP, 11, &GOOD_GROUP, 12, &KNOWN_HASHES, &no_pairs); + assert(v5.code == CsecCode.StaleEpochCommitSecret); + + var hash_buf: [64]u8; + for j in 0..64 { + hash_buf[j] = 0; + } + for j in 0..12 { + hash_buf[j] = TRANS_A[j]; + } + const zero_pairs: [1]ExportedPair = [1]ExportedPair{ + ExportedPair { hash: hash_buf, hash_len: 12, secret: PAIR_ZERO, secret_len: 32, }, + }; + const v6 = validate_commit_secret_export(&ZERO_SECRET, &TRANS_A, &GOOD_GROUP, 12, &GOOD_GROUP, 12, &KNOWN_HASHES, &zero_pairs); + assert(v6.code == CsecCode.CommitSecretReplay); +}