From 0b85d4bd2a6f2a90d3e5b5ab8ad2cedbbe0fc1ba Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Wed, 22 Jul 2026 09:38:49 -0500 Subject: [PATCH] enforce Sync 1.1 commit proofs --- src/internal/broadcaster.zig | 3 + src/internal/validator.zig | 235 ++++++++++++++++++++++++++++------- 2 files changed, 192 insertions(+), 46 deletions(-) diff --git a/src/internal/broadcaster.zig b/src/internal/broadcaster.zig index 3ea42f9..69c8a9f 100644 --- a/src/internal/broadcaster.zig +++ b/src/internal/broadcaster.zig @@ -40,6 +40,7 @@ pub const Stats = struct { failed_bad_structure: std.atomic.Value(u64) = .{ .raw = 0 }, failed_signature: std.atomic.Value(u64) = .{ .raw = 0 }, failed_commit_integrity: std.atomic.Value(u64) = .{ .raw = 0 }, + failed_sync_1_1: std.atomic.Value(u64) = .{ .raw = 0 }, failed_host_authority: std.atomic.Value(u64) = .{ .raw = 0 }, failed_future_rev: std.atomic.Value(u64) = .{ .raw = 0 }, skipped: std.atomic.Value(u64) = .{ .raw = 0 }, @@ -1124,6 +1125,7 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib \\relay_validation_failed{{reason="bad_structure"}} {d} \\relay_validation_failed{{reason="signature"}} {d} \\relay_validation_failed{{reason="commit_integrity"}} {d} + \\relay_validation_failed{{reason="sync_1_1"}} {d} \\relay_validation_failed{{reason="host_authority"}} {d} \\relay_validation_failed{{reason="future_rev"}} {d} \\ @@ -1151,6 +1153,7 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib stats.failed_bad_structure.load(.acquire), stats.failed_signature.load(.acquire), stats.failed_commit_integrity.load(.acquire), + stats.failed_sync_1_1.load(.acquire), stats.failed_host_authority.load(.acquire), stats.failed_future_rev.load(.acquire), stats.host_authority_reject_parse_did.load(.acquire), diff --git a/src/internal/validator.zig b/src/internal/validator.zig index c821110..562e4ae 100644 --- a/src/internal/validator.zig +++ b/src/internal/validator.zig @@ -45,7 +45,7 @@ pub const ValidatorConfig = struct { /// verify MST structure during signature verification verify_mst: bool = false, // off by default for relay throughput /// verify commit diffs via MST inversion (sync 1.1) - verify_commit_diff: bool = false, + verify_commit_diff: bool = true, /// max allowed operations per commit max_ops: usize = 200, /// max clock skew for rev timestamps (seconds) @@ -283,6 +283,18 @@ pub const Validator = struct { return self.rejectSignature(); } + switch (err) { + error.InvalidCommitDiff, + error.MstRootMismatch, + error.PrevDataMismatch, + error.InversionMismatch, + error.PartialTree, + error.DuplicatePath, + error.InvalidMstNode, + => _ = self.stats.failed_sync_1_1.fetchAdd(1, .monotonic), + else => {}, + } + return self.rejectCommitIntegrity(); } } @@ -360,30 +372,27 @@ pub const Validator = struct { defer arena.deinit(); const alloc = arena.allocator(); - // try sync 1.1 path: extract ops and use verifyCommitDiff + // Sync 1.1 path: validate the CAR diff against ops + prevData using + // MST inversion. Do not silently fall back to signature-only validation + // when the proof envelope is absent or malformed. if (self.config.verify_commit_diff) { - if (self.extractOps(alloc, payload)) |msg_ops| { - // get stored prev_data from payload - const prev_data: ?[]const u8 = if (payload.get("prevData")) |pd| switch (pd) { - .cid => |c| c.raw, - .null => null, - else => null, - } else null; + const msg_ops = try self.extractOps(alloc, payload); + const prev_data: ?[]const u8 = if (payload.get("prevData")) |pd| switch (pd) { + .cid => |c| c.raw, + .null => null, + else => return error.InvalidFrame, + } else null; - const diff_result = zat.verifyCommitDiff(alloc, blocks, msg_ops, prev_data, public_key, .{ - .expected_did = expected_did, - .skip_inversion = prev_data == null, - }) catch |err| { - return err; - }; - - return .{ - .valid = true, - .skipped = false, - .data_cid = diff_result.data_cid, - .commit_rev = diff_result.commit_rev, - }; - } + const diff_result = try zat.verifyCommitDiff(alloc, blocks, msg_ops, prev_data, public_key, .{ + .expected_did = expected_did, + }); + + return .{ + .valid = true, + .skipped = false, + .data_cid = diff_result.data_cid, + .commit_rev = diff_result.commit_rev, + }; } // fallback: legacy verification (signature + optional MST walk) @@ -404,16 +413,16 @@ pub const Validator = struct { /// extract ops from payload and convert to mst.Operation array. /// the firehose format uses a single "path" field ("collection/rkey"), /// not separate "collection"/"rkey" fields. - fn extractOps(self: *Validator, alloc: Allocator, payload: zat.cbor.Value) ?[]const zat.MstOperation { + fn extractOps(self: *Validator, alloc: Allocator, payload: zat.cbor.Value) ![]const zat.MstOperation { _ = self; - const ops_array = payload.getArray("ops") orelse return null; + const ops_array = payload.getArray("ops") orelse return error.InvalidCommitDiff; var ops: std.ArrayListUnmanaged(zat.MstOperation) = .empty; for (ops_array) |op| { - const action = op.getString("action") orelse continue; - const path = op.getString("path") orelse continue; + const action = op.getString("action") orelse return error.InvalidCommitDiff; + const path = op.getString("path") orelse return error.InvalidCommitDiff; // validate path contains "/" (collection/rkey) - if (std.mem.indexOfScalar(u8, path, '/') == null) continue; + if (std.mem.indexOfScalar(u8, path, '/') == null) return error.InvalidCommitDiff; // extract CID values const cid_value: ?[]const u8 = if (op.get("cid")) |v| switch (v) { @@ -425,28 +434,29 @@ pub const Validator = struct { var prev: ?[]const u8 = null; if (std.mem.eql(u8, action, "create")) { - value = cid_value; + value = cid_value orelse return error.InvalidCommitDiff; } else if (std.mem.eql(u8, action, "update")) { - value = cid_value; + value = cid_value orelse return error.InvalidCommitDiff; prev = if (op.get("prev")) |v| switch (v) { .cid => |c| c.raw, else => null, } else null; + if (prev == null) return error.InvalidCommitDiff; } else if (std.mem.eql(u8, action, "delete")) { prev = if (op.get("prev")) |v| switch (v) { .cid => |c| c.raw, else => null, } else null; - } else continue; + if (prev == null) return error.InvalidCommitDiff; + } else return error.InvalidCommitDiff; - ops.append(alloc, .{ + try ops.append(alloc, .{ .path = path, .value = value, .prev = prev, - }) catch return null; + }); } - if (ops.items.len == 0) return null; return ops.items; } @@ -1006,6 +1016,7 @@ test "extractOps reads path field from firehose format" { .{ .map = &.{ .{ .key = "action", .value = .{ .text = "delete" } }, .{ .key = "path", .value = .{ .text = "app.bsky.feed.like/3k2def000000" } }, + .{ .key = "prev", .value = .{ .cid = .{ .raw = "fakecid67890" } } }, } }, }; @@ -1014,13 +1025,12 @@ test "extractOps reads path field from firehose format" { .{ .key = "ops", .value = .{ .array = &ops } }, } }; - const result = v.extractOps(arena.allocator(), payload); - try std.testing.expect(result != null); - try std.testing.expectEqual(@as(usize, 2), result.?.len); - try std.testing.expectEqualStrings("app.bsky.feed.post/3k2abc000000", result.?[0].path); - try std.testing.expectEqualStrings("app.bsky.feed.like/3k2def000000", result.?[1].path); - try std.testing.expect(result.?[0].value != null); // create has cid - try std.testing.expect(result.?[1].value == null); // delete has no cid + const result = try v.extractOps(arena.allocator(), payload); + try std.testing.expectEqual(@as(usize, 2), result.len); + try std.testing.expectEqualStrings("app.bsky.feed.post/3k2abc000000", result[0].path); + try std.testing.expectEqualStrings("app.bsky.feed.like/3k2def000000", result[1].path); + try std.testing.expect(result[0].value != null); // create has cid + try std.testing.expect(result[1].value == null); // delete has no cid } test "extractOps rejects malformed path without slash" { @@ -1044,9 +1054,7 @@ test "extractOps rejects malformed path without slash" { .{ .key = "ops", .value = .{ .array = &ops } }, } }; - // malformed path (no slash) → all ops skipped → returns null - const result = v.extractOps(arena.allocator(), payload); - try std.testing.expect(result == null); + try std.testing.expectError(error.InvalidCommitDiff, v.extractOps(arena.allocator(), payload)); } test "checkCommitStructure validates path field" { @@ -1116,7 +1124,98 @@ fn buildSignedCommitCar(a: Allocator, kp: zat.Keypair, did_str: []const u8, rev: }); } -test "wrong signing key rejects commit" { +const CommitDiffFixture = struct { + car_bytes: []const u8, + prev_data: zat.cbor.Cid, + data: zat.cbor.Cid, + old_record: zat.cbor.Cid, + new_record: zat.cbor.Cid, + commit: zat.cbor.Cid, +}; + +fn buildCommitDiffFixture(a: Allocator, kp: zat.Keypair, did: []const u8, rev: []const u8) !CommitDiffFixture { + const path = "app.bsky.feed.post/3k2abcdefghij"; + const old_record_bytes = "old-record"; + const new_record_bytes = "new-record"; + const old_record = try zat.cbor.Cid.forDagCbor(a, old_record_bytes); + const new_record = try zat.cbor.Cid.forDagCbor(a, new_record_bytes); + + var before = zat.mst.Mst.init(a); + try before.put(path, old_record); + const prev_data = try before.rootCid(); + + var after = try before.copy(); + try after.put(path, new_record); + const data = try after.rootCid(); + + const signed = try zat.signCommit(a, .{ + .did = did, + .rev = rev, + .data = data, + }, &kp); + + var blocks: std.ArrayList(zat.car.Block) = .empty; + try blocks.append(a, .{ .cid_raw = signed.cid.raw, .data = signed.bytes }); + try after.collectBlocks(&blocks); + try blocks.append(a, .{ .cid_raw = new_record.raw, .data = new_record_bytes }); + + return .{ + .car_bytes = try zat.car.writeAlloc(a, .{ + .roots = &.{signed.cid}, + .blocks = blocks.items, + }), + .prev_data = prev_data, + .data = data, + .old_record = old_record, + .new_record = new_record, + .commit = signed.cid, + }; +} + +test "Sync 1.1 accepts commit diff with correct prevData" { + var stats = broadcaster.Stats{}; + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); + defer v.deinit(); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const kp = try zat.Keypair.fromSecretKey(.p256, .{1} ** 32); + const did = "did:plc:test123"; + const rev = "3k2abcdefghij"; + const fixture = try buildCommitDiffFixture(a, kp, did, rev); + const pubkey = try kp.publicKey(); + try v.cache.put(did, .{ + .key_type = .p256, + .raw = pubkey, + .len = 33, + .resolve_time = 100, + }); + + const ops = [_]zat.cbor.Value{.{ .map = &.{ + .{ .key = "action", .value = .{ .text = "update" } }, + .{ .key = "path", .value = .{ .text = "app.bsky.feed.post/3k2abcdefghij" } }, + .{ .key = "cid", .value = .{ .cid = fixture.new_record } }, + .{ .key = "prev", .value = .{ .cid = fixture.old_record } }, + } }}; + const payload: zat.cbor.Value = .{ .map = &.{ + .{ .key = "repo", .value = .{ .text = did } }, + .{ .key = "rev", .value = .{ .text = rev } }, + .{ .key = "commit", .value = .{ .cid = fixture.commit } }, + .{ .key = "blocks", .value = .{ .bytes = fixture.car_bytes } }, + .{ .key = "ops", .value = .{ .array = &ops } }, + .{ .key = "prevData", .value = .{ .cid = fixture.prev_data } }, + } }; + + const result = v.validateCommit(payload); + try std.testing.expect(result.valid); + try std.testing.expect(!result.skipped); + try std.testing.expectEqualSlices(u8, fixture.data.raw, result.data_cid.?); + try std.testing.expectEqualStrings(rev, result.commit_rev.?); +} + +test "Sync 1.1 rejects commit diff with wrong prevData" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); @@ -1125,6 +1224,50 @@ test "wrong signing key rejects commit" { defer arena.deinit(); const a = arena.allocator(); + const kp = try zat.Keypair.fromSecretKey(.p256, .{1} ** 32); + const did = "did:plc:test123"; + const rev = "3k2abcdefghij"; + const fixture = try buildCommitDiffFixture(a, kp, did, rev); + const wrong_prev_data = try zat.cbor.Cid.forDagCbor(a, "wrong-prev-data"); + const pubkey = try kp.publicKey(); + try v.cache.put(did, .{ + .key_type = .p256, + .raw = pubkey, + .len = 33, + .resolve_time = 100, + }); + + const ops = [_]zat.cbor.Value{.{ .map = &.{ + .{ .key = "action", .value = .{ .text = "update" } }, + .{ .key = "path", .value = .{ .text = "app.bsky.feed.post/3k2abcdefghij" } }, + .{ .key = "cid", .value = .{ .cid = fixture.new_record } }, + .{ .key = "prev", .value = .{ .cid = fixture.old_record } }, + } }}; + const payload: zat.cbor.Value = .{ .map = &.{ + .{ .key = "repo", .value = .{ .text = did } }, + .{ .key = "rev", .value = .{ .text = rev } }, + .{ .key = "commit", .value = .{ .cid = fixture.commit } }, + .{ .key = "blocks", .value = .{ .bytes = fixture.car_bytes } }, + .{ .key = "ops", .value = .{ .array = &ops } }, + .{ .key = "prevData", .value = .{ .cid = wrong_prev_data } }, + } }; + + const result = v.validateCommit(payload); + try std.testing.expect(!result.valid); + try std.testing.expect(!result.skipped); + try std.testing.expectEqual(@as(u64, 1), stats.failed_commit_integrity.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), stats.failed_sync_1_1.load(.acquire)); +} + +test "wrong signing key rejects commit" { + var stats = broadcaster.Stats{}; + var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = false }, std.testing.io); + defer v.deinit(); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const signer = try zat.Keypair.fromSecretKey(.p256, .{1} ** 32); const wrong_signer = try zat.Keypair.fromSecretKey(.p256, .{2} ** 32); const did = "did:plc:test123"; @@ -1273,7 +1416,7 @@ test "regression: sig-only verification returns null data_cid" { // prevData chain-continuity check (~91% of relay_chain_breaks_total were // commit-CID-vs-MST-root comparisons that can never be equal). var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats, std.testing.io); + var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = false }, std.testing.io); defer v.deinit(); var arena = std.heap.ArenaAllocator.init(std.testing.allocator); -- 2.51.2