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, 3:38 PM -0500) e64bdc38 1080ae01

+390 -27
+2
README.md
··· 49 49 ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \ 50 50 ZDS_HANDLE_DOMAINS='.example.com,example.com' \ 51 51 ZDS_CRAWLERS='https://bsky.network,https://vsky.network' \ 52 + ZDS_PLC_ROTATION_KEY='64-hex-character-secp256k1-secret' \ 53 + ZDS_RECOVERY_DID_KEY='did:key:optionalRecoveryKey' \ 52 54 ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ 53 55 ZDS_ADMIN_TOKEN='another-random-secret' \ 54 56 ZDS_INVITE_REQUIRED=true \
+6
docs/operations.md
··· 55 55 56 56 - `ZDS_PUBLIC_URL`: public PDS origin. 57 57 - `ZDS_SERVER_DID`: PDS service DID, usually `did:web:<host>`. 58 + - `ZDS_PLC_ROTATION_KEY`: 32-byte secp256k1 private key as 64 lowercase hex 59 + characters. New `did:plc` accounts use this as the PDS rotation authority, 60 + and recommended DID credentials return its `did:key` instead of reusing the 61 + account signing key. 62 + - `ZDS_RECOVERY_DID_KEY`: optional recovery `did:key` returned before the PDS 63 + rotation key in recommended DID credentials. 58 64 - `ZDS_JWT_SECRET`: stable secret for access and refresh JWTs. 59 65 - `ZDS_HANDLE_DOMAINS`: comma-separated domains advertised by 60 66 `describeServer`.
+66 -7
src/atproto/identity.zig
··· 3 3 const config = @import("../core/config.zig"); 4 4 const mail = @import("../core/mail.zig"); 5 5 const http_api = @import("../http/api.zig"); 6 + const plc = @import("plc.zig"); 6 7 const store = @import("../storage/store.zig"); 7 8 const zat = @import("zat"); 8 9 ··· 15 16 16 17 const account = requireAccount(request, allocator) catch return; 17 18 var keypair = try store.signingKeypair(account.did); 18 - const did_key = try keypair.did(allocator); 19 + const signing_did_key = try keypair.did(allocator); 20 + const rotation_keys_json = if (std.mem.startsWith(u8, account.did, "did:web:")) 21 + "[]" 22 + else keys: { 23 + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { 24 + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), 25 + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), 26 + }; 27 + const rotation_keys = try plc.rotationDidKeys(allocator, &rotation_keypair); 28 + break :keys try jsonStringArray(allocator, rotation_keys); 29 + }; 19 30 const body = try std.fmt.allocPrint( 20 31 allocator, 21 - "{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}", 32 + "{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":{s},\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}", 22 33 .{ 23 34 account.handle, 24 - std.json.fmt(did_key, .{}), 25 - std.json.fmt(did_key, .{}), 35 + std.json.fmt(signing_did_key, .{}), 36 + rotation_keys_json, 26 37 std.json.fmt(config.publicUrl(), .{}), 27 38 }, 28 39 ); ··· 78 89 const unsigned_parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); 79 90 const unsigned_cbor = try jsonToCbor(allocator, unsigned_parsed.value); 80 91 const encoded = try zat.cbor.encodeAlloc(allocator, unsigned_cbor); 81 - var keypair = try store.signingKeypair(account.did); 82 - const sig = try keypair.sign(encoded); 92 + var signing_keypair = try plcOperationSigningKeypair(request, allocator, account, last_op); 93 + const sig = try signing_keypair.sign(encoded); 83 94 const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes); 84 95 try store.clearAuthCode(account.did); 85 96 ··· 172 183 }; 173 184 } 174 185 186 + fn plcOperationSigningKeypair( 187 + request: *http.Server.Request, 188 + allocator: std.mem.Allocator, 189 + account: auth.Account, 190 + last_op: std.json.Value, 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 + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { 198 + error.MissingPlcRotationKey => { 199 + try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"); 200 + return error.HandledResponse; 201 + }, 202 + error.InvalidPlcRotationKey => { 203 + try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"); 204 + return error.HandledResponse; 205 + }, 206 + }; 207 + const rotation_did_key = try rotation_keypair.did(allocator); 208 + if (jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), rotation_did_key)) { 209 + return rotation_keypair; 210 + } 211 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "No controlled key matches current PLC rotation keys"); 212 + return error.HandledResponse; 213 + } 214 + 175 215 fn validatePlcOperation( 176 216 request: *http.Server.Request, 177 217 allocator: std.mem.Allocator, ··· 180 220 ) !void { 181 221 var keypair = try store.signingKeypair(account.did); 182 222 const did_key = try keypair.did(allocator); 223 + const rotation_did_key = if (std.mem.startsWith(u8, account.did, "did:plc:")) key: { 224 + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { 225 + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), 226 + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), 227 + }; 228 + break :key try rotation_keypair.did(allocator); 229 + } else did_key; 183 230 const object = switch (operation) { 184 231 .object => |object| object, 185 232 else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid operation"), 186 233 }; 187 - if (!jsonArrayContainsString(object.get("rotationKeys"), did_key)) { 234 + if (!jsonArrayContainsString(object.get("rotationKeys"), rotation_did_key)) { 188 235 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys do not include server rotation key"); 189 236 } 190 237 const verification_methods = object.get("verificationMethods") orelse { ··· 210 257 { 211 258 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect atproto_pds service"); 212 259 } 260 + } 261 + 262 + fn jsonStringArray(allocator: std.mem.Allocator, items: []const []const u8) ![]const u8 { 263 + var out: std.Io.Writer.Allocating = .init(allocator); 264 + defer out.deinit(); 265 + try out.writer.writeByte('['); 266 + for (items, 0..) |item, idx| { 267 + if (idx != 0) try out.writer.writeByte(','); 268 + try out.writer.print("{f}", .{std.json.fmt(item, .{})}); 269 + } 270 + try out.writer.writeByte(']'); 271 + return out.toOwnedSlice(); 213 272 } 214 273 215 274 fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
+51 -15
src/atproto/plc.zig
··· 8 8 json: []const u8, 9 9 }; 10 10 11 + pub fn configuredRotationKeypair() !zat.Keypair { 12 + const hex = config.plcRotationKey() orelse return error.MissingPlcRotationKey; 13 + if (hex.len != 64) return error.InvalidPlcRotationKey; 14 + var secret: [32]u8 = undefined; 15 + _ = std.fmt.hexToBytes(&secret, hex) catch return error.InvalidPlcRotationKey; 16 + return zat.Keypair.fromSecretKey(.secp256k1, secret) catch error.InvalidPlcRotationKey; 17 + } 18 + 19 + pub fn rotationDidKeys(allocator: std.mem.Allocator, rotation_key: *const zat.Keypair) ![]const []const u8 { 20 + const base_len: usize = if (config.recoveryDidKey() == null) 1 else 2; 21 + const keys = try allocator.alloc([]const u8, base_len); 22 + var idx: usize = 0; 23 + if (config.recoveryDidKey()) |recovery| { 24 + keys[idx] = recovery; 25 + idx += 1; 26 + } 27 + keys[idx] = try rotation_key.did(allocator); 28 + return keys; 29 + } 30 + 11 31 pub fn createGenesisOperation( 12 32 allocator: std.mem.Allocator, 13 33 handle: []const u8, ··· 15 35 rotation_key: *const zat.Keypair, 16 36 ) !Operation { 17 37 const signing_did_key = try signing_key.did(allocator); 18 - const rotation_did_key = try rotation_key.did(allocator); 19 - const unsigned = try unsignedOperationCbor(allocator, handle, signing_did_key, rotation_did_key); 38 + const rotation_did_keys = try rotationDidKeys(allocator, rotation_key); 39 + const unsigned = try unsignedOperationCbor(allocator, handle, signing_did_key, rotation_did_keys); 20 40 const unsigned_bytes = try zat.cbor.encodeAlloc(allocator, unsigned); 21 41 const signature = try rotation_key.sign(unsigned_bytes); 22 42 const signature_text = try zat.jwt.base64UrlEncode(allocator, &signature.bytes); 23 43 24 - const signed = try signedOperationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text); 44 + const signed = try signedOperationCbor(allocator, handle, signing_did_key, rotation_did_keys, signature_text); 25 45 const signed_bytes = try zat.cbor.encodeAlloc(allocator, signed); 26 46 var digest: [32]u8 = undefined; 27 47 std.crypto.hash.sha2.Sha256.hash(signed_bytes, &digest, .{}); ··· 31 51 32 52 return .{ 33 53 .did = did, 34 - .json = try operationJson(allocator, handle, signing_did_key, rotation_did_key, signature_text), 54 + .json = try operationJson(allocator, handle, signing_did_key, rotation_did_keys, signature_text), 35 55 }; 36 56 } 37 57 ··· 56 76 allocator: std.mem.Allocator, 57 77 handle: []const u8, 58 78 signing_did_key: []const u8, 59 - rotation_did_key: []const u8, 79 + rotation_did_keys: []const []const u8, 60 80 ) !zat.cbor.Value { 61 - return operationCbor(allocator, handle, signing_did_key, rotation_did_key, null); 81 + return operationCbor(allocator, handle, signing_did_key, rotation_did_keys, null); 62 82 } 63 83 64 84 fn signedOperationCbor( 65 85 allocator: std.mem.Allocator, 66 86 handle: []const u8, 67 87 signing_did_key: []const u8, 68 - rotation_did_key: []const u8, 88 + rotation_did_keys: []const []const u8, 69 89 signature_text: []const u8, 70 90 ) !zat.cbor.Value { 71 - return operationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text); 91 + return operationCbor(allocator, handle, signing_did_key, rotation_did_keys, signature_text); 72 92 } 73 93 74 94 fn operationCbor( 75 95 allocator: std.mem.Allocator, 76 96 handle: []const u8, 77 97 signing_did_key: []const u8, 78 - rotation_did_key: []const u8, 98 + rotation_did_keys: []const []const u8, 79 99 signature_text: ?[]const u8, 80 100 ) !zat.cbor.Value { 81 101 const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); ··· 89 109 const verification_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1); 90 110 verification_entries[0] = .{ .key = "atproto", .value = .{ .text = signing_did_key } }; 91 111 92 - const rotation_items = try allocator.alloc(zat.cbor.Value, 1); 93 - rotation_items[0] = .{ .text = rotation_did_key }; 112 + const rotation_items = try allocator.alloc(zat.cbor.Value, rotation_did_keys.len); 113 + for (rotation_did_keys, 0..) |key, idx| { 114 + rotation_items[idx] = .{ .text = key }; 115 + } 94 116 const aka_items = try allocator.alloc(zat.cbor.Value, 1); 95 117 aka_items[0] = .{ .text = also_known_as }; 96 118 ··· 110 132 allocator: std.mem.Allocator, 111 133 handle: []const u8, 112 134 signing_did_key: []const u8, 113 - rotation_did_key: []const u8, 135 + rotation_did_keys: []const []const u8, 114 136 signature_text: []const u8, 115 137 ) ![]const u8 { 116 138 const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); 139 + const rotation_json = try jsonStringArray(allocator, rotation_did_keys); 117 140 return std.fmt.allocPrint( 118 141 allocator, 119 - "{{\"type\":\"plc_operation\",\"rotationKeys\":[{f}],\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}", 142 + "{{\"type\":\"plc_operation\",\"rotationKeys\":{s},\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}", 120 143 .{ 121 - std.json.fmt(rotation_did_key, .{}), 144 + rotation_json, 122 145 std.json.fmt(signing_did_key, .{}), 123 146 std.json.fmt(also_known_as, .{}), 124 147 std.json.fmt(config.publicUrl(), .{}), 125 148 std.json.fmt(signature_text, .{}), 126 149 }, 127 150 ); 151 + } 152 + 153 + fn jsonStringArray(allocator: std.mem.Allocator, items: []const []const u8) ![]const u8 { 154 + var out: std.Io.Writer.Allocating = .init(allocator); 155 + defer out.deinit(); 156 + try out.writer.writeByte('['); 157 + for (items, 0..) |item, idx| { 158 + if (idx != 0) try out.writer.writeByte(','); 159 + try out.writer.print("{f}", .{std.json.fmt(item, .{})}); 160 + } 161 + try out.writer.writeByte(']'); 162 + return out.toOwnedSlice(); 128 163 } 129 164 130 165 fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { ··· 164 199 const sig_text = parsed.value.object.get("sig").?.string; 165 200 const sig_bytes = try zat.jwt.base64UrlDecode(a, sig_text); 166 201 const did_key = try keypair.did(a); 167 - const unsigned = try unsignedOperationCbor(a, "alice.example.com", did_key, did_key); 202 + const rotation_keys = [_][]const u8{did_key}; 203 + const unsigned = try unsignedOperationCbor(a, "alice.example.com", did_key, &rotation_keys); 168 204 const unsigned_bytes = try zat.cbor.encodeAlloc(a, unsigned); 169 205 try zat.multicodec.verifyDidKeySignature(a, did_key, unsigned_bytes, sig_bytes); 170 206 }
+127 -5
src/atproto/server.zig
··· 48 48 return plain(request, .ok, account.did); 49 49 } 50 50 51 + pub fn reserveSigningKey(request: *http.Server.Request) !void { 52 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 53 + defer arena.deinit(); 54 + const allocator = arena.allocator(); 55 + 56 + const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); 57 + const did = if (body.len == 0) null else did: { 58 + const parsed = try http_api.parseJsonBody(request, allocator, body); 59 + break :did zat.json.getString(parsed.value, "did"); 60 + }; 61 + if (did) |value| { 62 + if (zat.Did.parse(value) == null) { 63 + return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); 64 + } 65 + } 66 + const reserved = try store.reserveSigningKey(allocator, did); 67 + const body_out = try std.fmt.allocPrint(allocator, "{{\"signingKey\":{f}}}", .{std.json.fmt(reserved.signing_key, .{})}); 68 + return http_api.json(request, .ok, body_out); 69 + } 70 + 51 71 pub fn createAccount(request: *http.Server.Request) !void { 52 72 const authorization = http_api.headerValue(request, "authorization"); 53 - var body_buf: [8192]u8 = undefined; 54 - const body = try http_api.readBody(request, &body_buf); 55 73 56 74 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 57 75 defer arena.deinit(); 58 76 const allocator = arena.allocator(); 59 77 78 + const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); 60 79 const parsed = try http_api.parseJsonBody(request, allocator, body); 61 80 const handle = zat.json.getString(parsed.value, "handle") orelse { 62 81 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); ··· 83 102 } 84 103 85 104 const existing_did = zat.json.getString(parsed.value, "did"); 105 + const plc_op = jsonObjectField(parsed.value, "plcOp"); 106 + const signing_key_input = zat.json.getString(parsed.value, "signingKey"); 86 107 const account = if (existing_did) |did| account: { 87 108 if (zat.Did.parse(did) == null) { 88 109 log.debug("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle }); 89 110 return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); 90 111 } 91 112 log.debug("xrpc createAccount migration attempt did={s} handle={s}\n", .{ did, handle }); 92 - try verifyCreateAccountServiceAuth(request, allocator, authorization, did); 93 - break :account store.createAccountWithSigningKeyAndInvite(allocator, handle, email, password, did, false, try store.generateAccountSigningKey(), invite_code) catch |err| switch (err) { 113 + const signing_key = if (plc_op) |operation| key: { 114 + const expected_key = signing_key_input orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "signingKey is required when plcOp is provided"); 115 + try validateCreateAccountPlcOperation(request, allocator, handle, operation, expected_key); 116 + const reserved_key = store.consumeReservedSigningKey(expected_key, did) catch |err| switch (err) { 117 + error.MissingReservedSigningKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "reserved signing key does not exist"), 118 + else => return err, 119 + }; 120 + break :key reserved_key; 121 + } else key: { 122 + try verifyCreateAccountServiceAuth(request, allocator, authorization, did); 123 + break :key try store.generateAccountSigningKey(); 124 + }; 125 + break :account store.createAccountWithSigningKeyAndInvite(allocator, handle, email, password, did, plc_op != null, signing_key, invite_code) catch |err| switch (err) { 94 126 error.InvalidInviteCode => return http_api.xrpcError(request, .bad_request, "InvalidInviteCode", "Provided invite code not available"), 95 127 else => { 96 128 log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle }); ··· 107 139 log.err("xrpc createAccount failed signing_key_parse handle={s}\n", .{handle}); 108 140 return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to prepare account signing key"); 109 141 }; 110 - const operation = plc.createGenesisOperation(allocator, handle, &keypair, &keypair) catch { 142 + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { 143 + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), 144 + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), 145 + }; 146 + const operation = plc.createGenesisOperation(allocator, handle, &keypair, &rotation_keypair) catch { 111 147 log.err("xrpc createAccount failed plc_operation handle={s}\n", .{handle}); 112 148 return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to create DID operation"); 113 149 }; ··· 259 295 return switch (raw) { 260 296 .integer => |n| n, 261 297 else => null, 298 + }; 299 + } 300 + 301 + fn jsonObjectField(value: std.json.Value, key: []const u8) ?std.json.Value { 302 + return switch (value) { 303 + .object => |object| object.get(key), 304 + else => null, 305 + }; 306 + } 307 + 308 + fn validateCreateAccountPlcOperation( 309 + request: *http.Server.Request, 310 + allocator: std.mem.Allocator, 311 + handle: []const u8, 312 + operation: std.json.Value, 313 + signing_did_key: []const u8, 314 + ) !void { 315 + const object = switch (operation) { 316 + .object => |object| object, 317 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid plc operation"), 318 + }; 319 + if (!jsonObjectStringEquals(operation, "type", "plc_operation")) { 320 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid plc operation type"); 321 + } 322 + if (!jsonObjectStringEquals(object.get("verificationMethods") orelse .null, "atproto", signing_did_key)) { 323 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not use reserved signing key"); 324 + } 325 + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { 326 + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), 327 + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), 328 + }; 329 + const server_rotation_key = try rotation_keypair.did(allocator); 330 + if (!jsonArrayContainsString(object.get("rotationKeys"), server_rotation_key)) { 331 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not include server rotation key"); 332 + } 333 + const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); 334 + if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), also_known_as)) { 335 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation handle does not match account handle"); 336 + } 337 + const services = object.get("services") orelse { 338 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation missing services"); 339 + }; 340 + const atproto_pds = switch (services) { 341 + .object => |services_object| services_object.get("atproto_pds") orelse { 342 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation missing atproto_pds service"); 343 + }, 344 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation services must be an object"), 345 + }; 346 + if (!jsonObjectStringEquals(atproto_pds, "type", "AtprotoPersonalDataServer") or 347 + !jsonObjectStringEquals(atproto_pds, "endpoint", config.publicUrl())) 348 + { 349 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation service does not match this PDS"); 350 + } 351 + } 352 + 353 + fn jsonObjectStringEquals(value: std.json.Value, key: []const u8, expected: []const u8) bool { 354 + return switch (value) { 355 + .object => |object| switch (object.get(key) orelse return false) { 356 + .string => |string| std.mem.eql(u8, string, expected), 357 + else => false, 358 + }, 359 + else => false, 360 + }; 361 + } 362 + 363 + fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool { 364 + const items = switch (value orelse return false) { 365 + .array => |array| array.items, 366 + else => return false, 367 + }; 368 + for (items) |item| switch (item) { 369 + .string => |string| if (std.mem.eql(u8, string, expected)) return true, 370 + else => {}, 371 + }; 372 + return false; 373 + } 374 + 375 + fn jsonArrayFirstStringEquals(value: ?std.json.Value, expected: []const u8) bool { 376 + const items = switch (value orelse return false) { 377 + .array => |array| array.items, 378 + else => return false, 379 + }; 380 + if (items.len == 0) return false; 381 + return switch (items[0]) { 382 + .string => |string| std.mem.eql(u8, string, expected), 383 + else => false, 262 384 }; 263 385 } 264 386
+18
src/core/config.zig
··· 3 3 var public_url_value: []const u8 = "http://localhost:2583"; 4 4 var server_did_value: []const u8 = "did:web:localhost"; 5 5 var plc_directory_value: []const u8 = "https://plc.directory"; 6 + var plc_rotation_key_value: ?[]const u8 = null; 7 + var recovery_did_key_value: ?[]const u8 = null; 6 8 var resend_api_key_value: ?[]const u8 = null; 7 9 var email_from_value: ?[]const u8 = null; 8 10 var blob_upload_limit_value: usize = 100_000_000; ··· 30 32 31 33 pub fn plcDirectory() []const u8 { 32 34 return plc_directory_value; 35 + } 36 + 37 + pub fn plcRotationKey() ?[]const u8 { 38 + return plc_rotation_key_value; 39 + } 40 + 41 + pub fn recoveryDidKey() ?[]const u8 { 42 + return recovery_did_key_value; 33 43 } 34 44 35 45 pub fn resendApiKey() ?[]const u8 { ··· 86 96 87 97 pub fn setPlcDirectory(value: []const u8) void { 88 98 plc_directory_value = trimTrailingSlash(value); 99 + } 100 + 101 + pub fn setPlcRotationKey(value: ?[]const u8) void { 102 + plc_rotation_key_value = value; 103 + } 104 + 105 + pub fn setRecoveryDidKey(value: ?[]const u8) void { 106 + recovery_did_key_value = value; 89 107 } 90 108 91 109 pub fn setResendApiKey(value: ?[]const u8) void {
+3
src/http/router.zig
··· 19 19 oauth_revoke, 20 20 atproto_did, 21 21 describe_server, 22 + reserve_signing_key, 22 23 create_account, 23 24 create_invite_code, 24 25 create_invite_codes, ··· 82 83 if (method == .POST and std.mem.eql(u8, path, "/oauth/revoke")) return .oauth_revoke; 83 84 if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; 84 85 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; 86 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.reserveSigningKey")) return .reserve_signing_key; 85 87 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAccount")) return .create_account; 86 88 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCode")) return .create_invite_code; 87 89 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCodes")) return .create_invite_codes; ··· 150 152 try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token")); 151 153 try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); 152 154 try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); 155 + try std.testing.expectEqual(Route.reserve_signing_key, route(.POST, "/xrpc/com.atproto.server.reserveSigningKey")); 153 156 try std.testing.expectEqual(Route.create_account, route(.POST, "/xrpc/com.atproto.server.createAccount")); 154 157 try std.testing.expectEqual(Route.create_invite_code, route(.POST, "/xrpc/com.atproto.server.createInviteCode")); 155 158 try std.testing.expectEqual(Route.create_invite_codes, route(.POST, "/xrpc/com.atproto.server.createInviteCodes"));
+1
src/http/server.zig
··· 102 102 .oauth_revoke => try atproto_oauth.revoke(request), 103 103 .atproto_did => try atproto_server.atprotoDid(request), 104 104 .describe_server => try atproto_server.describeServer(request), 105 + .reserve_signing_key => try atproto_server.reserveSigningKey(request), 105 106 .create_account => try atproto_server.createAccount(request), 106 107 .create_invite_code => try atproto_server.createInviteCode(request), 107 108 .create_invite_codes => try atproto_server.createInviteCodes(request),
+22
src/internal/cli.zig
··· 7 7 public_url: ?[]const u8 = null, 8 8 server_did: ?[]const u8 = null, 9 9 plc_directory: ?[]const u8 = null, 10 + plc_rotation_key: ?[]const u8 = null, 11 + recovery_did_key: ?[]const u8 = null, 10 12 email_from: ?[]const u8 = null, 11 13 resend_api_key: ?[]const u8 = null, 12 14 blob_upload_limit: ?usize = null, ··· 33 35 MissingJwtSecret, 34 36 MissingLogLevel, 35 37 MissingPlcDirectory, 38 + MissingPlcRotationKey, 39 + MissingRecoveryDidKey, 36 40 MissingPort, 37 41 MissingPublicUrl, 38 42 MissingResendApiKey, ··· 46 50 .public_url = env("ZDS_PUBLIC_URL"), 47 51 .server_did = env("ZDS_SERVER_DID"), 48 52 .plc_directory = env("ZDS_PLC_DIRECTORY"), 53 + .plc_rotation_key = env("ZDS_PLC_ROTATION_KEY"), 54 + .recovery_did_key = env("ZDS_RECOVERY_DID_KEY"), 49 55 .email_from = env("ZDS_EMAIL_FROM"), 50 56 .resend_api_key = env("ZDS_RESEND_API_KEY"), 51 57 .blob_upload_limit = try envUsize("ZDS_BLOB_UPLOAD_LIMIT"), ··· 74 80 pub fn usage() void { 75 81 std.debug.print( 76 82 \\usage: zds [--host HOST] [--port PORT] [--db PATH] [--public-url URL] [--server-did DID] 83 + \\ [--plc-rotation-key HEX] [--recovery-did-key DIDKEY] 77 84 \\ [--blob-upload-limit BYTES] [--blobstore-path PATH] 78 85 \\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET] 79 86 \\ [--admin-token TOKEN] [--invite-required] ··· 119 126 options.plc_directory = args.next() orelse return error.MissingPlcDirectory; 120 127 return true; 121 128 } 129 + if (std.mem.eql(u8, arg, "--plc-rotation-key")) { 130 + options.plc_rotation_key = args.next() orelse return error.MissingPlcRotationKey; 131 + return true; 132 + } 133 + if (std.mem.eql(u8, arg, "--recovery-did-key")) { 134 + options.recovery_did_key = args.next() orelse return error.MissingRecoveryDidKey; 135 + return true; 136 + } 122 137 if (std.mem.eql(u8, arg, "--email-from")) { 123 138 options.email_from = args.next() orelse return error.MissingEmailFrom; 124 139 return true; ··· 187 202 .{ .flag = "--public-url=", .field = "public_url" }, 188 203 .{ .flag = "--server-did=", .field = "server_did" }, 189 204 .{ .flag = "--plc-directory=", .field = "plc_directory" }, 205 + .{ .flag = "--plc-rotation-key=", .field = "plc_rotation_key" }, 206 + .{ .flag = "--recovery-did-key=", .field = "recovery_did_key" }, 190 207 .{ .flag = "--email-from=", .field = "email_from" }, 191 208 .{ .flag = "--resend-api-key=", .field = "resend_api_key" }, 192 209 .{ .flag = "--blobstore-path=", .field = "blobstore_path" }, ··· 222 239 "--port=8080", 223 240 "--db", 224 241 "dev/test.sqlite3", 242 + "--plc-rotation-key=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", 243 + "--recovery-did-key", 244 + "did:key:zRecovery", 225 245 "--debug", 226 246 }; 227 247 const init = std.process.Init{ .minimal = .{ .args = &argv } }; ··· 231 251 try std.testing.expectEqualStrings("0.0.0.0", options.host); 232 252 try std.testing.expectEqual(@as(u16, 8080), options.port); 233 253 try std.testing.expectEqualStrings("dev/test.sqlite3", options.db_path); 254 + try std.testing.expectEqualStrings("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", options.plc_rotation_key.?); 255 + try std.testing.expectEqualStrings("did:key:zRecovery", options.recovery_did_key.?); 234 256 try std.testing.expectEqualStrings("debug", options.log_level.?); 235 257 } 236 258
+2
src/main.zig
··· 22 22 if (options.public_url) |value| zds.core.config.setPublicUrl(value); 23 23 if (options.server_did) |value| zds.core.config.setServerDid(value); 24 24 if (options.plc_directory) |value| zds.core.config.setPlcDirectory(value); 25 + zds.core.config.setPlcRotationKey(options.plc_rotation_key); 26 + zds.core.config.setRecoveryDidKey(options.recovery_did_key); 25 27 zds.core.config.setEmailFrom(options.email_from); 26 28 zds.core.config.setResendApiKey(options.resend_api_key); 27 29 if (options.blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value);
+92
src/storage/store.zig
··· 13 13 InvalidCollection, 14 14 InvalidRecordKey, 15 15 InvalidRecordType, 16 + InvalidReservedSigningKey, 16 17 MissingRecord, 18 + MissingReservedSigningKey, 17 19 MissingRecordBlock, 18 20 RepoNotFound, 19 21 InvalidRepoPath, ··· 94 96 created_by: []const u8, 95 97 created_at: i64, 96 98 uses: []InviteCodeUse, 99 + }; 100 + 101 + pub const ReservedSigningKey = struct { 102 + did: ?[]const u8, 103 + signing_key: []const u8, 104 + secret_key: [32]u8, 105 + expires_at: i64, 97 106 }; 98 107 99 108 pub const InviteCodeUse = struct { ··· 503 512 defer db_mutex.unlock(store_io); 504 513 try requireInitialized(); 505 514 return generateSigningKey(); 515 + } 516 + 517 + pub fn reserveSigningKey(allocator: std.mem.Allocator, did: ?[]const u8) !ReservedSigningKey { 518 + db_mutex.lockUncancelable(store_io); 519 + defer db_mutex.unlock(store_io); 520 + try requireInitialized(); 521 + const secret_key = try generateSigningKey(); 522 + var keypair = try zat.Keypair.fromSecretKey(.secp256k1, secret_key); 523 + const signing_key = try keypair.did(allocator); 524 + const expires_at = nowMs() + (24 * 60 * 60 * 1000); 525 + try conn.exec( 526 + \\INSERT INTO reserved_signing_keys (did, signing_key, signing_key_type, secret_key, expires_at) 527 + \\VALUES (?, ?, 'secp256k1', ?, ?) 528 + , .{ did, signing_key, zqlite.blob(&secret_key), expires_at }); 529 + return .{ 530 + .did = if (did) |value| try allocator.dupe(u8, value) else null, 531 + .signing_key = signing_key, 532 + .secret_key = secret_key, 533 + .expires_at = expires_at, 534 + }; 535 + } 536 + 537 + pub fn consumeReservedSigningKey(signing_key_or_did: []const u8, did: []const u8) ![32]u8 { 538 + db_mutex.lockUncancelable(store_io); 539 + defer db_mutex.unlock(store_io); 540 + try requireInitialized(); 541 + return consumeReservedSigningKeyLocked(signing_key_or_did, did); 506 542 } 507 543 508 544 pub fn createInviteCode(allocator: std.mem.Allocator, public_url: []const u8, use_count: i64, for_account: []const u8, created_by: []const u8) ![]const u8 { ··· 2516 2552 return zat.Keypair.fromSecretKey(key_type, key_bytes); 2517 2553 } 2518 2554 2555 + fn consumeReservedSigningKeyLocked(signing_key_or_did: []const u8, did: []const u8) ![32]u8 { 2556 + const row = try conn.row( 2557 + \\SELECT signing_key, secret_key, expires_at 2558 + \\FROM reserved_signing_keys 2559 + \\WHERE used_at IS NULL 2560 + \\ AND expires_at > ? 2561 + \\ AND (signing_key = ? OR did = ?) 2562 + \\ORDER BY created_at DESC 2563 + \\LIMIT 1 2564 + , .{ nowMs(), signing_key_or_did, did }); 2565 + if (row == null) return Error.MissingReservedSigningKey; 2566 + defer row.?.deinit(); 2567 + 2568 + const signing_key = row.?.text(0); 2569 + const secret = row.?.blob(1); 2570 + if (secret.len != 32) return Error.InvalidReservedSigningKey; 2571 + const key_bytes = secret[0..32].*; 2572 + try conn.exec( 2573 + \\UPDATE reserved_signing_keys 2574 + \\SET used_at = ? 2575 + \\WHERE signing_key = ? 2576 + , .{ nowMs(), signing_key }); 2577 + return key_bytes; 2578 + } 2579 + 2519 2580 fn generateSigningKey() ![32]u8 { 2520 2581 var key: [32]u8 = undefined; 2521 2582 while (true) { ··· 3261 3322 \\ PRIMARY KEY (code, used_by) 3262 3323 \\) 3263 3324 , 3325 + \\CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 3326 + \\ id INTEGER PRIMARY KEY AUTOINCREMENT, 3327 + \\ did TEXT, 3328 + \\ signing_key TEXT NOT NULL UNIQUE, 3329 + \\ signing_key_type TEXT NOT NULL, 3330 + \\ secret_key BLOB NOT NULL, 3331 + \\ expires_at INTEGER NOT NULL, 3332 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), 3333 + \\ used_at INTEGER 3334 + \\) 3335 + , 3336 + "CREATE INDEX IF NOT EXISTS reserved_signing_keys_did_idx ON reserved_signing_keys (did) WHERE did IS NOT NULL", 3337 + "CREATE INDEX IF NOT EXISTS reserved_signing_keys_expires_idx ON reserved_signing_keys (expires_at) WHERE used_at IS NULL", 3264 3338 }; 3265 3339 3266 3340 const post_schema_statements = [_][*:0]const u8{ ··· 3489 3563 try std.testing.expectEqual(@as(usize, 1), codes.len); 3490 3564 try std.testing.expectEqual(@as(usize, 1), codes[0].uses.len); 3491 3565 try std.testing.expectEqualStrings("did:plc:invited", codes[0].uses[0].used_by); 3566 + } 3567 + 3568 + test "reserved signing keys are one-use account signing keys" { 3569 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 3570 + defer arena.deinit(); 3571 + const allocator = arena.allocator(); 3572 + 3573 + try init(std.Options.debug_io, ":memory:"); 3574 + defer close(); 3575 + 3576 + const did = "did:plc:reserved"; 3577 + const reserved = try reserveSigningKey(allocator, did); 3578 + try std.testing.expectEqualStrings(did, reserved.did.?); 3579 + try std.testing.expect(std.mem.startsWith(u8, reserved.signing_key, "did:key:")); 3580 + 3581 + const consumed = try consumeReservedSigningKey(reserved.signing_key, did); 3582 + try std.testing.expectEqualSlices(u8, &reserved.secret_key, &consumed); 3583 + try std.testing.expectError(error.MissingReservedSigningKey, consumeReservedSigningKey(reserved.signing_key, did)); 3492 3584 } 3493 3585 3494 3586 test "commit event encoding owns returned firehose op entries" {