atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

harden plc key lifecycle

zzstoatzz (May 26, 2026, 9:20 PM -0500) 0a80147c fbac513e

+226 -8
+1 -1
Dockerfile
··· 46 46 && rm -rf /var/lib/apt/lists/* 47 47 48 48 WORKDIR /app 49 - COPY --from=build /work/zat.dev/zds/zig-out/bin/zds /usr/local/bin/zds 49 + COPY --from=build /work/zat.dev/zds/zig-out/bin/ /usr/local/bin/ 50 50 RUN mkdir -p /data 51 51 52 52 ENV ZDS_HOST=0.0.0.0
+17
build.zig
··· 59 59 const bench_step = b.step("bench", "Run local ZDS benchmarks"); 60 60 bench_step.dependOn(&run_bench.step); 61 61 62 + const plc_repair = b.addExecutable(.{ 63 + .name = "zds-plc-repair", 64 + .root_module = b.createModule(.{ 65 + .root_source_file = b.path("tools/plc_repair.zig"), 66 + .target = target, 67 + .optimize = optimize, 68 + .imports = &.{.{ .name = "zds", .module = mod }}, 69 + }), 70 + }); 71 + b.installArtifact(plc_repair); 72 + 73 + const run_plc_repair = b.addRunArtifact(plc_repair); 74 + if (b.args) |args| run_plc_repair.addArgs(args); 75 + 76 + const plc_repair_step = b.step("plc-repair", "Repair an existing account PLC rotation key set"); 77 + plc_repair_step.dependOn(&run_plc_repair.step); 78 + 62 79 const tests = b.addTest(.{ .root_module = mod }); 63 80 const run_tests = b.addRunArtifact(tests); 64 81
+16
docs/operations.md
··· 73 73 - `ZDS_INVITE_REQUIRED`: set to `true` to require invite codes for account 74 74 creation. `describeServer` reflects this value. 75 75 76 + PLC key lifecycle: 77 + 78 + - Repo signing keys are for repository commits only. 79 + - PLC rotation keys are for DID PLC operations only. 80 + - `getRecommendedDidCredentials` follows the official PDS shape: the account 81 + signing key is returned under `verificationMethods.atproto`; rotation keys 82 + are the optional recovery DID key followed by the configured PDS rotation key. 83 + - ZDS rejects new PLC operations that put the account signing key in 84 + `rotationKeys`. 85 + - Existing early accounts can be repaired with `just plc-repair <did>` after 86 + setting `ZDS_DB` or `ZDS_DB_PATH`, `ZDS_PUBLIC_URL`, `ZDS_PLC_ROTATION_KEY`, 87 + and optional `ZDS_RECOVERY_DID_KEY`. The repair tool is intentionally separate 88 + from normal XRPC handling; it only uses the account signing key if the current 89 + public PLC state already authorizes that key as a rotation key, and submits a 90 + replacement operation containing the intended recovery/PDS rotation keys. 91 + 76 92 When invites are required and the database has no accounts or invite codes, ZDS 77 93 creates one bootstrap code and logs it during startup. Invite codes are stored 78 94 as metadata rows and uses are recorded separately, matching the official PDS
+4
justfile
··· 24 24 --data "$body" | 25 25 jq -r '.code' 26 26 27 + # repair an existing account's PLC rotation set from the local database 28 + plc-repair did: 29 + zig build plc-repair -- {{did}} 30 + 27 31 # build the container image locally 28 32 docker-build tag="latest" platform="linux/amd64" arch="amd64": 29 33 DOCKER_BUILDKIT=1 docker build --platform {{platform}} --build-arg TARGETARCH={{arch}} --build-arg ZDS_VERSION={{tag}} -t zds:{{tag}} .
+5 -7
src/atproto/identity.zig
··· 186 186 fn plcOperationSigningKeypair( 187 187 request: *http.Server.Request, 188 188 allocator: std.mem.Allocator, 189 - account: auth.Account, 189 + _: auth.Account, 190 190 last_op: std.json.Value, 191 191 ) !zat.Keypair { 192 - var account_keypair = try store.signingKeypair(account.did); 193 - const account_did_key = try account_keypair.did(allocator); 194 - if (jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), account_did_key)) { 195 - return account_keypair; 196 - } 197 192 var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { 198 193 error.MissingPlcRotationKey => { 199 194 try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"); ··· 208 203 if (jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), rotation_did_key)) { 209 204 return rotation_keypair; 210 205 } 211 - try http_api.xrpcError(request, .bad_request, "InvalidRequest", "No controlled key matches current PLC rotation keys"); 206 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "PDS rotation key is not authorized by current PLC rotation keys"); 212 207 return error.HandledResponse; 213 208 } 214 209 ··· 233 228 }; 234 229 if (!jsonArrayContainsString(object.get("rotationKeys"), rotation_did_key)) { 235 230 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys do not include server rotation key"); 231 + } 232 + if (jsonArrayContainsString(object.get("rotationKeys"), did_key)) { 233 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys must not include the account signing key"); 236 234 } 237 235 const verification_methods = object.get("verificationMethods") orelse { 238 236 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing verificationMethods");
+84
src/atproto/plc.zig
··· 108 108 } 109 109 } 110 110 111 + pub fn fetchLastOperation(allocator: std.mem.Allocator, did: []const u8) !std.json.Value { 112 + const escaped_did = try percentEncodeDid(allocator, did); 113 + const url = try std.fmt.allocPrint(allocator, "{s}/{s}/log/last", .{ config.plcDirectory(), escaped_did }); 114 + var transport = zat.HttpTransport.init(store.currentIo(), allocator); 115 + defer transport.deinit(); 116 + const result = try transport.fetch(.{ 117 + .url = url, 118 + .method = .GET, 119 + .accept = "application/json", 120 + .max_response_size = 1024 * 1024, 121 + }); 122 + if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.PlcFetchFailed; 123 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 124 + return parsed.value; 125 + } 126 + 127 + pub fn updateOperationJson( 128 + allocator: std.mem.Allocator, 129 + handle: []const u8, 130 + signing_did_key: []const u8, 131 + rotation_keys: []const []const u8, 132 + prev: []const u8, 133 + ) ![]const u8 { 134 + const rotation_keys_json = try jsonStringArray(allocator, rotation_keys); 135 + return std.fmt.allocPrint( 136 + allocator, 137 + "{{\"type\":\"plc_operation\",\"rotationKeys\":{s},\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":{f}}}", 138 + .{ 139 + rotation_keys_json, 140 + std.json.fmt(signing_did_key, .{}), 141 + std.json.fmt(try std.fmt.allocPrint(allocator, "at://{s}", .{handle}), .{}), 142 + std.json.fmt(config.publicUrl(), .{}), 143 + std.json.fmt(prev, .{}), 144 + }, 145 + ); 146 + } 147 + 148 + pub fn signOperationJson(allocator: std.mem.Allocator, unsigned_json: []const u8, signing_key: *const zat.Keypair) ![]const u8 { 149 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); 150 + const cbor_value = try jsonToCbor(allocator, parsed.value); 151 + const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value); 152 + const sig = try signing_key.sign(encoded); 153 + const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes); 154 + return std.fmt.allocPrint(allocator, "{s},\"sig\":{f}}}", .{ unsigned_json[0 .. unsigned_json.len - 1], std.json.fmt(sig_text, .{}) }); 155 + } 156 + 157 + pub fn cidForJsonValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 158 + const cbor_value = try jsonToCbor(allocator, value); 159 + const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value); 160 + const cid = try zat.cbor.Cid.forDagCbor(allocator, encoded); 161 + return zat.multibase.base32lower.encode(allocator, cid.raw); 162 + } 163 + 111 164 fn unsignedOperationCbor( 112 165 allocator: std.mem.Allocator, 113 166 handle: []const u8, ··· 196 249 } 197 250 try out.writer.writeByte(']'); 198 251 return out.toOwnedSlice(); 252 + } 253 + 254 + fn jsonToCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value { 255 + return switch (value) { 256 + .null => .null, 257 + .bool => |boolean| .{ .boolean = boolean }, 258 + .integer => |integer| if (integer >= 0) 259 + .{ .unsigned = @intCast(integer) } 260 + else 261 + .{ .negative = integer }, 262 + .float => return error.InvalidDagCbor, 263 + .number_string => return error.InvalidDagCbor, 264 + .string => |string| .{ .text = string }, 265 + .array => |array| blk: { 266 + const items = try allocator.alloc(zat.cbor.Value, array.items.len); 267 + for (array.items, 0..) |item, idx| items[idx] = try jsonToCbor(allocator, item); 268 + break :blk .{ .array = items }; 269 + }, 270 + .object => |object| blk: { 271 + const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count()); 272 + var it = object.iterator(); 273 + var idx: usize = 0; 274 + while (it.next()) |entry| : (idx += 1) { 275 + entries[idx] = .{ 276 + .key = entry.key_ptr.*, 277 + .value = try jsonToCbor(allocator, entry.value_ptr.*), 278 + }; 279 + } 280 + break :blk .{ .map = entries }; 281 + }, 282 + }; 199 283 } 200 284 201 285 fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 {
+3
src/atproto/server.zig
··· 330 330 if (!jsonArrayContainsString(object.get("rotationKeys"), server_rotation_key)) { 331 331 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not include server rotation key"); 332 332 } 333 + if (jsonArrayContainsString(object.get("rotationKeys"), signing_did_key)) { 334 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation must not include reserved signing key as a rotation key"); 335 + } 333 336 const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); 334 337 if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), also_known_as)) { 335 338 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation handle does not match account handle");
+96
tools/plc_repair.zig
··· 1 + const std = @import("std"); 2 + const zds = @import("zds"); 3 + 4 + const Io = std.Io; 5 + 6 + var app_threaded_io: Io.Threaded = undefined; 7 + pub const std_options_debug_threaded_io: ?*Io.Threaded = &app_threaded_io; 8 + 9 + pub fn main(init: std.process.Init) !void { 10 + const allocator = std.heap.smp_allocator; 11 + app_threaded_io = Io.Threaded.init(allocator, .{}); 12 + const io = app_threaded_io.io(); 13 + 14 + const did = parseDid(init) orelse { 15 + std.debug.print("usage: zds-plc-repair DID\n", .{}); 16 + return error.MissingDid; 17 + }; 18 + 19 + if (env("ZDS_PUBLIC_URL")) |value| zds.core.config.setPublicUrl(value); 20 + if (env("ZDS_PLC_DIRECTORY")) |value| zds.core.config.setPlcDirectory(value); 21 + zds.core.config.setPlcRotationKey(env("ZDS_PLC_ROTATION_KEY")); 22 + zds.core.config.setRecoveryDidKey(env("ZDS_RECOVERY_DID_KEY")); 23 + 24 + const db_path = env("ZDS_DB_PATH") orelse env("ZDS_DB") orelse "dev/zds.sqlite3"; 25 + try zds.storage.store.init(io, db_path); 26 + 27 + const account = (try zds.storage.store.findAccount(allocator, did)) orelse return error.AccountNotFound; 28 + var account_keypair = try zds.storage.store.signingKeypair(account.did); 29 + const account_did_key = try account_keypair.did(allocator); 30 + var rotation_keypair = try zds.atproto.plc.configuredRotationKeypair(); 31 + const rotation_did_key = try rotation_keypair.did(allocator); 32 + 33 + const last_op = try zds.atproto.plc.fetchLastOperation(allocator, account.did); 34 + const current_rotation_keys = zds.zat.json.getPath(last_op, "rotationKeys"); 35 + const signer = if (jsonArrayContainsString(current_rotation_keys, rotation_did_key)) 36 + &rotation_keypair 37 + else if (jsonArrayContainsString(current_rotation_keys, account_did_key)) 38 + &account_keypair 39 + else 40 + return error.NoControlledCurrentRotationKey; 41 + 42 + const target_rotation_keys = try targetRotationKeys(allocator, rotation_did_key); 43 + if (stringListContains(target_rotation_keys, account_did_key)) { 44 + return error.AccountSigningKeyWouldRemainRotationKey; 45 + } 46 + 47 + const prev = try zds.atproto.plc.cidForJsonValue(allocator, last_op); 48 + const unsigned = try zds.atproto.plc.updateOperationJson(allocator, account.handle, account_did_key, target_rotation_keys, prev); 49 + const signed = try zds.atproto.plc.signOperationJson(allocator, unsigned, signer); 50 + try zds.atproto.plc.submitOperation(allocator, account.did, signed); 51 + 52 + std.debug.print("repaired PLC rotation keys for {s}\n", .{account.did}); 53 + } 54 + 55 + fn parseDid(init: std.process.Init) ?[]const u8 { 56 + var args = std.process.Args.Iterator.init(init.minimal.args); 57 + _ = args.next(); 58 + return args.next(); 59 + } 60 + 61 + fn env(name: [:0]const u8) ?[]const u8 { 62 + const value = std.c.getenv(name) orelse return null; 63 + return std.mem.span(value); 64 + } 65 + 66 + fn targetRotationKeys(allocator: std.mem.Allocator, rotation_did_key: []const u8) ![]const []const u8 { 67 + const recovery = zds.core.config.recoveryDidKey(); 68 + const count: usize = if (recovery == null) 1 else 2; 69 + const keys = try allocator.alloc([]const u8, count); 70 + var idx: usize = 0; 71 + if (recovery) |value| { 72 + keys[idx] = value; 73 + idx += 1; 74 + } 75 + keys[idx] = rotation_did_key; 76 + return keys; 77 + } 78 + 79 + fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool { 80 + const items = switch (value orelse return false) { 81 + .array => |array| array.items, 82 + else => return false, 83 + }; 84 + for (items) |item| switch (item) { 85 + .string => |string| if (std.mem.eql(u8, string, expected)) return true, 86 + else => {}, 87 + }; 88 + return false; 89 + } 90 + 91 + fn stringListContains(items: []const []const u8, expected: []const u8) bool { 92 + for (items) |item| { 93 + if (std.mem.eql(u8, item, expected)) return true; 94 + } 95 + return false; 96 + }