From 60eb554c85eff23dd2ab145694479b4ddaa312e3 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Thu, 2 Jul 2026 16:27:58 -0500 Subject: [PATCH] feat: add signCommit for the repo commit produce side zat covered the consume/verify half of a repo commit (verifyCommitCar, verifyCommitDiff, loadCommitFromCAR) but not the produce half. downstream PDS-shaped consumers (zds, zlay) each hand-rolled the canonical did/version:3/data/rev/prev commit, signed it, and computed the CID. signCommit(allocator, CommitParams, *Keypair) -> SignedCommit is the mirror of verifyCommitCar: it builds the canonical unsigned commit, signs the DAG-CBOR bytes, and returns the signed block plus its CID. a round-trip test signs a real MST-backed commit and verifies it through verifyCommitCar. rename internal/repo/repo_verifier.zig -> repo.zig now that the file holds both the produce and verify sides; public export names are unchanged. add a commit-sign-bench build target for the PDS write hot path. Co-Authored-By: Claude Opus 4.8 --- build.zig | 17 +++ scripts/commit_sign_bench.zig | 81 ++++++++++++ .../repo/{repo_verifier.zig => repo.zig} | 123 +++++++++++++++++- src/root.zig | 27 ++-- 4 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 scripts/commit_sign_bench.zig rename src/internal/repo/{repo_verifier.zig => repo.zig} (85%) diff --git a/build.zig b/build.zig index b094163..78d0077 100644 --- a/build.zig +++ b/build.zig @@ -138,6 +138,23 @@ pub fn build(b: *std.Build) void { const bench_step = b.step("bench", "run CBOR codec benchmarks"); bench_step.dependOn(&run_bench.step); + // commit build + sign benchmark (PDS write hot path) + const commit_sign_bench = b.addExecutable(.{ + .name = "commit-sign-bench", + .root_module = b.createModule(.{ + .root_source_file = b.path("scripts/commit_sign_bench.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{.{ .name = "zat", .module = mod }}, + }), + }); + b.installArtifact(commit_sign_bench); + + const run_commit_sign_bench = b.addRunArtifact(commit_sign_bench); + const commit_sign_bench_step = b.step("commit-sign-bench", "benchmark zat.signCommit across key types"); + commit_sign_bench_step.dependOn(&run_commit_sign_bench.step); + // publish-docs script (uses zat to publish docs to ATProto) const publish_docs = b.addExecutable(.{ .name = "publish-docs", diff --git a/scripts/commit_sign_bench.zig b/scripts/commit_sign_bench.zig new file mode 100644 index 0000000..fdb481a --- /dev/null +++ b/scripts/commit_sign_bench.zig @@ -0,0 +1,81 @@ +//! commit build + sign benchmark (PDS write hot path) +//! +//! measures `zat.signCommit`: canonical unsigned-commit encode, ECDSA sign, +//! signed-commit encode, and commit CID computation. this is the producer-side +//! counterpart to the decode/verify benchmarks — the work a PDS does on every +//! record write. +//! +//! run: zig build commit-sign-bench -Doptimize=ReleaseFast + +const std = @import("std"); +const zat = @import("zat"); + +const warmup_iters = 1_000; +const min_iters = 2_000; +const target_ns: u64 = 500_000_000; // ~500ms per bench + +fn clockNs() u64 { + const ts = std.Io.Timestamp.now(std.Options.debug_io, .awake); + return @intCast(ts.nanoseconds); +} + +fn bench(name: []const u8, comptime func: anytype) void { + for (0..warmup_iters) |_| func(); + + var start = clockNs(); + for (0..min_iters) |_| func(); + const calibrate_ns = clockNs() - start; + const iters: u64 = if (calibrate_ns == 0) + min_iters * 100 + else + @max(min_iters, target_ns * min_iters / calibrate_ns); + + start = clockNs(); + for (0..iters) |_| func(); + const elapsed_ns = clockNs() - start; + const ns_per_op = elapsed_ns / iters; + const ops_per_sec = if (ns_per_op == 0) 0 else 1_000_000_000 / ns_per_op; + std.debug.print(" {s:<28} {d:>8} ns/op {d:>10} ops/sec ({d} iters)\n", .{ name, ns_per_op, ops_per_sec, iters }); +} + +var keypair_p256: zat.Keypair = undefined; +var keypair_k256: zat.Keypair = undefined; +var commit_did: []const u8 = undefined; +var data_cid: zat.cbor.Cid = undefined; + +fn signWith(keypair: *const zat.Keypair) void { + var scratch: [4096]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&scratch); + const signed = zat.signCommit(fba.allocator(), .{ + .did = commit_did, + .rev = "3k2abcdefghij", + .data = data_cid, + }, keypair) catch @panic("signCommit"); + std.mem.doNotOptimizeAway(signed.cid.raw); + std.mem.doNotOptimizeAway(signed.bytes); +} + +fn benchP256() void { + signWith(&keypair_p256); +} + +fn benchK256() void { + signWith(&keypair_k256); +} + +pub fn main() void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + + keypair_p256 = zat.Keypair.fromSecretKey(.p256, .{7} ** 32) catch @panic("keypair p256"); + keypair_k256 = zat.Keypair.fromSecretKey(.secp256k1, .{7} ** 32) catch @panic("keypair k256"); + commit_did = keypair_p256.did(a) catch @panic("did"); + data_cid = zat.cbor.Cid.forDagCbor(a, "mst-root") catch @panic("data cid"); + + std.debug.print("\ncommit build + sign (PDS write hot path)\n", .{}); + std.debug.print("{s}\n\n", .{"=" ** 68}); + bench("signCommit (p256)", benchP256); + bench("signCommit (secp256k1)", benchK256); + std.debug.print("\n", .{}); +} diff --git a/src/internal/repo/repo_verifier.zig b/src/internal/repo/repo.zig similarity index 85% rename from src/internal/repo/repo_verifier.zig rename to src/internal/repo/repo.zig index a510fad..012c62e 100644 --- a/src/internal/repo/repo_verifier.zig +++ b/src/internal/repo/repo.zig @@ -1,6 +1,7 @@ -//! end-to-end repo verification +//! repo commit layer: build/sign, load, and verify //! -//! exercises the full AT Protocol trust chain: +//! the produce side builds and signs a commit (`signCommit`), and the consume +//! side verifies it against the full AT Protocol trust chain: //! handle → DID → DID document → signing key //! ↓ //! repo CAR → commit → signature ← verified against key @@ -19,6 +20,7 @@ const HttpTransport = @import("../xrpc/transport.zig").HttpTransport; const multibase = @import("../crypto/multibase.zig"); const multicodec = @import("../crypto/multicodec.zig"); const jwt = @import("../crypto/jwt.zig"); +const Keypair = @import("../crypto/keypair.zig").Keypair; const cbor = @import("cbor.zig"); const car = @import("car.zig"); const mst = @import("mst.zig"); @@ -244,6 +246,67 @@ pub fn encodeUnsignedCommit(allocator: Allocator, commit: cbor.Value) ![]u8 { return cbor.encodeAlloc(allocator, unsigned_value); } +/// parameters for building a signed AT Protocol repo commit (data model v3). +pub const CommitParams = struct { + /// repo DID the commit belongs to. + did: []const u8, + /// commit revision TID; must increase monotonically per repo. + rev: []const u8, + /// CID of the MST root — the repo's "data" pointer. + data: cbor.Cid, + /// CID of the previous commit, or null for the first commit. + prev: ?cbor.Cid = null, + /// commit data-model version. AT Protocol currently requires 3. + version: u64 = 3, +}; + +/// a signed commit block ready to embed in a CAR or firehose frame. +pub const SignedCommit = struct { + /// CID of the signed commit block. + cid: cbor.Cid, + /// canonical DAG-CBOR bytes of the signed commit. + bytes: []const u8, + + pub fn deinit(self: SignedCommit, allocator: Allocator) void { + allocator.free(self.cid.raw); + allocator.free(self.bytes); + } +}; + +/// build and sign an AT Protocol repo commit. +/// +/// producer-side counterpart to `verifyCommitCar`: assembles the canonical +/// unsigned commit, signs its DAG-CBOR bytes with `keypair`, and returns the +/// signed commit block plus its CID. the resulting signature verifies under +/// `verifyCommitCar` / `verifyCommitDiff` against the keypair's public key. +/// +/// caller owns the returned `SignedCommit` and must `deinit` it. +pub fn signCommit( + allocator: Allocator, + params: CommitParams, + keypair: *const Keypair, +) !SignedCommit { + const unsigned_entries = [_]cbor.Value.MapEntry{ + .{ .key = "did", .value = .{ .text = params.did } }, + .{ .key = "version", .value = .{ .unsigned = params.version } }, + .{ .key = "data", .value = .{ .cid = params.data } }, + .{ .key = "rev", .value = .{ .text = params.rev } }, + .{ .key = "prev", .value = if (params.prev) |p| .{ .cid = p } else .null }, + }; + + const unsigned_bytes = try cbor.encodeAlloc(allocator, .{ .map = &unsigned_entries }); + defer allocator.free(unsigned_bytes); + const sig = try keypair.sign(unsigned_bytes); + + const signed_entries = unsigned_entries ++ [_]cbor.Value.MapEntry{ + .{ .key = "sig", .value = .{ .bytes = &sig.bytes } }, + }; + const signed_bytes = try cbor.encodeAlloc(allocator, .{ .map = &signed_entries }); + errdefer allocator.free(signed_bytes); + const cid = try cbor.Cid.forDagCbor(allocator, signed_bytes); + return .{ .cid = cid, .bytes = signed_bytes }; +} + const MstWalkOptions = struct { require_record_blocks: bool = false, }; @@ -566,6 +629,60 @@ test "verifyCommitDiff: build tree, serialize partial CAR, verify inversion" { try std.testing.expect(!std.mem.eql(u8, prev_data_cid.raw, new_data_cid.raw)); } +test "signCommit round-trips through verifyCommitCar" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // real MST root so verify_mst walks a genuine tree + var tree = mst.Mst.init(a); + try tree.put("app.bsky.feed.post/3k2abcdefghij", try cbor.Cid.forDagCbor(a, "record1")); + try tree.put("app.bsky.feed.post/3k2abcdefghik", try cbor.Cid.forDagCbor(a, "record2")); + const data_cid = try tree.rootCid(); + + const keypair = try Keypair.fromSecretKey(.p256, .{7} ** 32); + const did = try keypair.did(a); + + const signed = try signCommit(a, .{ + .did = did, + .rev = "3k2abcdefghij", + .data = data_cid, + }, &keypair); + + // assemble a full-repo CAR: commit root + all MST blocks + var blocks: std.ArrayList(car.Block) = .empty; + try blocks.append(a, .{ .cid_raw = signed.cid.raw, .data = signed.bytes }); + try tree.collectBlocks(&blocks); + const car_bytes = try car.writeAlloc(a, .{ + .roots = &.{signed.cid}, + .blocks = blocks.items, + }); + + const pubkey = try keypair.publicKey(); + const result = try verifyCommitCar(a, car_bytes, .{ .key_type = .p256, .raw = &pubkey }, .{ + .expected_did = did, + }); + try std.testing.expectEqualStrings(did, result.commit_did); + try std.testing.expectEqual(@as(i64, 3), result.commit_version); + try std.testing.expectEqualStrings("3k2abcdefghij", result.commit_rev); +} + +test "signCommit rev change produces a different signature and CID" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const keypair = try Keypair.fromSecretKey(.p256, .{9} ** 32); + const did = try keypair.did(a); + const data_cid = try cbor.Cid.forDagCbor(a, "mst-root"); + + const first = try signCommit(a, .{ .did = did, .rev = "3k2aaaaaaaaaa", .data = data_cid }, &keypair); + const second = try signCommit(a, .{ .did = did, .rev = "3k2bbbbbbbbbb", .data = data_cid, .prev = first.cid }, &keypair); + + try std.testing.expect(!std.mem.eql(u8, first.cid.raw, second.cid.raw)); + try std.testing.expect(!std.mem.eql(u8, first.bytes, second.bytes)); +} + test "loadCommitFromCAR extracts commit fields" { // build a minimal valid commit CAR var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -750,7 +867,7 @@ test "loadCompleteCommitFromCAR catches block-boundary truncation" { } // stress test: pfrazee.com (~192k records on bsky.network) -// run manually with: zig test src/internal/repo/repo_verifier.zig -- +// run manually with: zig test src/internal/repo/repo.zig -- // not included in `zig build test` — too slow for CI // // test "verify repo - pfrazee.com (stress)" { diff --git a/src/root.zig b/src/root.zig index 13c1ad3..bb4e6e7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -38,20 +38,23 @@ pub const mst = @import("internal/repo/mst.zig"); pub const cbor = @import("internal/repo/cbor.zig"); pub const car = @import("internal/repo/car.zig"); -// repo verification -const repo_verifier = @import("internal/repo/repo_verifier.zig"); -pub const verifyRepo = repo_verifier.verifyRepo; -pub const VerifyResult = repo_verifier.VerifyResult; -pub const verifyCommitCar = repo_verifier.verifyCommitCar; -pub const CommitVerifyResult = repo_verifier.CommitVerifyResult; +// repo commit layer: build/sign, load, and verify +const repo = @import("internal/repo/repo.zig"); +pub const verifyRepo = repo.verifyRepo; +pub const VerifyResult = repo.VerifyResult; +pub const verifyCommitCar = repo.verifyCommitCar; +pub const CommitVerifyResult = repo.CommitVerifyResult; +pub const signCommit = repo.signCommit; +pub const CommitParams = repo.CommitParams; +pub const SignedCommit = repo.SignedCommit; // sync 1.1: commit diff verification pub const MstOperation = mst.Operation; -pub const Commit = repo_verifier.Commit; -pub const LoadedCommitCar = repo_verifier.LoadedCommitCar; -pub const loadCommitFromCAR = repo_verifier.loadCommitFromCAR; -pub const verifyCommitDiff = repo_verifier.verifyCommitDiff; -pub const CommitDiffResult = repo_verifier.CommitDiffResult; +pub const Commit = repo.Commit; +pub const LoadedCommitCar = repo.LoadedCommitCar; +pub const loadCommitFromCAR = repo.loadCommitFromCAR; +pub const verifyCommitDiff = repo.verifyCommitDiff; +pub const CommitDiffResult = repo.CommitDiffResult; // sync / streaming const sync = @import("internal/streaming/sync.zig"); @@ -77,7 +80,7 @@ pub const FirehoseEvent = firehose.Event; comptime { if (@import("builtin").is_test) { _ = @import("internal/testing/interop_tests.zig"); - _ = @import("internal/repo/repo_verifier.zig"); + _ = @import("internal/repo/repo.zig"); _ = @import("internal/repo/cbor_test.zig"); _ = @import("internal/repo/cbor_read_test.zig"); _ = @import("internal/repo/cbor_write_test.zig"); -- 2.51.2