atproto pds in zig pds.zat.dev
pds atproto
Something went wrong. Try again.
Zig
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418const std = @import("std");const config = @import("../core/config.zig");const store = @import("../storage/store.zig");const zat = @import("zat");
pub const Operation = struct { did: []const u8, json: []const u8,};
pub fn configuredRotationKeypair() !zat.Keypair { const key = config.plcRotationKey() orelse return error.MissingPlcRotationKey; return parsePrivateKey(std.heap.smp_allocator, key) catch error.InvalidPlcRotationKey;}
pub fn parsePrivateKey(allocator: std.mem.Allocator, key: []const u8) !zat.Keypair { if (key.len == 64 and isHex(key)) { return parseHexPrivateKey(key); } if (key.len >= 2 and key[0] == 'z') { return parsePrivateMultikey(allocator, key); } return error.InvalidPrivateKey;}
fn parseHexPrivateKey(hex: []const u8) !zat.Keypair { var secret: [32]u8 = undefined; _ = std.fmt.hexToBytes(&secret, hex) catch return error.InvalidPrivateKey; return zat.Keypair.fromSecretKey(.secp256k1, secret) catch error.InvalidPrivateKey;}
fn parsePrivateMultikey(allocator: std.mem.Allocator, key: []const u8) !zat.Keypair { const decoded = try zat.multibase.decode(allocator, key); defer allocator.free(decoded); if (decoded.len != 34) return error.InvalidPrivateKey;
const key_type: zat.multicodec.KeyType = if (decoded[0] == 0x81 and decoded[1] == 0x26) .secp256k1 else if (decoded[0] == 0x86 and decoded[1] == 0x26) .p256 else return error.InvalidPrivateKey;
return zat.Keypair.fromSecretKey(key_type, decoded[2..34].*) catch error.InvalidPrivateKey;}
fn isHex(input: []const u8) bool { for (input) |c| switch (c) { '0'...'9', 'a'...'f', 'A'...'F' => {}, else => return false, }; return true;}
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, signing_key: *const zat.Keypair, rotation_key: *const zat.Keypair,) !Operation { const signing_did_key = try signing_key.did(allocator); 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_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, .{}); const encoded = try zat.multibase.base32lower.encode(allocator, &digest); const did_hash = if (encoded.len > 0 and encoded[0] == 'b') encoded[1..] else encoded; const did = try std.fmt.allocPrint(allocator, "did:plc:{s}", .{did_hash[0..24]});
return .{ .did = did, .json = try operationJson(allocator, handle, signing_did_key, rotation_did_keys, signature_text), };}
pub fn submitOperation(allocator: std.mem.Allocator, did: []const u8, operation_json: []const u8) !void { const escaped_did = try percentEncodeDid(allocator, did); const url = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ config.plcDirectory(), escaped_did }); var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); const result = try transport.fetch(.{ .url = url, .method = .POST, .payload = operation_json, .content_type = "application/json", .max_response_size = 64 * 1024, }); if (@backingInt(result.status) < 200 or @backingInt(result.status) >= 300) { return error.PlcSubmissionRejected; }}
pub fn fetchLastOperation(allocator: std.mem.Allocator, did: []const u8) !std.json.Value { const escaped_did = try percentEncodeDid(allocator, did); const url = try std.fmt.allocPrint(allocator, "{s}/{s}/log/last", .{ config.plcDirectory(), escaped_did }); var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); const result = try transport.fetch(.{ .url = url, .method = .GET, .accept = "application/json", .max_response_size = 1024 * 1024, }); if (@backingInt(result.status) < 200 or @backingInt(result.status) >= 300) return error.PlcFetchFailed; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); return parsed.value;}
pub fn updateOperationJson( allocator: std.mem.Allocator, handle: []const u8, signing_did_key: []const u8, rotation_keys: []const []const u8, prev: []const u8,) ![]const u8 { const rotation_keys_json = try jsonStringArray(allocator, rotation_keys); return std.fmt.allocPrint( allocator, "{{\"type\":\"plc_operation\",\"rotationKeys\":{s},\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":{f}}}", .{ rotation_keys_json, std.json.fmt(signing_did_key, .{}), std.json.fmt(try std.fmt.allocPrint(allocator, "at://{s}", .{handle}), .{}), std.json.fmt(config.publicUrl(), .{}), std.json.fmt(prev, .{}), }, );}
pub fn updateHandleOperationJson( allocator: std.mem.Allocator, last_op: std.json.Value, handle: []const u8, prev: []const u8,) ![]const u8 { const last = switch (last_op) { .object => |object| object, else => return error.InvalidPlcOperation, }; const rotation_keys = last.get("rotationKeys") orelse return error.InvalidPlcOperation; const verification_methods = last.get("verificationMethods") orelse return error.InvalidPlcOperation; const services = last.get("services") orelse return error.InvalidPlcOperation; const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle});
return std.fmt.allocPrint( allocator, "{{\"type\":\"plc_operation\",\"rotationKeys\":{s},\"verificationMethods\":{s},\"alsoKnownAs\":[{f}],\"services\":{s},\"prev\":{f}}}", .{ try stringifyJson(allocator, rotation_keys), try stringifyJson(allocator, verification_methods), std.json.fmt(also_known_as, .{}), try stringifyJson(allocator, services), std.json.fmt(prev, .{}), }, );}
pub fn signOperationJson(allocator: std.mem.Allocator, unsigned_json: []const u8, signing_key: *const zat.Keypair) ![]const u8 { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); const cbor_value = try jsonToCbor(allocator, parsed.value); const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value); const sig = try signing_key.sign(encoded); const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes); return std.fmt.allocPrint(allocator, "{s},\"sig\":{f}}}", .{ unsigned_json[0 .. unsigned_json.len - 1], std.json.fmt(sig_text, .{}) });}
pub fn cidForJsonValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { const cbor_value = try jsonToCbor(allocator, value); const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value); const cid = try zat.cbor.Cid.forDagCbor(allocator, encoded); return zat.multibase.base32lower.encode(allocator, cid.raw);}
fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.print("{f}", .{std.json.fmt(value, .{})}); return out.toOwnedSlice();}
fn unsignedOperationCbor( allocator: std.mem.Allocator, handle: []const u8, signing_did_key: []const u8, rotation_did_keys: []const []const u8,) !zat.cbor.Value { 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_keys: []const []const u8, signature_text: []const u8,) !zat.cbor.Value { 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_keys: []const []const u8, signature_text: ?[]const u8,) !zat.cbor.Value { const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); const service_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 2); service_entries[0] = .{ .key = "type", .value = .{ .text = "AtprotoPersonalDataServer" } }; service_entries[1] = .{ .key = "endpoint", .value = .{ .text = config.publicUrl() } };
const services_entries = try allocator.alloc(zat.cbor.Value.MapEntry, 1); services_entries[0] = .{ .key = "atproto_pds", .value = .{ .map = service_entries } };
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, 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 };
const entry_count: usize = if (signature_text == null) 6 else 7; const entries = try allocator.alloc(zat.cbor.Value.MapEntry, entry_count); entries[0] = .{ .key = "type", .value = .{ .text = "plc_operation" } }; entries[1] = .{ .key = "rotationKeys", .value = .{ .array = rotation_items } }; entries[2] = .{ .key = "verificationMethods", .value = .{ .map = verification_entries } }; entries[3] = .{ .key = "alsoKnownAs", .value = .{ .array = aka_items } }; entries[4] = .{ .key = "services", .value = .{ .map = services_entries } }; entries[5] = .{ .key = "prev", .value = .null }; if (signature_text) |sig| entries[6] = .{ .key = "sig", .value = .{ .text = sig } }; return .{ .map = entries };}
fn operationJson( allocator: std.mem.Allocator, handle: []const u8, signing_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\":{s},\"verificationMethods\":{{\"atproto\":{f}}},\"alsoKnownAs\":[{f}],\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}},\"prev\":null,\"sig\":{f}}}", .{ rotation_json, std.json.fmt(signing_did_key, .{}), std.json.fmt(also_known_as, .{}), std.json.fmt(config.publicUrl(), .{}), std.json.fmt(signature_text, .{}), }, );}
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 jsonToCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value { return switch (value) { .null => .null, .bool => |boolean| .{ .boolean = boolean }, .integer => |integer| if (integer >= 0) .{ .unsigned = @intCast(integer) } else .{ .negative = integer }, .float => return error.InvalidDagCbor, .number_string => return error.InvalidDagCbor, .string => |string| .{ .text = string }, .array => |array| blk: { const items = try allocator.alloc(zat.cbor.Value, array.items.len); for (array.items, 0..) |item, idx| items[idx] = try jsonToCbor(allocator, item); break :blk .{ .array = items }; }, .object => |object| blk: { const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count()); var it = object.iterator(); var idx: usize = 0; while (it.next()) |entry| : (idx += 1) { entries[idx] = .{ .key = entry.key_ptr.*, .value = try jsonToCbor(allocator, entry.value_ptr.*), }; } break :blk .{ .map = entries }; }, };}
fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); for (did) |c| switch (c) { ':' => try out.writer.writeAll("%3A"), else => try out.writer.writeByte(c), }; return out.toOwnedSlice();}
test "PLC genesis operation is signed and derives a did:plc" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); const before = config.publicUrl(); defer config.setPublicUrl(before); config.setPublicUrl("https://pds.example.com");
const secret: [32]u8 = .{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, }; const keypair = try zat.Keypair.fromSecretKey(.secp256k1, secret); const op = try createGenesisOperation(a, "alice.example.com", &keypair, &keypair); try std.testing.expect(std.mem.startsWith(u8, op.did, "did:plc:")); try std.testing.expectEqual(@as(usize, "did:plc:".len + 24), op.did.len); try std.testing.expect(std.mem.indexOf(u8, op.json, "\"type\":\"plc_operation\"") != null); try std.testing.expect(std.mem.indexOf(u8, op.json, "\"sig\"") != null); try std.testing.expect(std.mem.indexOf(u8, op.json, "https://pds.example.com") != null);
const parsed = try std.json.parseFromSlice(std.json.Value, a, op.json, .{}); 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 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);}
test "PLC private key parser accepts official hex and PDS Moover private multikey" { const allocator = std.testing.allocator; const hex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; const from_hex = try parsePrivateKey(allocator, hex);
var secret: [32]u8 = undefined; _ = try std.fmt.hexToBytes(&secret, hex); var private_multikey_bytes: [34]u8 = undefined; private_multikey_bytes[0] = 0x81; private_multikey_bytes[1] = 0x26; @memcpy(private_multikey_bytes[2..], &secret);
const private_multikey = try zat.multibase.encode(allocator, .base58btc, &private_multikey_bytes); defer allocator.free(private_multikey);
const from_multikey = try parsePrivateKey(allocator, private_multikey); const hex_did = try from_hex.did(allocator); defer allocator.free(hex_did); const multikey_did = try from_multikey.did(allocator); defer allocator.free(multikey_did);
try std.testing.expectEqualStrings(hex_did, multikey_did);}
test "handle update operation preserves current PLC authority and services" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
const current = \\{"type":"plc_operation","rotationKeys":["did:key:recovery","did:key:pds"],"verificationMethods":{"atproto":"did:key:account","extra":"did:key:extra"},"alsoKnownAs":["at://old.example.com"],"services":{"atproto_pds":{"type":"AtprotoPersonalDataServer","endpoint":"https://old-pds.example.com"},"other":{"type":"Other","endpoint":"https://service.example.com"}},"prev":"bafyprev","sig":"old-signature"} ; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, current, .{}); const updated = try updateHandleOperationJson(allocator, parsed.value, "new.example.com", "bafycurrent"); const operation = try std.json.parseFromSlice(std.json.Value, allocator, updated, .{});
try std.testing.expectEqualStrings( "at://new.example.com", operation.value.object.get("alsoKnownAs").?.array.items[0].string, ); try std.testing.expectEqualStrings( "did:key:recovery", operation.value.object.get("rotationKeys").?.array.items[0].string, ); try std.testing.expectEqualStrings( "did:key:extra", operation.value.object.get("verificationMethods").?.object.get("extra").?.string, ); try std.testing.expectEqualStrings( "https://service.example.com", operation.value.object.get("services").?.object.get("other").?.object.get("endpoint").?.string, ); try std.testing.expectEqualStrings("bafycurrent", operation.value.object.get("prev").?.string); try std.testing.expect(operation.value.object.get("sig") == null);}