//! relay frame validator — DID key resolution + real signature verification //! //! validates firehose commit frames by verifying the commit signature against //! the pre-resolved signing key for the DID. accepts pre-decoded CBOR payload //! from the subscriber (decoded via zat SDK). on cache miss, skips validation //! and queues background resolution. no frame is ever blocked on network I/O. const std = @import("std"); const Io = std.Io; const zat = @import("zat"); const broadcaster = @import("broadcaster.zig"); const event_log_mod = @import("event_log.zig"); const lru = @import("util/lru.zig"); const util = @import("util/util.zig"); const atproto = @import("atproto/main.zig"); const Allocator = std.mem.Allocator; const log = std.log.scoped(.relay); const parseEnvInt = util.parseEnvInt; const timestamp = util.timestamp; const microTimestamp = util.microTimestamp; const extractHostFromUrl = atproto.extractHostFromUrl; const HostAuthorityCache = struct { const Key = struct { did: []const u8, incoming_host_id: u64, }; const KeyContext = struct { pub fn hash(_: KeyContext, key: Key) u64 { var h = std.hash.Wyhash.init(0); h.update(key.did); h.update(std.mem.asBytes(&key.incoming_host_id)); return h.final(); } pub fn eql(_: KeyContext, a: Key, b: Key) bool { return a.incoming_host_id == b.incoming_host_id and std.mem.eql(u8, a.did, b.did); } }; const Map = std.HashMapUnmanaged(Key, i64, KeyContext, 80); const recheck_interval_seconds: i64 = 60; const default_capacity: u32 = 8192; allocator: Allocator, io: Io, map: Map = .empty, capacity: u32, mutex: Io.Mutex = Io.Mutex.init, fn init(allocator: Allocator, io: Io, capacity: u32) HostAuthorityCache { return .{ .allocator = allocator, .io = io, .capacity = capacity, }; } fn deinit(self: *HostAuthorityCache) void { self.clearLocked(); self.map.deinit(self.allocator); } fn recentlyRejected(self: *HostAuthorityCache, did: []const u8, incoming_host_id: u64) bool { return self.recentlyRejectedAt(did, incoming_host_id, self.now()); } fn recentlyRejectedAt(self: *HostAuthorityCache, did: []const u8, incoming_host_id: u64, current_time: i64) bool { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); const key: Key = .{ .did = did, .incoming_host_id = incoming_host_id }; const checked_at = self.map.getContext(key, .{}) orelse return false; if (current_time >= checked_at and current_time - checked_at < recheck_interval_seconds) return true; if (self.map.fetchRemoveContext(key, .{})) |removed| { self.allocator.free(removed.key.did); } return false; } fn remember(self: *HostAuthorityCache, did: []const u8, incoming_host_id: u64) bool { return self.rememberAt(did, incoming_host_id, self.now()); } /// Returns true only for the first confirmed mismatch in the current TTL /// window. Concurrent duplicate network checks update the same entry. fn rememberAt(self: *HostAuthorityCache, did: []const u8, incoming_host_id: u64, current_time: i64) bool { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); const lookup: Key = .{ .did = did, .incoming_host_id = incoming_host_id }; if (self.map.getPtrContext(lookup, .{})) |checked_at| { checked_at.* = current_time; return false; } if (self.map.count() >= self.capacity) { self.pruneExpiredLocked(current_time); if (self.map.count() >= self.capacity) self.clearLocked(); } const owned_did = self.allocator.dupe(u8, did) catch return true; const owned_key: Key = .{ .did = owned_did, .incoming_host_id = incoming_host_id }; self.map.putContext(self.allocator, owned_key, current_time, .{}) catch { self.allocator.free(owned_did); return true; }; return true; } fn forget(self: *HostAuthorityCache, did: []const u8, incoming_host_id: u64) void { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); const key: Key = .{ .did = did, .incoming_host_id = incoming_host_id }; if (self.map.fetchRemoveContext(key, .{})) |removed| { self.allocator.free(removed.key.did); } } fn count(self: *HostAuthorityCache) u32 { if (!self.mutex.tryLock()) return 0; defer self.mutex.unlock(self.io); return self.map.count(); } fn pruneExpiredLocked(self: *HostAuthorityCache, current_time: i64) void { var expired: std.ArrayListUnmanaged(Key) = .empty; defer expired.deinit(self.allocator); var it = self.map.iterator(); while (it.next()) |entry| { const checked_at = entry.value_ptr.*; if (current_time < checked_at or current_time - checked_at >= recheck_interval_seconds) { expired.append(self.allocator, entry.key_ptr.*) catch { self.clearLocked(); return; }; } } for (expired.items) |key| { if (self.map.fetchRemoveContext(key, .{})) |removed| { self.allocator.free(removed.key.did); } } } fn clearLocked(self: *HostAuthorityCache) void { var it = self.map.keyIterator(); while (it.next()) |key| self.allocator.free(key.did); self.map.clearRetainingCapacity(); } fn now(self: *HostAuthorityCache) i64 { return Io.Timestamp.now(self.io, .awake).toSeconds(); } }; /// decoded and cached signing key for a DID const CachedKey = struct { key_type: zat.multicodec.KeyType, raw: [33]u8, // compressed public key (secp256k1 or p256) len: u8, resolve_time: i64 = 0, // epoch seconds when resolved }; pub const ValidationResult = struct { valid: bool, skipped: bool, // MST root CID from verified commit. only set by the verify_commit_diff // path — sig-only verification can't recover the MST root (verifyCommitCar // exposes the commit CID, which is a different block), so it returns null // rather than poison the prevData chain-continuity check downstream. data_cid: ?[]const u8 = null, commit_rev: ?[]const u8 = null, // rev from verified commit }; /// configuration for commit validation checks 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 = true, /// max allowed operations per commit max_ops: usize = 200, /// max clock skew for rev timestamps (seconds) rev_clock_skew: i64 = 300, // 5 minutes }; pub const Validator = struct { allocator: Allocator, stats: *broadcaster.Stats, config: ValidatorConfig, persist: ?*event_log_mod.DiskPersist = null, // DID → signing key cache (decoded, ready for verification) cache: lru.LruCache(CachedKey), // background resolve queue queue: std.ArrayListUnmanaged([]const u8) = .empty, // in-flight set — prevents duplicate DID entries in the queue queued_set: std.StringHashMapUnmanaged(void) = .empty, queue_mutex: Io.Mutex = Io.Mutex.init, queue_cond: Io.Condition = Io.Condition.init, resolver_futures: [max_resolver_threads]?Io.Future(void) = .{null} ** max_resolver_threads, alive: std.atomic.Value(bool) = .{ .raw = true }, max_cache_size: u32 = 250_000, io: Io, // pool of reusable resolvers for inline host authority and signing-key refreshes. // frame workers acquire/release via atomic flag to avoid creating // a fresh resolver (and fresh TLS handshake) per call. host_resolvers: [host_resolver_pool_size]zat.DidResolver = undefined, host_resolver_available: [host_resolver_pool_size]std.atomic.Value(bool) = .{std.atomic.Value(bool){ .raw = false }} ** host_resolver_pool_size, host_resolver_inited: bool = false, host_authority_cache: HostAuthorityCache, const max_resolver_threads = 8; const default_resolver_threads = 4; const max_queue_size: usize = 100_000; const host_resolver_pool_size: usize = 4; const signature_refresh_min_interval: i64 = 60; pub fn init(allocator: Allocator, stats: *broadcaster.Stats, io: Io) Validator { return initWithConfig(allocator, stats, .{}, io); } pub fn initWithConfig(allocator: Allocator, stats: *broadcaster.Stats, config: ValidatorConfig, io: Io) Validator { return .{ .allocator = allocator, .stats = stats, .config = config, .cache = lru.LruCache(CachedKey).init(allocator, 250_000, io), .host_authority_cache = HostAuthorityCache.init(allocator, io, HostAuthorityCache.default_capacity), .io = io, }; } pub fn deinit(self: *Validator) void { self.alive.store(false, .release); self.queue_cond.broadcast(self.io); for (&self.resolver_futures) |*slot| { if (slot.*) |*f| { f.cancel(self.io); } slot.* = null; } if (self.host_resolver_inited) { for (&self.host_resolvers) |*r| { r.deinit(); } self.host_resolver_inited = false; } self.cache.deinit(); self.host_authority_cache.deinit(); // free queued DIDs for (self.queue.items) |did| { self.allocator.free(did); } self.queue.deinit(self.allocator); self.queued_set.deinit(self.allocator); } /// start background resolver threads and host authority resolver pool pub fn start(self: *Validator) !void { self.max_cache_size = parseEnvInt(u32, "VALIDATOR_CACHE_SIZE", self.max_cache_size); self.cache.capacity = self.max_cache_size; const n = parseEnvInt(u8, "RESOLVER_THREADS", default_resolver_threads); const count = @min(n, max_resolver_threads); for (self.resolver_futures[0..count]) |*slot| { slot.* = try self.io.concurrent(resolveLoop, .{self}); } // init host authority resolver pool (reused across calls) for (&self.host_resolvers) |*r| { r.* = zat.DidResolver.initWithOptions(self.io, self.allocator, .{}); } for (&self.host_resolver_available) |*a| { a.store(true, .release); } self.host_resolver_inited = true; } /// validate a #sync frame: signature verification only (no ops, no MST). /// #sync resets a repo to a new commit state — used for recovery from broken streams. /// on cache miss, queues background resolution and skips. pub fn validateSync(self: *Validator, payload: zat.cbor.Value) ValidationResult { const did = payload.getString("did") orelse { _ = self.stats.skipped.fetchAdd(1, .monotonic); return .{ .valid = true, .skipped = true }; }; if (zat.Did.parse(did) == null) { _ = self.stats.failed.fetchAdd(1, .monotonic); _ = self.stats.failed_bad_did.fetchAdd(1, .monotonic); return .{ .valid = false, .skipped = false }; } // check rev is valid TID (if present) if (payload.getString("rev")) |rev| { if (zat.Tid.parse(rev) == null) { _ = self.stats.failed.fetchAdd(1, .monotonic); _ = self.stats.failed_bad_rev.fetchAdd(1, .monotonic); return .{ .valid = false, .skipped = false }; } } const blocks = payload.getBytes("blocks") orelse { _ = self.stats.failed.fetchAdd(1, .monotonic); _ = self.stats.failed_missing_blocks.fetchAdd(1, .monotonic); return .{ .valid = false, .skipped = false }; }; // #sync CAR should be small (just the signed commit block) // lexicon maxLength: 10000 if (blocks.len > 10_000) { _ = self.stats.failed.fetchAdd(1, .monotonic); _ = self.stats.failed_oversized_blocks.fetchAdd(1, .monotonic); return .{ .valid = false, .skipped = false }; } // cache lookup const cached_key: ?CachedKey = self.cache.get(did); if (cached_key == null) { _ = self.stats.cache_misses.fetchAdd(1, .monotonic); _ = self.stats.skipped.fetchAdd(1, .monotonic); self.queueResolve(did); return .{ .valid = true, .skipped = true }; } _ = self.stats.cache_hits.fetchAdd(1, .monotonic); // verify signature (no MST, no ops) const public_key = zat.multicodec.PublicKey{ .key_type = cached_key.?.key_type, .raw = cached_key.?.raw[0..cached_key.?.len], }; var arena = std.heap.ArenaAllocator.init(self.allocator); defer arena.deinit(); const result = zat.verifyCommitCar(arena.allocator(), blocks, public_key, .{ .verify_mst = false, .expected_did = did, .max_car_size = 10 * 1024, }) catch |err| { log.debug("sync verification failed for {s}: {s}", .{ did, @errorName(err) }); if (err == error.SignatureVerificationFailed) { if (self.refreshSigningKey(did, cached_key.?)) |fresh_key| { const fresh_public_key = zat.multicodec.PublicKey{ .key_type = fresh_key.key_type, .raw = fresh_key.raw[0..fresh_key.len], }; const retry = zat.verifyCommitCar(arena.allocator(), blocks, fresh_public_key, .{ .verify_mst = false, .expected_did = did, .max_car_size = 10 * 1024, }) catch |retry_err| { log.debug("sync verification failed after key refresh for {s}: {s}", .{ did, @errorName(retry_err) }); return self.rejectSignature(); }; _ = self.stats.validated.fetchAdd(1, .monotonic); const loaded = zat.loadCommitFromCAR(arena.allocator(), blocks) catch return self.rejectCommitIntegrity(); return .{ .valid = true, .skipped = false, .data_cid = loaded.commit.data_cid, .commit_rev = retry.commit_rev, }; } return self.rejectSignature(); } return self.rejectCommitIntegrity(); }; _ = self.stats.validated.fetchAdd(1, .monotonic); const loaded = zat.loadCommitFromCAR(arena.allocator(), blocks) catch return self.rejectCommitIntegrity(); return .{ .valid = true, .skipped = false, .data_cid = loaded.commit.data_cid, .commit_rev = result.commit_rev, }; } /// validate a commit frame using a pre-decoded CBOR payload (from SDK decoder). /// on cache miss, queues background resolution and skips. pub fn validateCommit(self: *Validator, payload: zat.cbor.Value) ValidationResult { // extract DID from decoded payload const did = payload.getString("repo") orelse { _ = self.stats.skipped.fetchAdd(1, .monotonic); return .{ .valid = true, .skipped = true }; }; // check cache for pre-resolved signing key const cached_key: ?CachedKey = self.cache.get(did); if (cached_key == null) { // cache miss — queue for background resolution, skip validation _ = self.stats.cache_misses.fetchAdd(1, .monotonic); _ = self.stats.skipped.fetchAdd(1, .monotonic); self.queueResolve(did); return .{ .valid = true, .skipped = true }; } _ = self.stats.cache_hits.fetchAdd(1, .monotonic); // cache hit — do structure checks + signature verification if (self.verifyCommit(payload, did, cached_key.?)) |vr| { _ = self.stats.validated.fetchAdd(1, .monotonic); return vr; } else |err| { log.debug("commit verification failed for {s}: {s}", .{ did, @errorName(err) }); if (err == error.SignatureVerificationFailed) { if (self.refreshSigningKey(did, cached_key.?)) |fresh_key| { if (self.verifyCommit(payload, did, fresh_key)) |vr| { _ = self.stats.validated.fetchAdd(1, .monotonic); return vr; } else |retry_err| { log.debug("commit verification failed after key refresh for {s}: {s}", .{ did, @errorName(retry_err) }); } } 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(); } } fn rejectSignature(self: *Validator) ValidationResult { _ = self.stats.failed.fetchAdd(1, .monotonic); _ = self.stats.failed_signature.fetchAdd(1, .monotonic); 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 }; } /// Once a relay holds prior repo state, prevData is required to make the /// commit diff an inductive proof. A present-but-wrong value is rejected by /// MST inversion; this handles omission/null before the expensive verify. pub fn validatePrevDataPresence(self: *Validator, payload: zat.cbor.Value) bool { if (!self.config.verify_commit_diff) return true; if (payload.get("prevData")) |prev_data| { if (prev_data == .cid) return true; } // counted apart from sync_1_1: an omitted prevData is a sender that // predates Sync 1.1, whereas a sync_1_1 failure means a proof did not // invert — which can equally mean our verifier is wrong. _ = self.stats.failed.fetchAdd(1, .monotonic); _ = self.stats.failed_prev_data_missing.fetchAdd(1, .monotonic); _ = self.stats.chain_breaks_prev_data.fetchAdd(1, .monotonic); return 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. fn refreshSigningKey(self: *Validator, did: []const u8, cached_key: CachedKey) ?CachedKey { const now = timestamp(self.io); if (now - cached_key.resolve_time < signature_refresh_min_interval) return null; if (!self.host_resolver_inited) return null; // Record the attempt before network I/O. If resolution fails, retain the // known key and wait before trying the directory again. var attempted_key = cached_key; attempted_key.resolve_time = now; self.cache.put(did, attempted_key) catch return null; const parsed = zat.Did.parse(did) orelse return null; const idx = self.acquireHostResolver(); defer self.releaseHostResolver(idx); var doc = self.host_resolvers[idx].resolve(parsed) catch return null; defer doc.deinit(); const vm = doc.signingKey() orelse return null; const key_bytes = zat.multibase.decode(self.allocator, vm.public_key_multibase) catch return null; defer self.allocator.free(key_bytes); const public_key = zat.multicodec.parsePublicKey(key_bytes) catch return null; var fresh = CachedKey{ .key_type = public_key.key_type, .raw = undefined, .len = @intCast(public_key.raw.len), .resolve_time = now, }; @memcpy(fresh.raw[0..public_key.raw.len], public_key.raw); self.cache.put(did, fresh) catch return null; return fresh; } fn verifyCommit(self: *Validator, payload: zat.cbor.Value, expected_did: []const u8, cached_key: CachedKey) !ValidationResult { // commit structure checks first (cheap, no allocation) self.checkCommitStructure(payload) catch { _ = self.stats.failed_bad_structure.fetchAdd(1, .monotonic); return error.InvalidFrame; }; // extract blocks (raw CAR bytes) from the pre-decoded payload const blocks = payload.getBytes("blocks") orelse return error.InvalidFrame; // blocks size check — lexicon maxLength: 2000000 if (blocks.len > 2_000_000) return error.InvalidFrame; // build public key for verification const public_key = zat.multicodec.PublicKey{ .key_type = cached_key.key_type, .raw = cached_key.raw[0..cached_key.len], }; // run real signature verification (needs its own arena for CAR/MST temporaries) var arena = std.heap.ArenaAllocator.init(self.allocator); defer arena.deinit(); const alloc = arena.allocator(); // 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) { 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 = 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) const result = zat.verifyCommitCar(alloc, blocks, public_key, .{ .verify_mst = self.config.verify_mst, .expected_did = expected_did, }) catch |err| { return err; }; return .{ .valid = true, .skipped = false, .commit_rev = result.commit_rev, }; } /// 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 { _ = self; 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 return error.InvalidCommitDiff; const path = op.getString("path") orelse return error.InvalidCommitDiff; // validate path contains "/" (collection/rkey) 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) { .cid => |c| c.raw, else => null, } else null; var value: ?[]const u8 = null; var prev: ?[]const u8 = null; if (std.mem.eql(u8, action, "create")) { value = cid_value orelse return error.InvalidCommitDiff; } else if (std.mem.eql(u8, action, "update")) { 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; if (prev == null) return error.InvalidCommitDiff; } else return error.InvalidCommitDiff; try ops.append(alloc, .{ .path = path, .value = value, .prev = prev, }); } return ops.items; } fn checkCommitStructure(self: *Validator, payload: zat.cbor.Value) !void { // check repo field is a valid DID const repo = payload.getString("repo") orelse return error.InvalidFrame; if (zat.Did.parse(repo) == null) return error.InvalidFrame; // check rev is a valid TID if (payload.getString("rev")) |rev| { if (zat.Tid.parse(rev) == null) return error.InvalidFrame; } // check ops count if (payload.get("ops")) |ops_value| { switch (ops_value) { .array => |ops| { if (ops.len > self.config.max_ops) return error.InvalidFrame; // validate each op has valid path (collection/rkey) for (ops) |op| { if (op.getString("path")) |path| { if (std.mem.indexOfScalar(u8, path, '/')) |sep| { const collection = path[0..sep]; const rkey = path[sep + 1 ..]; if (zat.Nsid.parse(collection) == null) return error.InvalidFrame; if (rkey.len > 0) { if (zat.Rkey.parse(rkey) == null) return error.InvalidFrame; } } else return error.InvalidFrame; // path must contain '/' } } }, else => return error.InvalidFrame, } } } fn queueResolve(self: *Validator, did: []const u8) void { // check if already cached (race between validate and resolver) if (self.cache.contains(did)) return; const duped = self.allocator.dupe(u8, did) catch return; self.queue_mutex.lockUncancelable(self.io); defer self.queue_mutex.unlock(self.io); // skip if already queued (prevents unbounded queue growth) if (self.queued_set.contains(duped)) { self.allocator.free(duped); return; } // cap queue size — drop DID without adding to queued_set so it can be re-queued later if (self.queue.items.len >= max_queue_size) { self.allocator.free(duped); return; } self.queue.append(self.allocator, duped) catch { self.allocator.free(duped); return; }; self.queued_set.put(self.allocator, duped, {}) catch {}; self.queue_cond.signal(self.io); } fn resolveLoop(self: *Validator) void { var resolver = zat.DidResolver.initWithOptions(self.io, self.allocator, .{ .keep_alive = true }); defer resolver.deinit(); while (self.alive.load(.acquire)) { var did: ?[]const u8 = null; { self.queue_mutex.lockUncancelable(self.io); defer self.queue_mutex.unlock(self.io); while (self.queue.items.len == 0 and self.alive.load(.acquire)) { self.queue_cond.waitUncancelable(self.io, &self.queue_mutex); } if (self.queue.items.len > 0) { did = self.queue.orderedRemove(0); _ = self.queued_set.remove(did.?); } } const d = did orelse continue; defer self.allocator.free(d); // skip if already cached (resolved while queued) if (self.cache.contains(d)) continue; // resolve DID → signing key const parsed = zat.Did.parse(d) orelse continue; var doc = resolver.resolve(parsed) catch |err| { log.debug("DID resolve failed for {s}: {s}", .{ d, @errorName(err) }); continue; }; defer doc.deinit(); // extract and decode signing key const vm = doc.signingKey() orelse continue; const key_bytes = zat.multibase.decode(self.allocator, vm.public_key_multibase) catch continue; defer self.allocator.free(key_bytes); const public_key = zat.multicodec.parsePublicKey(key_bytes) catch continue; // store decoded key in cache (fixed-size, no pointer chasing) var cached = CachedKey{ .key_type = public_key.key_type, .raw = undefined, .len = @intCast(public_key.raw.len), .resolve_time = timestamp(self.io), }; @memcpy(cached.raw[0..public_key.raw.len], public_key.raw); self.cache.put(d, cached) catch continue; // --- host validation (merged from migration queue) --- // while we have the DID doc, check PDS endpoint and update host if needed. // best-effort: failures don't prevent signing key caching. if (self.persist) |persist| { if (doc.pdsEndpoint()) |pds_endpoint| { if (extractHostFromUrl(pds_endpoint)) |pds_host| { const pds_host_id = (persist.getHostIdForHostname(pds_host) catch null) orelse continue; const uid = persist.uidForDid(d) catch continue; const current_host = persist.getAccountHostId(uid) catch continue; if (current_host != 0 and current_host != pds_host_id) { persist.setAccountHostId(uid, pds_host_id) catch {}; log.info("host updated via DID doc: {s} -> host {d}", .{ d, pds_host_id }); } } } } } } /// seed a DID's signing key directly, bypassing DID-document resolution. /// the conformance harness needs the corpus keypair in the cache so the /// commit path takes the cache-hit branch instead of skipping on a miss. pub fn seedSigningKey( self: *Validator, did: []const u8, public_key: zat.multicodec.PublicKey, resolve_time: i64, ) !void { if (public_key.raw.len > 33) return error.KeyTooLong; var cached: CachedKey = .{ .key_type = public_key.key_type, .raw = undefined, .len = @intCast(public_key.raw.len), .resolve_time = resolve_time, }; @memcpy(cached.raw[0..public_key.raw.len], public_key.raw); try self.cache.put(did, cached); } /// evict a DID's cached signing key (e.g. on #identity event). /// the next commit from this DID will trigger a fresh resolution. pub fn evictKey(self: *Validator, did: []const u8) void { _ = self.cache.remove(did); } /// cache size (for diagnostics) pub fn cacheSize(self: *Validator) u32 { return self.cache.count(); } /// resolve queue length (for diagnostics — non-blocking) pub fn resolveQueueLen(self: *Validator) usize { if (!self.queue_mutex.tryLock()) return 0; defer self.queue_mutex.unlock(self.io); return self.queue.items.len; } /// resolve dedup set size (for diagnostics — non-blocking) pub fn resolveQueuedSetCount(self: *Validator) u32 { if (!self.queue_mutex.tryLock()) return 0; defer self.queue_mutex.unlock(self.io); return self.queued_set.count(); } /// signing key cache hashmap backing capacity (for memory attribution) pub fn cacheMapCapacity(self: *Validator) u32 { return self.cache.mapCapacity(); } pub fn hostAuthorityCacheSize(self: *Validator) u32 { return self.host_authority_cache.count(); } /// resolver dedup set hashmap backing capacity (for memory attribution — non-blocking) pub fn resolveQueuedSetCapacity(self: *Validator) u32 { if (!self.queue_mutex.tryLock()) return 0; defer self.queue_mutex.unlock(self.io); return self.queued_set.capacity(); } pub const HostAuthority = enum { accept, migrate, reject }; /// synchronous host authority check. called on first-seen DIDs (is_new) /// and host migrations (host_changed). resolves the DID doc to verify the /// PDS endpoint matches the incoming host. retries once on failure to /// handle transient network errors. /// /// uses a pooled resolver to avoid creating a fresh resolver (and fresh /// TLS handshake) per call. blocks briefly if all pool slots are in use. /// /// returns: /// .accept — should not happen (caller should only call on new/mismatch) /// .migrate — DID doc confirms this host, caller should update host_id /// .reject — DID doc does not confirm, caller should drop the event pub fn resolveHostAuthority( self: *Validator, did: []const u8, incoming_host_id: u64, incoming_host: []const u8, ) HostAuthority { const persist = self.persist orelse return .migrate; // no DB — can't check const parsed = zat.Did.parse(did) orelse { _ = self.stats.host_authority_reject_parse_did.fetchAdd(1, .monotonic); return .reject; }; if (self.host_authority_cache.recentlyRejected(did, incoming_host_id)) { _ = self.stats.host_authority_cache_hits.fetchAdd(1, .monotonic); return .reject; } _ = self.stats.host_authority_network_checks.fetchAdd(1, .monotonic); const network_t0 = microTimestamp(self.io); defer { const elapsed: u64 = @intCast(@max(0, microTimestamp(self.io) - network_t0)); _ = self.stats.host_authority_network_time_us.fetchAdd(elapsed, .monotonic); } const idx = self.acquireHostResolver(); defer self.releaseHostResolver(idx); var resolver = &self.host_resolvers[idx]; // first resolve attempt var doc = resolver.resolve(parsed) catch { // retry once on network failure var doc2 = resolver.resolve(parsed) catch { _ = self.stats.host_authority_reject_resolve.fetchAdd(1, .monotonic); return .reject; }; defer doc2.deinit(); return self.checkPdsHost(&doc2, persist, did, incoming_host_id, incoming_host); }; defer doc.deinit(); return self.checkPdsHost(&doc, persist, did, incoming_host_id, incoming_host); } /// acquire a resolver from the pool. spins until one is available. fn acquireHostResolver(self: *Validator) usize { while (self.alive.load(.acquire)) { for (0..host_resolver_pool_size) |i| { if (self.host_resolver_available[i].cmpxchgStrong(true, false, .acquire, .monotonic) == null) { return i; } } self.io.sleep(Io.Duration.fromMilliseconds(1), .awake) catch {}; } return 0; // shutdown path — caller will exit soon } fn releaseHostResolver(self: *Validator, idx: usize) void { self.host_resolver_available[idx].store(true, .release); } fn checkPdsHost( self: *Validator, doc: *zat.DidDocument, persist: *event_log_mod.DiskPersist, did: []const u8, incoming_host_id: u64, incoming_host: []const u8, ) HostAuthority { const pds_endpoint = doc.pdsEndpoint() orelse { _ = self.stats.host_authority_reject_no_endpoint.fetchAdd(1, .monotonic); self.sampleLogReject("no_endpoint", did, "", incoming_host_id, 0); return .reject; }; const pds_host = extractHostFromUrl(pds_endpoint) orelse { _ = self.stats.host_authority_reject_bad_url.fetchAdd(1, .monotonic); self.sampleLogReject("bad_url", did, pds_endpoint, incoming_host_id, 0); return .reject; }; const pds_host_id = (persist.getHostIdForHostname(pds_host) catch null) orelse { _ = self.stats.host_authority_reject_unknown_host.fetchAdd(1, .monotonic); self.sampleLogReject("unknown_host", did, pds_host, incoming_host_id, 0); return .reject; }; if (pds_host_id == incoming_host_id) { self.host_authority_cache.forget(did, incoming_host_id); return .migrate; } const mismatch_index = self.stats.host_authority_reject_host_mismatch.fetchAdd(1, .monotonic) + 1; const first_occurrence = self.host_authority_cache.remember(did, incoming_host_id); if (first_occurrence) { self.sampleLogHostMismatch(mismatch_index, did, incoming_host, pds_host, incoming_host_id, pds_host_id); } return .reject; } /// Log the first network-confirmed mismatch and then one in every 128. /// Cache hits are intentionally silent: this is visibility into distinct /// authority decisions without recreating the wrong-host log storm. fn sampleLogHostMismatch( _: *Validator, mismatch_index: u64, did: []const u8, incoming_host: []const u8, expected_host: []const u8, incoming_host_id: u64, expected_host_id: u64, ) void { if (mismatch_index != 1 and (mismatch_index & 0x7f) != 0) return; log.warn( "host_authority mismatch did={s} incoming_host={s} incoming_host_id={d} expected_host={s} expected_host_id={d}", .{ did, incoming_host, incoming_host_id, expected_host, expected_host_id }, ); } /// log a rejection sample at 1-in-2048 rate. at ~10 rejections/sec that's /// one log line every ~3.5min. total rejections per branch are available /// via relay_host_authority_reject{branch=...} in prometheus. fn sampleLogReject( self: *Validator, branch: []const u8, did: []const u8, detail: []const u8, incoming_host_id: u64, resolved_host_id: u64, ) void { const count = self.stats.failed_host_authority.load(.monotonic); // parens mandatory: `&` and `!=` precedence differs from C in zig. if ((count & 0x7ff) != 0) return; log.warn( "host_authority reject branch={s} did={s} detail={s} incoming_host_id={d} resolved_host_id={d}", .{ branch, did, detail, incoming_host_id, resolved_host_id }, ); } }; // --- tests --- test "host authority cache keys by DID and incoming host and expires at 60 seconds" { var cache = HostAuthorityCache.init(std.testing.allocator, std.testing.io, 8); defer cache.deinit(); try std.testing.expect(cache.rememberAt("did:plc:alice", 7, 100)); try std.testing.expect(cache.recentlyRejectedAt("did:plc:alice", 7, 100)); try std.testing.expect(cache.recentlyRejectedAt("did:plc:alice", 7, 159)); try std.testing.expect(!cache.recentlyRejectedAt("did:plc:alice", 8, 159)); try std.testing.expect(!cache.recentlyRejectedAt("did:plc:bob", 7, 159)); try std.testing.expect(!cache.recentlyRejectedAt("did:plc:alice", 7, 160)); try std.testing.expectEqual(@as(u32, 0), cache.count()); } test "host authority cache clears a mismatch after authority succeeds" { var cache = HostAuthorityCache.init(std.testing.allocator, std.testing.io, 8); defer cache.deinit(); try std.testing.expect(cache.rememberAt("did:plc:alice", 7, 100)); try std.testing.expect(cache.recentlyRejectedAt("did:plc:alice", 7, 120)); cache.forget("did:plc:alice", 7); try std.testing.expect(!cache.recentlyRejectedAt("did:plc:alice", 7, 120)); } test "host authority cache prunes expired entries and remains bounded" { var cache = HostAuthorityCache.init(std.testing.allocator, std.testing.io, 3); defer cache.deinit(); _ = cache.rememberAt("did:plc:expired-a", 1, 0); _ = cache.rememberAt("did:plc:expired-b", 1, 0); _ = cache.rememberAt("did:plc:fresh", 1, 100); _ = cache.rememberAt("did:plc:new", 1, 100); try std.testing.expectEqual(@as(u32, 2), cache.count()); try std.testing.expect(!cache.recentlyRejectedAt("did:plc:expired-a", 1, 100)); try std.testing.expect(cache.recentlyRejectedAt("did:plc:fresh", 1, 100)); try std.testing.expect(cache.recentlyRejectedAt("did:plc:new", 1, 100)); _ = cache.rememberAt("did:plc:third", 1, 100); _ = cache.rememberAt("did:plc:overflow", 1, 100); try std.testing.expectEqual(@as(u32, 1), cache.count()); try std.testing.expect(!cache.recentlyRejectedAt("did:plc:fresh", 1, 100)); try std.testing.expect(cache.recentlyRejectedAt("did:plc:overflow", 1, 100)); } test "host authority cache handles a 16-worker cached mismatch storm without IO" { const Worker = struct { fn run(cache: *HostAuthorityCache, misses: *std.atomic.Value(u64)) void { for (0..10_000) |_| { if (!cache.recentlyRejectedAt("did:plc:wrong-host", 42, 120)) { _ = misses.fetchAdd(1, .monotonic); } } } }; var cache = HostAuthorityCache.init(std.testing.allocator, std.testing.io, 8192); defer cache.deinit(); _ = cache.rememberAt("did:plc:wrong-host", 42, 100); var misses: std.atomic.Value(u64) = .{ .raw = 0 }; var threads: [16]std.Thread = undefined; for (&threads) |*thread| { thread.* = try std.Thread.spawn(.{}, Worker.run, .{ &cache, &misses }); } for (&threads) |*thread| thread.join(); try std.testing.expectEqual(@as(u64, 0), misses.load(.acquire)); try std.testing.expectEqual(@as(u32, 1), cache.count()); } test "validateCommit skips on cache miss" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // build a commit payload using SDK const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "seq", .value = .{ .unsigned = 42 } }, .{ .key = "rev", .value = .{ .text = "3k2abc000000" } }, .{ .key = "time", .value = .{ .text = "2024-01-15T10:30:00Z" } }, } }; 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.cache_misses.load(.acquire)); } test "validateCommit skips when no repo field" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // payload without "repo" field const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "seq", .value = .{ .unsigned = 42 } }, } }; 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.skipped.load(.acquire)); } test "checkCommitStructure rejects invalid DID" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "not-a-did" } }, } }; try std.testing.expectError(error.InvalidFrame, v.checkCommitStructure(payload)); } test "checkCommitStructure accepts valid commit" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, } }; try v.checkCommitStructure(payload); } test "validateSync skips on cache miss" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "did", .value = .{ .text = "did:plc:test123" } }, .{ .key = "seq", .value = .{ .unsigned = 42 } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, .{ .key = "blocks", .value = .{ .bytes = "deadbeef" } }, } }; 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.cache_misses.load(.acquire)); } test "validateSync rejects invalid DID" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "did", .value = .{ .text = "not-a-did" } }, .{ .key = "blocks", .value = .{ .bytes = "deadbeef" } }, } }; 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)); } test "validateSync rejects missing blocks" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "did", .value = .{ .text = "did:plc:test123" } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, } }; 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)); } test "validateSync skips when no did field" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "seq", .value = .{ .unsigned = 42 } }, } }; 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.skipped.load(.acquire)); } test "LRU cache evicts least recently used" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); v.cache.capacity = 3; defer v.deinit(); const mk = CachedKey{ .key_type = .p256, .raw = .{0} ** 33, .len = 33 }; try v.cache.put("did:plc:aaa", mk); try v.cache.put("did:plc:bbb", mk); try v.cache.put("did:plc:ccc", mk); // access "aaa" to promote it _ = v.cache.get("did:plc:aaa"); // insert "ddd" — should evict "bbb" (LRU) try v.cache.put("did:plc:ddd", mk); try std.testing.expect(v.cache.get("did:plc:bbb") == null); try std.testing.expect(v.cache.get("did:plc:aaa") != null); try std.testing.expect(v.cache.get("did:plc:ccc") != null); try std.testing.expect(v.cache.get("did:plc:ddd") != null); try std.testing.expectEqual(@as(u32, 3), v.cache.count()); } test "checkCommitStructure rejects too many ops" { var stats = broadcaster.Stats{}; var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .max_ops = 2 }, std.testing.io); defer v.deinit(); // build ops array with 3 items (over limit of 2) const ops = [_]zat.cbor.Value{ .{ .map = &.{.{ .key = "action", .value = .{ .text = "create" } }} }, .{ .map = &.{.{ .key = "action", .value = .{ .text = "create" } }} }, .{ .map = &.{.{ .key = "action", .value = .{ .text = "create" } }} }, }; const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "ops", .value = .{ .array = &ops } }, } }; try std.testing.expectError(error.InvalidFrame, v.checkCommitStructure(payload)); } // --- spec conformance tests --- test "spec: #commit blocks > 2,000,000 bytes rejected" { // lexicon maxLength for #commit blocks: 2,000,000 var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // insert a fake cached key so we reach the blocks size check const did = "did:plc:test123"; try v.cache.put(did, .{ .key_type = .p256, .raw = .{0} ** 33, .len = 33, .resolve_time = 100, }); // blocks with 2,000,001 bytes (1 byte over limit) const oversized_blocks = try std.testing.allocator.alloc(u8, 2_000_001); defer std.testing.allocator.free(oversized_blocks); @memset(oversized_blocks, 0); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = did } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, .{ .key = "blocks", .value = .{ .bytes = oversized_blocks } }, } }; const result = v.validateCommit(payload); try std.testing.expect(!result.valid or result.skipped); } test "spec: #commit blocks = 2,000,000 bytes accepted (boundary)" { // lexicon maxLength for #commit blocks: 2,000,000 — exactly at limit should pass size check var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const did = "did:plc:test123"; try v.cache.put(did, .{ .key_type = .p256, .raw = .{0} ** 33, .len = 33, .resolve_time = 100, }); // exactly 2,000,000 bytes — should pass size check (may fail signature verify, that's ok) const exact_blocks = try std.testing.allocator.alloc(u8, 2_000_000); defer std.testing.allocator.free(exact_blocks); @memset(exact_blocks, 0); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = did } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, .{ .key = "blocks", .value = .{ .bytes = exact_blocks } }, } }; _ = 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" { // lexicon maxLength for #sync blocks: 10,000 var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "did", .value = .{ .text = "did:plc:test123" } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, .{ .key = "blocks", .value = .{ .bytes = &([_]u8{0} ** 10_001) } }, } }; const result = v.validateSync(payload); try std.testing.expect(!result.valid); try std.testing.expect(!result.skipped); } test "spec: #sync blocks = 10,000 bytes accepted (boundary)" { // lexicon maxLength for #sync blocks: 10,000 — exactly at limit should pass size check var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "did", .value = .{ .text = "did:plc:test123" } }, .{ .key = "rev", .value = .{ .text = "3k2abcdefghij" } }, .{ .key = "blocks", .value = .{ .bytes = &([_]u8{0} ** 10_000) } }, } }; const result = v.validateSync(payload); // should pass size check — will be a cache miss → skipped (no cached key) try std.testing.expect(result.valid); try std.testing.expect(result.skipped); } test "extractOps reads path field from firehose format" { var stats = broadcaster.Stats{}; var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = true }, std.testing.io); defer v.deinit(); // use arena since extractOps allocates an ArrayList internally var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const ops = [_]zat.cbor.Value{ .{ .map = &.{ .{ .key = "action", .value = .{ .text = "create" } }, .{ .key = "path", .value = .{ .text = "app.bsky.feed.post/3k2abc000000" } }, .{ .key = "cid", .value = .{ .cid = .{ .raw = "fakecid12345" } } }, } }, .{ .map = &.{ .{ .key = "action", .value = .{ .text = "delete" } }, .{ .key = "path", .value = .{ .text = "app.bsky.feed.like/3k2def000000" } }, .{ .key = "prev", .value = .{ .cid = .{ .raw = "fakecid67890" } } }, } }, }; const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "ops", .value = .{ .array = &ops } }, } }; 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" { var stats = broadcaster.Stats{}; var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = true }, std.testing.io); defer v.deinit(); var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const ops = [_]zat.cbor.Value{ .{ .map = &.{ .{ .key = "action", .value = .{ .text = "create" } }, .{ .key = "path", .value = .{ .text = "noslash" } }, .{ .key = "cid", .value = .{ .cid = .{ .raw = "fakecid12345" } } }, } }, }; const payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "ops", .value = .{ .array = &ops } }, } }; try std.testing.expectError(error.InvalidCommitDiff, v.extractOps(arena.allocator(), payload)); } test "checkCommitStructure validates path field" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // valid path const valid_ops = [_]zat.cbor.Value{ .{ .map = &.{ .{ .key = "action", .value = .{ .text = "create" } }, .{ .key = "path", .value = .{ .text = "app.bsky.feed.post/3k2abc000000" } }, } }, }; const valid_payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "ops", .value = .{ .array = &valid_ops } }, } }; try v.checkCommitStructure(valid_payload); // invalid collection in path const invalid_ops = [_]zat.cbor.Value{ .{ .map = &.{ .{ .key = "action", .value = .{ .text = "create" } }, .{ .key = "path", .value = .{ .text = "not-an-nsid/3k2abc000000" } }, } }, }; const invalid_payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "ops", .value = .{ .array = &invalid_ops } }, } }; try std.testing.expectError(error.InvalidFrame, v.checkCommitStructure(invalid_payload)); } test "queueResolve deduplicates repeated DIDs" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // queue the same DID 100 times for (0..100) |_| { v.queueResolve("did:plc:duplicate"); } // should have exactly 1 entry, not 100 try std.testing.expectEqual(@as(usize, 1), v.queue.items.len); try std.testing.expectEqual(@as(u32, 1), v.queued_set.count()); } test "prior repo state requires prevData" { var stats = broadcaster.Stats{}; var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const missing: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, } }; try std.testing.expect(!v.validatePrevDataPresence(missing)); try std.testing.expectEqual(@as(u64, 1), stats.failed.load(.acquire)); try std.testing.expectEqual(@as(u64, 1), stats.failed_prev_data_missing.load(.acquire)); try std.testing.expectEqual(@as(u64, 1), stats.chain_breaks_prev_data.load(.acquire)); // must NOT be attributable to a proof failure: conflating the two is what // let a verifier bug hide behind expected pre-Sync-1.1 sender behavior try std.testing.expectEqual(@as(u64, 0), stats.failed_sync_1_1.load(.acquire)); try std.testing.expectEqual(@as(u64, 0), stats.failed_commit_integrity.load(.acquire)); const cid = try zat.cbor.Cid.forDagCbor(std.testing.allocator, "prior-root"); defer std.testing.allocator.free(cid.raw); const present: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = "did:plc:test123" } }, .{ .key = "prevData", .value = .{ .cid = cid } }, } }; try std.testing.expect(v.validatePrevDataPresence(present)); } // build a commit CAR with a real signature over the unsigned commit bytes, // so verification reaches the success path (not a sig failure → skip) fn buildSignedCommitCar(a: Allocator, kp: zat.Keypair, did_str: []const u8, rev: []const u8) ![]u8 { const data_cid = try zat.cbor.Cid.forDagCbor(a, "mst-root-placeholder"); const signed = try zat.signCommit(a, .{ .did = did_str, .rev = rev, .data = data_cid, }, &kp); return zat.car.writeAlloc(a, .{ .roots = &.{signed.cid}, .blocks = &.{.{ .cid_raw = signed.cid.raw, .data = signed.bytes }}, }); } 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(); 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 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)); // the other half of the split: a proof that failed to invert is never // reported as a sender that omitted prevData try std.testing.expectEqual(@as(u64, 0), stats.failed_prev_data_missing.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"; const rev = "3k2abcdefghij"; const car_bytes = try buildSignedCommitCar(a, signer, did, rev); const wrong_pubkey = try wrong_signer.publicKey(); try v.cache.put(did, .{ .key_type = .p256, .raw = wrong_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 = car_bytes } }, } }; 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_signature.load(.acquire)); try std.testing.expectEqual(@as(u64, 0), stats.skipped.load(.acquire)); } test "wrong signing key 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 wrong_signer = try zat.Keypair.fromSecretKey(.p256, .{2} ** 32); const did = "did:plc:test123"; const rev = "3k2abcdefghij"; const car_bytes = try buildSignedCommitCar(a, signer, did, rev); const wrong_pubkey = try wrong_signer.publicKey(); try v.cache.put(did, .{ .key_type = .p256, .raw = wrong_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 = car_bytes } }, } }; 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_signature.load(.acquire)); 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: commit and sync return the correct data CID" { // Signature-only #commit verification cannot recover the MST root and must // return null. #sync still contains a signed commit object, so extract its // data field rather than mistaking the commit CID for the MST root. 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 kp = try zat.Keypair.fromSecretKey(.p256, .{1} ** 32); const did = "did:plc:test123"; const rev = "3k2abcdefghij"; const car_bytes = try buildSignedCommitCar(a, kp, did, rev); const loaded = try zat.loadCommitFromCAR(a, car_bytes); const pubkey = try kp.publicKey(); try v.cache.put(did, .{ .key_type = .p256, .raw = pubkey, .len = 33, .resolve_time = 100, }); // #commit path (verifyCommit fallback) const commit_payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "repo", .value = .{ .text = did } }, .{ .key = "rev", .value = .{ .text = rev } }, .{ .key = "blocks", .value = .{ .bytes = car_bytes } }, } }; const commit_result = v.validateCommit(commit_payload); try std.testing.expect(commit_result.valid); try std.testing.expect(!commit_result.skipped); try std.testing.expectEqualStrings(rev, commit_result.commit_rev.?); try std.testing.expect(commit_result.data_cid == null); // #sync path (validateSync) const sync_payload: zat.cbor.Value = .{ .map = &.{ .{ .key = "did", .value = .{ .text = did } }, .{ .key = "rev", .value = .{ .text = rev } }, .{ .key = "blocks", .value = .{ .bytes = car_bytes } }, } }; const sync_result = v.validateSync(sync_payload); try std.testing.expect(sync_result.valid); try std.testing.expect(!sync_result.skipped); try std.testing.expectEqualStrings(rev, sync_result.commit_rev.?); try std.testing.expectEqualSlices(u8, loaded.commit.data_cid, sync_result.data_cid.?); }