From e64bdc38fc27180e124a068b2e29dd4f4acf4cb0 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 26 May 2026 15:38:49 -0500 Subject: [PATCH] harden plc key lifecycle --- README.md | 2 + docs/operations.md | 6 ++ src/atproto/identity.zig | 73 +++++++++++++++++++--- src/atproto/plc.zig | 66 +++++++++++++++----- src/atproto/server.zig | 132 +++++++++++++++++++++++++++++++++++++-- src/core/config.zig | 18 ++++++ src/http/router.zig | 3 + src/http/server.zig | 1 + src/internal/cli.zig | 22 +++++++ src/main.zig | 2 + src/storage/store.zig | 92 +++++++++++++++++++++++++++ 11 files changed, 390 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 792ecd3..08a3f55 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ ZDS_BLOB_UPLOAD_LIMIT=100000000 \ ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \ ZDS_HANDLE_DOMAINS='.example.com,example.com' \ ZDS_CRAWLERS='https://bsky.network,https://vsky.network' \ +ZDS_PLC_ROTATION_KEY='64-hex-character-secp256k1-secret' \ +ZDS_RECOVERY_DID_KEY='did:key:optionalRecoveryKey' \ ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ ZDS_ADMIN_TOKEN='another-random-secret' \ ZDS_INVITE_REQUIRED=true \ diff --git a/docs/operations.md b/docs/operations.md index 5582383..e5f0c5d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -55,6 +55,12 @@ Common deployment settings: - `ZDS_PUBLIC_URL`: public PDS origin. - `ZDS_SERVER_DID`: PDS service DID, usually `did:web:`. +- `ZDS_PLC_ROTATION_KEY`: 32-byte secp256k1 private key as 64 lowercase hex + characters. New `did:plc` accounts use this as the PDS rotation authority, + and recommended DID credentials return its `did:key` instead of reusing the + account signing key. +- `ZDS_RECOVERY_DID_KEY`: optional recovery `did:key` returned before the PDS + rotation key in recommended DID credentials. - `ZDS_JWT_SECRET`: stable secret for access and refresh JWTs. - `ZDS_HANDLE_DOMAINS`: comma-separated domains advertised by `describeServer`. diff --git a/src/atproto/identity.zig b/src/atproto/identity.zig index 98b44c3..31857aa 100644 --- a/src/atproto/identity.zig +++ b/src/atproto/identity.zig @@ -3,6 +3,7 @@ const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const mail = @import("../core/mail.zig"); const http_api = @import("../http/api.zig"); +const plc = @import("plc.zig"); const store = @import("../storage/store.zig"); const zat = @import("zat"); @@ -15,14 +16,24 @@ pub fn getRecommendedDidCredentials(request: *http.Server.Request) !void { const account = requireAccount(request, allocator) catch return; var keypair = try store.signingKeypair(account.did); - const did_key = try keypair.did(allocator); + const signing_did_key = try keypair.did(allocator); + const rotation_keys_json = if (std.mem.startsWith(u8, account.did, "did:web:")) + "[]" + else keys: { + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), + }; + const rotation_keys = try plc.rotationDidKeys(allocator, &rotation_keypair); + break :keys try jsonStringArray(allocator, rotation_keys); + }; const body = try std.fmt.allocPrint( allocator, - "{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}", + "{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":{s},\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}", .{ account.handle, - std.json.fmt(did_key, .{}), - std.json.fmt(did_key, .{}), + std.json.fmt(signing_did_key, .{}), + rotation_keys_json, std.json.fmt(config.publicUrl(), .{}), }, ); @@ -78,8 +89,8 @@ pub fn signPlcOperation(request: *http.Server.Request) !void { const unsigned_parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); const unsigned_cbor = try jsonToCbor(allocator, unsigned_parsed.value); const encoded = try zat.cbor.encodeAlloc(allocator, unsigned_cbor); - var keypair = try store.signingKeypair(account.did); - const sig = try keypair.sign(encoded); + var signing_keypair = try plcOperationSigningKeypair(request, allocator, account, last_op); + const sig = try signing_keypair.sign(encoded); const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes); try store.clearAuthCode(account.did); @@ -172,6 +183,35 @@ fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) ! }; } +fn plcOperationSigningKeypair( + request: *http.Server.Request, + allocator: std.mem.Allocator, + account: auth.Account, + last_op: std.json.Value, +) !zat.Keypair { + var account_keypair = try store.signingKeypair(account.did); + const account_did_key = try account_keypair.did(allocator); + if (jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), account_did_key)) { + return account_keypair; + } + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { + error.MissingPlcRotationKey => { + try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"); + return error.HandledResponse; + }, + error.InvalidPlcRotationKey => { + try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"); + return error.HandledResponse; + }, + }; + const rotation_did_key = try rotation_keypair.did(allocator); + if (jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), rotation_did_key)) { + return rotation_keypair; + } + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "No controlled key matches current PLC rotation keys"); + return error.HandledResponse; +} + fn validatePlcOperation( request: *http.Server.Request, allocator: std.mem.Allocator, @@ -180,11 +220,18 @@ fn validatePlcOperation( ) !void { var keypair = try store.signingKeypair(account.did); const did_key = try keypair.did(allocator); + const rotation_did_key = if (std.mem.startsWith(u8, account.did, "did:plc:")) key: { + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), + }; + break :key try rotation_keypair.did(allocator); + } else did_key; const object = switch (operation) { .object => |object| object, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid operation"), }; - if (!jsonArrayContainsString(object.get("rotationKeys"), did_key)) { + if (!jsonArrayContainsString(object.get("rotationKeys"), rotation_did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys do not include server rotation key"); } const verification_methods = object.get("verificationMethods") orelse { @@ -212,6 +259,18 @@ fn validatePlcOperation( } } +fn jsonStringArray(allocator: std.mem.Allocator, items: []const []const u8) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try out.writer.writeByte('['); + for (items, 0..) |item, idx| { + if (idx != 0) try out.writer.writeByte(','); + try out.writer.print("{f}", .{std.json.fmt(item, .{})}); + } + try out.writer.writeByte(']'); + return out.toOwnedSlice(); +} + fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); diff --git a/src/atproto/plc.zig b/src/atproto/plc.zig index ef11e3d..0b79de9 100644 --- a/src/atproto/plc.zig +++ b/src/atproto/plc.zig @@ -8,6 +8,26 @@ pub const Operation = struct { json: []const u8, }; +pub fn configuredRotationKeypair() !zat.Keypair { + const hex = config.plcRotationKey() orelse return error.MissingPlcRotationKey; + if (hex.len != 64) return error.InvalidPlcRotationKey; + var secret: [32]u8 = undefined; + _ = std.fmt.hexToBytes(&secret, hex) catch return error.InvalidPlcRotationKey; + return zat.Keypair.fromSecretKey(.secp256k1, secret) catch error.InvalidPlcRotationKey; +} + +pub fn rotationDidKeys(allocator: std.mem.Allocator, rotation_key: *const zat.Keypair) ![]const []const u8 { + const base_len: usize = if (config.recoveryDidKey() == null) 1 else 2; + const keys = try allocator.alloc([]const u8, base_len); + var idx: usize = 0; + if (config.recoveryDidKey()) |recovery| { + keys[idx] = recovery; + idx += 1; + } + keys[idx] = try rotation_key.did(allocator); + return keys; +} + pub fn createGenesisOperation( allocator: std.mem.Allocator, handle: []const u8, @@ -15,13 +35,13 @@ pub fn createGenesisOperation( rotation_key: *const zat.Keypair, ) !Operation { const signing_did_key = try signing_key.did(allocator); - const rotation_did_key = try rotation_key.did(allocator); - const unsigned = try unsignedOperationCbor(allocator, handle, signing_did_key, rotation_did_key); + const rotation_did_keys = try rotationDidKeys(allocator, rotation_key); + const unsigned = try unsignedOperationCbor(allocator, handle, signing_did_key, rotation_did_keys); const unsigned_bytes = try zat.cbor.encodeAlloc(allocator, unsigned); const signature = try rotation_key.sign(unsigned_bytes); const signature_text = try zat.jwt.base64UrlEncode(allocator, &signature.bytes); - const signed = try signedOperationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text); + const signed = try signedOperationCbor(allocator, handle, signing_did_key, rotation_did_keys, signature_text); const signed_bytes = try zat.cbor.encodeAlloc(allocator, signed); var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(signed_bytes, &digest, .{}); @@ -31,7 +51,7 @@ pub fn createGenesisOperation( return .{ .did = did, - .json = try operationJson(allocator, handle, signing_did_key, rotation_did_key, signature_text), + .json = try operationJson(allocator, handle, signing_did_key, rotation_did_keys, signature_text), }; } @@ -56,26 +76,26 @@ fn unsignedOperationCbor( allocator: std.mem.Allocator, handle: []const u8, signing_did_key: []const u8, - rotation_did_key: []const u8, + rotation_did_keys: []const []const u8, ) !zat.cbor.Value { - return operationCbor(allocator, handle, signing_did_key, rotation_did_key, null); + return operationCbor(allocator, handle, signing_did_key, rotation_did_keys, null); } fn signedOperationCbor( allocator: std.mem.Allocator, handle: []const u8, signing_did_key: []const u8, - rotation_did_key: []const u8, + rotation_did_keys: []const []const u8, signature_text: []const u8, ) !zat.cbor.Value { - return operationCbor(allocator, handle, signing_did_key, rotation_did_key, signature_text); + return operationCbor(allocator, handle, signing_did_key, rotation_did_keys, signature_text); } fn operationCbor( allocator: std.mem.Allocator, handle: []const u8, signing_did_key: []const u8, - rotation_did_key: []const u8, + rotation_did_keys: []const []const u8, signature_text: ?[]const u8, ) !zat.cbor.Value { const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); @@ -89,8 +109,10 @@ fn operationCbor( const verification_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1); verification_entries[0] = .{ .key = "atproto", .value = .{ .text = signing_did_key } }; - const rotation_items = try allocator.alloc(zat.cbor.Value, 1); - rotation_items[0] = .{ .text = rotation_did_key }; + const rotation_items = try allocator.alloc(zat.cbor.Value, rotation_did_keys.len); + for (rotation_did_keys, 0..) |key, idx| { + rotation_items[idx] = .{ .text = key }; + } const aka_items = try allocator.alloc(zat.cbor.Value, 1); aka_items[0] = .{ .text = also_known_as }; @@ -110,15 +132,16 @@ fn operationJson( allocator: std.mem.Allocator, handle: []const u8, signing_did_key: []const u8, - rotation_did_key: []const u8, + rotation_did_keys: []const []const u8, signature_text: []const u8, ) ![]const u8 { const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); + const rotation_json = try jsonStringArray(allocator, rotation_did_keys); return std.fmt.allocPrint( allocator, - "{{\"type\":\"plc_operation\",\"rotationKeys\":[{f}],\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}", + "{{\"type\":\"plc_operation\",\"rotationKeys\":{s},\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}", .{ - std.json.fmt(rotation_did_key, .{}), + rotation_json, std.json.fmt(signing_did_key, .{}), std.json.fmt(also_known_as, .{}), std.json.fmt(config.publicUrl(), .{}), @@ -127,6 +150,18 @@ fn operationJson( ); } +fn jsonStringArray(allocator: std.mem.Allocator, items: []const []const u8) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try out.writer.writeByte('['); + for (items, 0..) |item, idx| { + if (idx != 0) try out.writer.writeByte(','); + try out.writer.print("{f}", .{std.json.fmt(item, .{})}); + } + try out.writer.writeByte(']'); + return out.toOwnedSlice(); +} + fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); @@ -164,7 +199,8 @@ test "PLC genesis operation is signed and derives a did:plc" { const sig_text = parsed.value.object.get("sig").?.string; const sig_bytes = try zat.jwt.base64UrlDecode(a, sig_text); const did_key = try keypair.did(a); - const unsigned = try unsignedOperationCbor(a, "alice.example.com", did_key, did_key); + const rotation_keys = [_][]const u8{did_key}; + const unsigned = try unsignedOperationCbor(a, "alice.example.com", did_key, &rotation_keys); const unsigned_bytes = try zat.cbor.encodeAlloc(a, unsigned); try zat.multicodec.verifyDidKeySignature(a, did_key, unsigned_bytes, sig_bytes); } diff --git a/src/atproto/server.zig b/src/atproto/server.zig index c4ed51c..26bbe15 100644 --- a/src/atproto/server.zig +++ b/src/atproto/server.zig @@ -48,15 +48,34 @@ pub fn atprotoDid(request: *http.Server.Request) !void { return plain(request, .ok, account.did); } +pub fn reserveSigningKey(request: *http.Server.Request) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); + const did = if (body.len == 0) null else did: { + const parsed = try http_api.parseJsonBody(request, allocator, body); + break :did zat.json.getString(parsed.value, "did"); + }; + if (did) |value| { + if (zat.Did.parse(value) == null) { + return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); + } + } + const reserved = try store.reserveSigningKey(allocator, did); + const body_out = try std.fmt.allocPrint(allocator, "{{\"signingKey\":{f}}}", .{std.json.fmt(reserved.signing_key, .{})}); + return http_api.json(request, .ok, body_out); +} + pub fn createAccount(request: *http.Server.Request) !void { const authorization = http_api.headerValue(request, "authorization"); - var body_buf: [8192]u8 = undefined; - const body = try http_api.readBody(request, &body_buf); var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); + const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const handle = zat.json.getString(parsed.value, "handle") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); @@ -83,14 +102,27 @@ pub fn createAccount(request: *http.Server.Request) !void { } const existing_did = zat.json.getString(parsed.value, "did"); + const plc_op = jsonObjectField(parsed.value, "plcOp"); + const signing_key_input = zat.json.getString(parsed.value, "signingKey"); const account = if (existing_did) |did| account: { if (zat.Did.parse(did) == null) { log.debug("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle }); return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); } log.debug("xrpc createAccount migration attempt did={s} handle={s}\n", .{ did, handle }); - try verifyCreateAccountServiceAuth(request, allocator, authorization, did); - break :account store.createAccountWithSigningKeyAndInvite(allocator, handle, email, password, did, false, try store.generateAccountSigningKey(), invite_code) catch |err| switch (err) { + const signing_key = if (plc_op) |operation| key: { + const expected_key = signing_key_input orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "signingKey is required when plcOp is provided"); + try validateCreateAccountPlcOperation(request, allocator, handle, operation, expected_key); + const reserved_key = store.consumeReservedSigningKey(expected_key, did) catch |err| switch (err) { + error.MissingReservedSigningKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "reserved signing key does not exist"), + else => return err, + }; + break :key reserved_key; + } else key: { + try verifyCreateAccountServiceAuth(request, allocator, authorization, did); + break :key try store.generateAccountSigningKey(); + }; + break :account store.createAccountWithSigningKeyAndInvite(allocator, handle, email, password, did, plc_op != null, signing_key, invite_code) catch |err| switch (err) { error.InvalidInviteCode => return http_api.xrpcError(request, .bad_request, "InvalidInviteCode", "Provided invite code not available"), else => { log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle }); @@ -107,7 +139,11 @@ pub fn createAccount(request: *http.Server.Request) !void { log.err("xrpc createAccount failed signing_key_parse handle={s}\n", .{handle}); return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to prepare account signing key"); }; - const operation = plc.createGenesisOperation(allocator, handle, &keypair, &keypair) catch { + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), + }; + const operation = plc.createGenesisOperation(allocator, handle, &keypair, &rotation_keypair) catch { log.err("xrpc createAccount failed plc_operation handle={s}\n", .{handle}); return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to create DID operation"); }; @@ -262,6 +298,92 @@ fn jsonInt(value: std.json.Value, key: []const u8) ?i64 { }; } +fn jsonObjectField(value: std.json.Value, key: []const u8) ?std.json.Value { + return switch (value) { + .object => |object| object.get(key), + else => null, + }; +} + +fn validateCreateAccountPlcOperation( + request: *http.Server.Request, + allocator: std.mem.Allocator, + handle: []const u8, + operation: std.json.Value, + signing_did_key: []const u8, +) !void { + const object = switch (operation) { + .object => |object| object, + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid plc operation"), + }; + if (!jsonObjectStringEquals(operation, "type", "plc_operation")) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid plc operation type"); + } + if (!jsonObjectStringEquals(object.get("verificationMethods") orelse .null, "atproto", signing_did_key)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not use reserved signing key"); + } + var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { + error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), + error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), + }; + const server_rotation_key = try rotation_keypair.did(allocator); + if (!jsonArrayContainsString(object.get("rotationKeys"), server_rotation_key)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not include server rotation key"); + } + const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); + if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), also_known_as)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation handle does not match account handle"); + } + const services = object.get("services") orelse { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation missing services"); + }; + const atproto_pds = switch (services) { + .object => |services_object| services_object.get("atproto_pds") orelse { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation missing atproto_pds service"); + }, + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation services must be an object"), + }; + if (!jsonObjectStringEquals(atproto_pds, "type", "AtprotoPersonalDataServer") or + !jsonObjectStringEquals(atproto_pds, "endpoint", config.publicUrl())) + { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation service does not match this PDS"); + } +} + +fn jsonObjectStringEquals(value: std.json.Value, key: []const u8, expected: []const u8) bool { + return switch (value) { + .object => |object| switch (object.get(key) orelse return false) { + .string => |string| std.mem.eql(u8, string, expected), + else => false, + }, + else => false, + }; +} + +fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool { + const items = switch (value orelse return false) { + .array => |array| array.items, + else => return false, + }; + for (items) |item| switch (item) { + .string => |string| if (std.mem.eql(u8, string, expected)) return true, + else => {}, + }; + return false; +} + +fn jsonArrayFirstStringEquals(value: ?std.json.Value, expected: []const u8) bool { + const items = switch (value orelse return false) { + .array => |array| array.items, + else => return false, + }; + if (items.len == 0) return false; + return switch (items[0]) { + .string => |string| std.mem.eql(u8, string, expected), + else => false, + }; +} + fn writeInviteCodeGroup(allocator: std.mem.Allocator, writer: anytype, for_account: []const u8, code_count: i64, use_count: i64, created_by: []const u8) !void { const codes = store.createInviteCodes(allocator, config.publicUrl(), code_count, use_count, for_account, created_by) catch |err| switch (err) { error.InvalidUseCount => return error.InvalidInviteInput, diff --git a/src/core/config.zig b/src/core/config.zig index ce03632..f783b2f 100644 --- a/src/core/config.zig +++ b/src/core/config.zig @@ -3,6 +3,8 @@ const std = @import("std"); var public_url_value: []const u8 = "http://localhost:2583"; var server_did_value: []const u8 = "did:web:localhost"; var plc_directory_value: []const u8 = "https://plc.directory"; +var plc_rotation_key_value: ?[]const u8 = null; +var recovery_did_key_value: ?[]const u8 = null; var resend_api_key_value: ?[]const u8 = null; var email_from_value: ?[]const u8 = null; var blob_upload_limit_value: usize = 100_000_000; @@ -32,6 +34,14 @@ pub fn plcDirectory() []const u8 { return plc_directory_value; } +pub fn plcRotationKey() ?[]const u8 { + return plc_rotation_key_value; +} + +pub fn recoveryDidKey() ?[]const u8 { + return recovery_did_key_value; +} + pub fn resendApiKey() ?[]const u8 { return resend_api_key_value; } @@ -88,6 +98,14 @@ pub fn setPlcDirectory(value: []const u8) void { plc_directory_value = trimTrailingSlash(value); } +pub fn setPlcRotationKey(value: ?[]const u8) void { + plc_rotation_key_value = value; +} + +pub fn setRecoveryDidKey(value: ?[]const u8) void { + recovery_did_key_value = value; +} + pub fn setResendApiKey(value: ?[]const u8) void { resend_api_key_value = value; } diff --git a/src/http/router.zig b/src/http/router.zig index 43bf9c3..559875b 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -19,6 +19,7 @@ pub const Route = enum { oauth_revoke, atproto_did, describe_server, + reserve_signing_key, create_account, create_invite_code, create_invite_codes, @@ -82,6 +83,7 @@ pub fn route(method: http.Method, target: []const u8) Route { if (method == .POST and std.mem.eql(u8, path, "/oauth/revoke")) return .oauth_revoke; if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.reserveSigningKey")) return .reserve_signing_key; if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAccount")) return .create_account; if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCode")) return .create_invite_code; if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCodes")) return .create_invite_codes; @@ -150,6 +152,7 @@ test "routes pds probes" { try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token")); try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); + try std.testing.expectEqual(Route.reserve_signing_key, route(.POST, "/xrpc/com.atproto.server.reserveSigningKey")); try std.testing.expectEqual(Route.create_account, route(.POST, "/xrpc/com.atproto.server.createAccount")); try std.testing.expectEqual(Route.create_invite_code, route(.POST, "/xrpc/com.atproto.server.createInviteCode")); try std.testing.expectEqual(Route.create_invite_codes, route(.POST, "/xrpc/com.atproto.server.createInviteCodes")); diff --git a/src/http/server.zig b/src/http/server.zig index 88a8251..7c56d0f 100644 --- a/src/http/server.zig +++ b/src/http/server.zig @@ -102,6 +102,7 @@ fn serveRequest(io: std.Io, request: *http.Server.Request) !void { .oauth_revoke => try atproto_oauth.revoke(request), .atproto_did => try atproto_server.atprotoDid(request), .describe_server => try atproto_server.describeServer(request), + .reserve_signing_key => try atproto_server.reserveSigningKey(request), .create_account => try atproto_server.createAccount(request), .create_invite_code => try atproto_server.createInviteCode(request), .create_invite_codes => try atproto_server.createInviteCodes(request), diff --git a/src/internal/cli.zig b/src/internal/cli.zig index 5a55d3c..b2a1cab 100644 --- a/src/internal/cli.zig +++ b/src/internal/cli.zig @@ -7,6 +7,8 @@ pub const Options = struct { public_url: ?[]const u8 = null, server_did: ?[]const u8 = null, plc_directory: ?[]const u8 = null, + plc_rotation_key: ?[]const u8 = null, + recovery_did_key: ?[]const u8 = null, email_from: ?[]const u8 = null, resend_api_key: ?[]const u8 = null, blob_upload_limit: ?usize = null, @@ -33,6 +35,8 @@ pub const ParseError = error{ MissingJwtSecret, MissingLogLevel, MissingPlcDirectory, + MissingPlcRotationKey, + MissingRecoveryDidKey, MissingPort, MissingPublicUrl, MissingResendApiKey, @@ -46,6 +50,8 @@ pub fn parse(init: std.process.Init) ParseError!Options { .public_url = env("ZDS_PUBLIC_URL"), .server_did = env("ZDS_SERVER_DID"), .plc_directory = env("ZDS_PLC_DIRECTORY"), + .plc_rotation_key = env("ZDS_PLC_ROTATION_KEY"), + .recovery_did_key = env("ZDS_RECOVERY_DID_KEY"), .email_from = env("ZDS_EMAIL_FROM"), .resend_api_key = env("ZDS_RESEND_API_KEY"), .blob_upload_limit = try envUsize("ZDS_BLOB_UPLOAD_LIMIT"), @@ -74,6 +80,7 @@ pub fn parse(init: std.process.Init) ParseError!Options { pub fn usage() void { std.debug.print( \\usage: zds [--host HOST] [--port PORT] [--db PATH] [--public-url URL] [--server-did DID] + \\ [--plc-rotation-key HEX] [--recovery-did-key DIDKEY] \\ [--blob-upload-limit BYTES] [--blobstore-path PATH] \\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET] \\ [--admin-token TOKEN] [--invite-required] @@ -119,6 +126,14 @@ fn parseSplitArg(options: *Options, arg: []const u8, args: *std.process.Args.Ite options.plc_directory = args.next() orelse return error.MissingPlcDirectory; return true; } + if (std.mem.eql(u8, arg, "--plc-rotation-key")) { + options.plc_rotation_key = args.next() orelse return error.MissingPlcRotationKey; + return true; + } + if (std.mem.eql(u8, arg, "--recovery-did-key")) { + options.recovery_did_key = args.next() orelse return error.MissingRecoveryDidKey; + return true; + } if (std.mem.eql(u8, arg, "--email-from")) { options.email_from = args.next() orelse return error.MissingEmailFrom; return true; @@ -187,6 +202,8 @@ const joined_string_options = [_]JoinedStringOption{ .{ .flag = "--public-url=", .field = "public_url" }, .{ .flag = "--server-did=", .field = "server_did" }, .{ .flag = "--plc-directory=", .field = "plc_directory" }, + .{ .flag = "--plc-rotation-key=", .field = "plc_rotation_key" }, + .{ .flag = "--recovery-did-key=", .field = "recovery_did_key" }, .{ .flag = "--email-from=", .field = "email_from" }, .{ .flag = "--resend-api-key=", .field = "resend_api_key" }, .{ .flag = "--blobstore-path=", .field = "blobstore_path" }, @@ -222,6 +239,9 @@ test "parse split and joined arguments" { "--port=8080", "--db", "dev/test.sqlite3", + "--plc-rotation-key=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "--recovery-did-key", + "did:key:zRecovery", "--debug", }; const init = std.process.Init{ .minimal = .{ .args = &argv } }; @@ -231,6 +251,8 @@ test "parse split and joined arguments" { try std.testing.expectEqualStrings("0.0.0.0", options.host); try std.testing.expectEqual(@as(u16, 8080), options.port); try std.testing.expectEqualStrings("dev/test.sqlite3", options.db_path); + try std.testing.expectEqualStrings("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", options.plc_rotation_key.?); + try std.testing.expectEqualStrings("did:key:zRecovery", options.recovery_did_key.?); try std.testing.expectEqualStrings("debug", options.log_level.?); } diff --git a/src/main.zig b/src/main.zig index 5a70bb3..5d1c203 100644 --- a/src/main.zig +++ b/src/main.zig @@ -22,6 +22,8 @@ pub fn main(init: std.process.Init) !void { if (options.public_url) |value| zds.core.config.setPublicUrl(value); if (options.server_did) |value| zds.core.config.setServerDid(value); if (options.plc_directory) |value| zds.core.config.setPlcDirectory(value); + zds.core.config.setPlcRotationKey(options.plc_rotation_key); + zds.core.config.setRecoveryDidKey(options.recovery_did_key); zds.core.config.setEmailFrom(options.email_from); zds.core.config.setResendApiKey(options.resend_api_key); if (options.blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value); diff --git a/src/storage/store.zig b/src/storage/store.zig index ce6316d..9c01e9c 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -13,7 +13,9 @@ pub const Error = error{ InvalidCollection, InvalidRecordKey, InvalidRecordType, + InvalidReservedSigningKey, MissingRecord, + MissingReservedSigningKey, MissingRecordBlock, RepoNotFound, InvalidRepoPath, @@ -96,6 +98,13 @@ pub const InviteCode = struct { uses: []InviteCodeUse, }; +pub const ReservedSigningKey = struct { + did: ?[]const u8, + signing_key: []const u8, + secret_key: [32]u8, + expires_at: i64, +}; + pub const InviteCodeUse = struct { used_by: []const u8, used_at: i64, @@ -505,6 +514,33 @@ pub fn generateAccountSigningKey() ![32]u8 { return generateSigningKey(); } +pub fn reserveSigningKey(allocator: std.mem.Allocator, did: ?[]const u8) !ReservedSigningKey { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + const secret_key = try generateSigningKey(); + var keypair = try zat.Keypair.fromSecretKey(.secp256k1, secret_key); + const signing_key = try keypair.did(allocator); + const expires_at = nowMs() + (24 * 60 * 60 * 1000); + try conn.exec( + \\INSERT INTO reserved_signing_keys (did, signing_key, signing_key_type, secret_key, expires_at) + \\VALUES (?, ?, 'secp256k1', ?, ?) + , .{ did, signing_key, zqlite.blob(&secret_key), expires_at }); + return .{ + .did = if (did) |value| try allocator.dupe(u8, value) else null, + .signing_key = signing_key, + .secret_key = secret_key, + .expires_at = expires_at, + }; +} + +pub fn consumeReservedSigningKey(signing_key_or_did: []const u8, did: []const u8) ![32]u8 { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + return consumeReservedSigningKeyLocked(signing_key_or_did, did); +} + pub fn createInviteCode(allocator: std.mem.Allocator, public_url: []const u8, use_count: i64, for_account: []const u8, created_by: []const u8) ![]const u8 { if (use_count < 1) return error.InvalidUseCount; db_mutex.lockUncancelable(store_io); @@ -2516,6 +2552,31 @@ fn signingKeypairLocked(did: []const u8) !zat.Keypair { return zat.Keypair.fromSecretKey(key_type, key_bytes); } +fn consumeReservedSigningKeyLocked(signing_key_or_did: []const u8, did: []const u8) ![32]u8 { + const row = try conn.row( + \\SELECT signing_key, secret_key, expires_at + \\FROM reserved_signing_keys + \\WHERE used_at IS NULL + \\ AND expires_at > ? + \\ AND (signing_key = ? OR did = ?) + \\ORDER BY created_at DESC + \\LIMIT 1 + , .{ nowMs(), signing_key_or_did, did }); + if (row == null) return Error.MissingReservedSigningKey; + defer row.?.deinit(); + + const signing_key = row.?.text(0); + const secret = row.?.blob(1); + if (secret.len != 32) return Error.InvalidReservedSigningKey; + const key_bytes = secret[0..32].*; + try conn.exec( + \\UPDATE reserved_signing_keys + \\SET used_at = ? + \\WHERE signing_key = ? + , .{ nowMs(), signing_key }); + return key_bytes; +} + fn generateSigningKey() ![32]u8 { var key: [32]u8 = undefined; while (true) { @@ -3261,6 +3322,19 @@ const schema_statements = [_][*:0]const u8{ \\ PRIMARY KEY (code, used_by) \\) , + \\CREATE TABLE IF NOT EXISTS reserved_signing_keys ( + \\ id INTEGER PRIMARY KEY AUTOINCREMENT, + \\ did TEXT, + \\ signing_key TEXT NOT NULL UNIQUE, + \\ signing_key_type TEXT NOT NULL, + \\ secret_key BLOB NOT NULL, + \\ expires_at INTEGER NOT NULL, + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + \\ used_at INTEGER + \\) + , + "CREATE INDEX IF NOT EXISTS reserved_signing_keys_did_idx ON reserved_signing_keys (did) WHERE did IS NOT NULL", + "CREATE INDEX IF NOT EXISTS reserved_signing_keys_expires_idx ON reserved_signing_keys (expires_at) WHERE used_at IS NULL", }; const post_schema_statements = [_][*:0]const u8{ @@ -3491,6 +3565,24 @@ test "invite codes gate account creation and record uses" { try std.testing.expectEqualStrings("did:plc:invited", codes[0].uses[0].used_by); } +test "reserved signing keys are one-use account signing keys" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + try init(std.Options.debug_io, ":memory:"); + defer close(); + + const did = "did:plc:reserved"; + const reserved = try reserveSigningKey(allocator, did); + try std.testing.expectEqualStrings(did, reserved.did.?); + try std.testing.expect(std.mem.startsWith(u8, reserved.signing_key, "did:key:")); + + const consumed = try consumeReservedSigningKey(reserved.signing_key, did); + try std.testing.expectEqualSlices(u8, &reserved.secret_key, &consumed); + try std.testing.expectError(error.MissingReservedSigningKey, consumeReservedSigningKey(reserved.signing_key, did)); +} + test "commit event encoding owns returned firehose op entries" { const allocator = std.testing.allocator; const cid = "bafyreifmpxapiafzeml4ns5nedutwbsnjw5obdganycdy6mhmb2mxth5aq"; -- 2.51.2