atproto utils for zig
zat.dev
atproto
sdk
zig
1//! multicodec key parsing
2//!
3//! parses multicodec-prefixed public keys from DID documents.
4//! extracts key type and raw key bytes.
5//!
6//! see: https://github.com/multiformats/multicodec
7
8const std = @import("std");
9
10/// supported key types for AT Protocol
11pub const KeyType = enum {
12 secp256k1, // ES256K - used by most AT Protocol accounts
13 p256, // ES256 - also supported
14};
15
16/// parsed public key with type and raw bytes
17pub const PublicKey = struct {
18 key_type: KeyType,
19 /// raw compressed public key (33 bytes for secp256k1/p256)
20 raw: []const u8,
21};
22
23/// multicodec prefixes (unsigned varint encoding)
24/// secp256k1-pub: 0xe7 = 231, varint encoded as 0xe7 0x01 (2 bytes)
25/// p256-pub: 0x1200 = 4608, varint encoded as 0x80 0x24 (2 bytes)
26/// parse a multicodec-prefixed public key
27/// returns the key type and a slice pointing to the raw key bytes
28pub fn parsePublicKey(data: []const u8) !PublicKey {
29 if (data.len < 2) return error.TooShort;
30
31 // check for secp256k1-pub (varint 0xe7 = 231 encoded as 0xe7 0x01)
32 if (data.len >= 2 and data[0] == 0xe7 and data[1] == 0x01) {
33 const raw = data[2..];
34 if (raw.len != 33) return error.InvalidKeyLength;
35 return .{
36 .key_type = .secp256k1,
37 .raw = raw,
38 };
39 }
40
41 // check for p256-pub (varint 0x1200 = 4608 encoded as 0x80 0x24)
42 if (data.len >= 2 and data[0] == 0x80 and data[1] == 0x24) {
43 const raw = data[2..];
44 if (raw.len != 33) return error.InvalidKeyLength;
45 return .{
46 .key_type = .p256,
47 .raw = raw,
48 };
49 }
50
51 return error.UnsupportedKeyType;
52}
53
54/// encode a raw public key with multicodec prefix
55pub fn encodePublicKey(allocator: std.mem.Allocator, key_type: KeyType, raw: []const u8) ![]u8 {
56 if (raw.len != 33) return error.InvalidKeyLength;
57
58 const result = try allocator.alloc(u8, 2 + raw.len);
59 switch (key_type) {
60 .secp256k1 => {
61 result[0] = 0xe7;
62 result[1] = 0x01;
63 },
64 .p256 => {
65 result[0] = 0x80;
66 result[1] = 0x24;
67 },
68 }
69 @memcpy(result[2..], raw);
70 return result;
71}
72
73/// format a raw public key as a did:key string
74pub fn formatDidKey(allocator: std.mem.Allocator, key_type: KeyType, raw: []const u8) ![]u8 {
75 const multibase = @import("multibase.zig");
76
77 const mc_bytes = try encodePublicKey(allocator, key_type, raw);
78 defer allocator.free(mc_bytes);
79
80 const multibase_str = try multibase.encode(allocator, .base58btc, mc_bytes);
81 defer allocator.free(multibase_str);
82
83 // "did:key:" + multibase string (which already has 'z' prefix)
84 const result = try allocator.alloc(u8, did_key_prefix.len + multibase_str.len);
85 @memcpy(result[0..did_key_prefix.len], did_key_prefix);
86 @memcpy(result[did_key_prefix.len..], multibase_str);
87 return result;
88}
89
90const did_key_prefix = "did:key:";
91
92/// parse a did:key string into key type and raw public key bytes.
93/// caller owns the returned slice (raw field).
94pub fn parseDidKey(allocator: std.mem.Allocator, did: []const u8) !struct { key_type: KeyType, raw: []u8 } {
95 const multibase = @import("multibase.zig");
96
97 if (!std.mem.startsWith(u8, did, did_key_prefix)) return error.InvalidDidKey;
98 const multibase_str = did[did_key_prefix.len..];
99 if (multibase_str.len == 0) return error.InvalidDidKey;
100
101 const mc_bytes = try multibase.decode(allocator, multibase_str);
102 defer allocator.free(mc_bytes);
103
104 const parsed = try parsePublicKey(mc_bytes);
105 const raw = try allocator.dupe(u8, parsed.raw);
106 return .{ .key_type = parsed.key_type, .raw = raw };
107}
108
109/// verify an ECDSA signature given a did:key string.
110/// dispatches to the correct curve based on the key type encoded in the did:key.
111pub fn verifyDidKeySignature(allocator: std.mem.Allocator, did: []const u8, message: []const u8, sig_bytes: []const u8) !void {
112 const jwt = @import("jwt.zig");
113
114 const parsed = try parseDidKey(allocator, did);
115 defer allocator.free(parsed.raw);
116
117 switch (parsed.key_type) {
118 .secp256k1 => try jwt.verifySecp256k1(message, sig_bytes, parsed.raw),
119 .p256 => try jwt.verifyP256(message, sig_bytes, parsed.raw),
120 }
121}
122
123// === tests ===
124
125test "parse secp256k1 key" {
126 // 0xe7 0x01 prefix (varint) + 33-byte compressed key
127 var data: [35]u8 = undefined;
128 data[0] = 0xe7;
129 data[1] = 0x01;
130 data[2] = 0x02; // compressed point prefix
131 @memset(data[3..], 0xaa);
132
133 const key = try parsePublicKey(&data);
134 try std.testing.expectEqual(KeyType.secp256k1, key.key_type);
135 try std.testing.expectEqual(@as(usize, 33), key.raw.len);
136}
137
138test "parse p256 key" {
139 // 0x80 0x24 prefix + 33-byte compressed key
140 var data: [35]u8 = undefined;
141 data[0] = 0x80;
142 data[1] = 0x24;
143 data[2] = 0x03; // compressed point prefix
144 @memset(data[3..], 0xbb);
145
146 const key = try parsePublicKey(&data);
147 try std.testing.expectEqual(KeyType.p256, key.key_type);
148 try std.testing.expectEqual(@as(usize, 33), key.raw.len);
149}
150
151test "reject unsupported key type" {
152 const data = [_]u8{ 0xff, 0x02, 0x00 };
153 try std.testing.expectError(error.UnsupportedKeyType, parsePublicKey(&data));
154}
155
156test "reject too short" {
157 const data = [_]u8{0xe7};
158 try std.testing.expectError(error.TooShort, parsePublicKey(&data));
159}
160
161test "encode-decode round-trip secp256k1" {
162 const alloc = std.testing.allocator;
163 var raw: [33]u8 = undefined;
164 raw[0] = 0x02;
165 @memset(raw[1..], 0xaa);
166
167 const encoded = try encodePublicKey(alloc, .secp256k1, &raw);
168 defer alloc.free(encoded);
169
170 const parsed = try parsePublicKey(encoded);
171 try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type);
172 try std.testing.expectEqualSlices(u8, &raw, parsed.raw);
173}
174
175test "did:key round-trip secp256k1" {
176 const alloc = std.testing.allocator;
177 const multibase = @import("multibase.zig");
178
179 var raw: [33]u8 = undefined;
180 raw[0] = 0x02;
181 @memset(raw[1..], 0xcc);
182
183 const did_key_str = try formatDidKey(alloc, .secp256k1, &raw);
184 defer alloc.free(did_key_str);
185
186 // should start with "did:key:z"
187 try std.testing.expect(std.mem.startsWith(u8, did_key_str, "did:key:z"));
188
189 // parse back: strip "did:key:" prefix, decode multibase, parse multicodec
190 const multibase_str = did_key_str["did:key:".len..];
191 const mc_bytes = try multibase.decode(alloc, multibase_str);
192 defer alloc.free(mc_bytes);
193
194 const parsed = try parsePublicKey(mc_bytes);
195 try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type);
196 try std.testing.expectEqualSlices(u8, &raw, parsed.raw);
197}
198
199test "did:key round-trip p256" {
200 const alloc = std.testing.allocator;
201 const multibase = @import("multibase.zig");
202
203 var raw: [33]u8 = undefined;
204 raw[0] = 0x03;
205 @memset(raw[1..], 0xdd);
206
207 const did_key_str = try formatDidKey(alloc, .p256, &raw);
208 defer alloc.free(did_key_str);
209
210 const multibase_str = did_key_str["did:key:".len..];
211 const mc_bytes = try multibase.decode(alloc, multibase_str);
212 defer alloc.free(mc_bytes);
213
214 const parsed = try parsePublicKey(mc_bytes);
215 try std.testing.expectEqual(KeyType.p256, parsed.key_type);
216 try std.testing.expectEqualSlices(u8, &raw, parsed.raw);
217}
218
219test "parseDidKey round-trip secp256k1" {
220 const alloc = std.testing.allocator;
221
222 var raw: [33]u8 = undefined;
223 raw[0] = 0x02;
224 @memset(raw[1..], 0xcc);
225
226 const did_str = try formatDidKey(alloc, .secp256k1, &raw);
227 defer alloc.free(did_str);
228
229 const parsed = try parseDidKey(alloc, did_str);
230 defer alloc.free(parsed.raw);
231
232 try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type);
233 try std.testing.expectEqualSlices(u8, &raw, parsed.raw);
234}
235
236test "parseDidKey round-trip p256" {
237 const alloc = std.testing.allocator;
238
239 var raw: [33]u8 = undefined;
240 raw[0] = 0x03;
241 @memset(raw[1..], 0xdd);
242
243 const did_str = try formatDidKey(alloc, .p256, &raw);
244 defer alloc.free(did_str);
245
246 const parsed = try parseDidKey(alloc, did_str);
247 defer alloc.free(parsed.raw);
248
249 try std.testing.expectEqual(KeyType.p256, parsed.key_type);
250 try std.testing.expectEqualSlices(u8, &raw, parsed.raw);
251}
252
253test "parseDidKey with real indigo test vector" {
254 // from bluesky-social/indigo jwt test fixtures
255 const alloc = std.testing.allocator;
256
257 const parsed = try parseDidKey(alloc, "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4");
258 defer alloc.free(parsed.raw);
259
260 try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type);
261 try std.testing.expectEqual(@as(usize, 33), parsed.raw.len);
262 try std.testing.expect(parsed.raw[0] == 0x02 or parsed.raw[0] == 0x03);
263}
264
265test "parseDidKey rejects invalid prefix" {
266 const alloc = std.testing.allocator;
267 try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "did:web:example.com"));
268 try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "did:key:"));
269 try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, ""));
270}
271
272test "verifyDidKeySignature secp256k1" {
273 const alloc = std.testing.allocator;
274 const jwt = @import("jwt.zig");
275 const crypto = std.crypto;
276 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256;
277
278 const sk_bytes = [_]u8{
279 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
280 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
281 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
282 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
283 };
284
285 const message = "verify via did:key";
286 const sig = try jwt.signSecp256k1(message, &sk_bytes);
287
288 // derive public key and format as did:key
289 const sk = try Scheme.SecretKey.fromBytes(sk_bytes);
290 const kp = try Scheme.KeyPair.fromSecretKey(sk);
291 const pk_bytes = kp.public_key.toCompressedSec1();
292 const did = try formatDidKey(alloc, .secp256k1, &pk_bytes);
293 defer alloc.free(did);
294
295 // should verify
296 try verifyDidKeySignature(alloc, did, message, &sig.bytes);
297
298 // should reject wrong message
299 try std.testing.expectError(
300 error.SignatureVerificationFailed,
301 verifyDidKeySignature(alloc, did, "wrong message", &sig.bytes),
302 );
303}