diff --git a/src/internal/broadcaster.zig b/src/internal/broadcaster.zig index df545d9..3ea42f9 100644 --- a/src/internal/broadcaster.zig +++ b/src/internal/broadcaster.zig @@ -39,6 +39,7 @@ pub const Stats = struct { failed_oversized_blocks: std.atomic.Value(u64) = .{ .raw = 0 }, 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_host_authority: std.atomic.Value(u64) = .{ .raw = 0 }, failed_future_rev: std.atomic.Value(u64) = .{ .raw = 0 }, skipped: std.atomic.Value(u64) = .{ .raw = 0 }, @@ -1122,6 +1123,7 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib \\relay_validation_failed{{reason="oversized_blocks"}} {d} \\relay_validation_failed{{reason="bad_structure"}} {d} \\relay_validation_failed{{reason="signature"}} {d} + \\relay_validation_failed{{reason="commit_integrity"}} {d} \\relay_validation_failed{{reason="host_authority"}} {d} \\relay_validation_failed{{reason="future_rev"}} {d} \\ @@ -1148,6 +1150,7 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib stats.failed_oversized_blocks.load(.acquire), stats.failed_bad_structure.load(.acquire), stats.failed_signature.load(.acquire), + stats.failed_commit_integrity.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 61ff95a..c821110 100644 --- a/src/internal/validator.zig +++ b/src/internal/validator.zig @@ -232,12 +232,7 @@ pub const Validator = struct { return self.rejectSignature(); } - // Other verification errors remain fail-open until commit-integrity - // rejection is introduced separately. - self.evictKey(did); - self.queueResolve(did); - _ = self.stats.skipped.fetchAdd(1, .monotonic); - return .{ .valid = true, .skipped = true }; + return self.rejectCommitIntegrity(); }; _ = self.stats.validated.fetchAdd(1, .monotonic); @@ -288,12 +283,7 @@ pub const Validator = struct { return self.rejectSignature(); } - // Other verification errors remain fail-open until commit-integrity - // rejection is introduced separately. - self.evictKey(did); - self.queueResolve(did); - _ = self.stats.skipped.fetchAdd(1, .monotonic); - return .{ .valid = true, .skipped = true }; + return self.rejectCommitIntegrity(); } } @@ -303,6 +293,12 @@ pub const Validator = struct { return .{ .valid = false, .skipped = false }; } + fn rejectCommitIntegrity(self: *Validator) ValidationResult { + _ = self.stats.failed.fetchAdd(1, .monotonic); + _ = self.stats.failed_commit_integrity.fetchAdd(1, .monotonic); + return .{ .valid = false, .skipped = false }; + } + /// Refresh a signing key after a signature failure. A recently resolved key /// is already authoritative enough to reject against; this bounds directory /// traffic when a sender repeatedly supplies bad signatures. @@ -951,10 +947,10 @@ test "spec: #commit blocks = 2,000,000 bytes accepted (boundary)" { .{ .key = "blocks", .value = .{ .bytes = exact_blocks } }, } }; - const result = v.validateCommit(payload); - // should not be rejected for size — may fail signature verification (that's fine, - // it means we passed the size check). with P1.1c, sig failure → skipped=true. - try std.testing.expect(result.valid or result.skipped); + _ = v.validateCommit(payload); + // The payload is not a CAR, but it reached verification rather than the + // oversized-block rejection branch. + try std.testing.expectEqual(@as(u64, 0), stats.failed_oversized_blocks.load(.acquire)); } test "spec: #sync blocks > 10,000 bytes rejected" { @@ -1192,6 +1188,85 @@ test "wrong signing key rejects sync" { try std.testing.expectEqual(@as(u64, 0), stats.skipped.load(.acquire)); } +test "CID mismatch rejects commit" { + 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 signer = try zat.Keypair.fromSecretKey(.p256, .{1} ** 32); + const did = "did:plc:test123"; + const rev = "3k2abcdefghij"; + const valid_car = try buildSignedCommitCar(a, signer, did, rev); + const tampered_car = try a.dupe(u8, valid_car); + tampered_car[tampered_car.len - 1] ^= 1; + try std.testing.expectError(error.BadBlockHash, zat.car.read(a, tampered_car)); + const pubkey = try signer.publicKey(); + try v.cache.put(did, .{ + .key_type = .p256, + .raw = pubkey, + .len = 33, + .resolve_time = 100, + }); + + const payload: zat.cbor.Value = .{ .map = &.{ + .{ .key = "repo", .value = .{ .text = did } }, + .{ .key = "rev", .value = .{ .text = rev } }, + .{ .key = "blocks", .value = .{ .bytes = tampered_car } }, + } }; + 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.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), stats.failed_commit_integrity.load(.acquire)); + try std.testing.expectEqual(@as(u64, 0), stats.failed_signature.load(.acquire)); + try std.testing.expectEqual(@as(u64, 0), stats.skipped.load(.acquire)); + try std.testing.expect(v.cache.contains(did)); +} + +test "CID mismatch rejects sync" { + 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 signer = try zat.Keypair.fromSecretKey(.p256, .{1} ** 32); + const did = "did:plc:test123"; + const rev = "3k2abcdefghij"; + const valid_car = try buildSignedCommitCar(a, signer, did, rev); + const tampered_car = try a.dupe(u8, valid_car); + tampered_car[tampered_car.len - 1] ^= 1; + const pubkey = try signer.publicKey(); + try v.cache.put(did, .{ + .key_type = .p256, + .raw = pubkey, + .len = 33, + .resolve_time = 100, + }); + + const payload: zat.cbor.Value = .{ .map = &.{ + .{ .key = "did", .value = .{ .text = did } }, + .{ .key = "rev", .value = .{ .text = rev } }, + .{ .key = "blocks", .value = .{ .bytes = tampered_car } }, + } }; + const result = v.validateSync(payload); + + try std.testing.expect(!result.valid); + try std.testing.expect(!result.skipped); + try std.testing.expectEqual(@as(u64, 1), stats.failed.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), stats.failed_commit_integrity.load(.acquire)); + try std.testing.expectEqual(@as(u64, 0), stats.failed_signature.load(.acquire)); + try std.testing.expectEqual(@as(u64, 0), stats.skipped.load(.acquire)); + try std.testing.expect(v.cache.contains(did)); +} + test "regression: sig-only verification returns null data_cid" { // with verify_commit_diff off (prod default), verification can only recover // the commit CID — not the MST root. returning it as data_cid poisoned the