atproto utils for zig
zat.dev
atproto
sdk
zig
1//! keypair abstraction for AT Protocol cryptography
2//!
3//! unified keypair type for secp256k1 (ES256K) and P-256 (ES256).
4//! handles signing with low-S normalization, public key derivation,
5//! and did:key formatting.
6//!
7//! see: https://atproto.com/specs/cryptography
8
9const std = @import("std");
10const crypto = std.crypto;
11const multicodec = @import("multicodec.zig");
12const jwt = @import("jwt.zig");
13
14pub const Keypair = struct {
15 key_type: multicodec.KeyType,
16 secret_key: [32]u8,
17
18 /// create a keypair from raw secret key bytes (32 bytes).
19 /// validates the key is on the curve.
20 pub fn fromSecretKey(key_type: multicodec.KeyType, secret_key: [32]u8) !Keypair {
21 // zero is not a valid scalar for any curve
22 if (std.mem.allEqual(u8, &secret_key, 0)) return error.InvalidSecretKey;
23 // validate by attempting to construct the stdlib key
24 switch (key_type) {
25 .secp256k1 => {
26 _ = crypto.sign.ecdsa.EcdsaSecp256k1Sha256.SecretKey.fromBytes(secret_key) catch
27 return error.InvalidSecretKey;
28 },
29 .p256 => {
30 _ = crypto.sign.ecdsa.EcdsaP256Sha256.SecretKey.fromBytes(secret_key) catch
31 return error.InvalidSecretKey;
32 },
33 }
34 return .{ .key_type = key_type, .secret_key = secret_key };
35 }
36
37 /// sign a message with deterministic ECDSA (RFC 6979) and low-S normalization
38 pub fn sign(self: *const Keypair, message: []const u8) !jwt.Signature {
39 return switch (self.key_type) {
40 .secp256k1 => jwt.signSecp256k1(message, &self.secret_key),
41 .p256 => jwt.signP256(message, &self.secret_key),
42 };
43 }
44
45 /// return the compressed SEC1 public key (33 bytes)
46 pub fn publicKey(self: *const Keypair) ![33]u8 {
47 switch (self.key_type) {
48 .secp256k1 => {
49 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256;
50 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey;
51 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey;
52 return kp.public_key.toCompressedSec1();
53 },
54 .p256 => {
55 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256;
56 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey;
57 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey;
58 return kp.public_key.toCompressedSec1();
59 },
60 }
61 }
62
63 /// format the public key as a did:key string.
64 /// caller owns the returned slice.
65 pub fn did(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 {
66 const pk = try self.publicKey();
67 return multicodec.formatDidKey(allocator, self.key_type, &pk);
68 }
69
70 /// return the JWT algorithm identifier
71 pub fn algorithm(self: *const Keypair) jwt.Algorithm {
72 return switch (self.key_type) {
73 .secp256k1 => .ES256K,
74 .p256 => .ES256,
75 };
76 }
77
78 /// return the uncompressed SEC1 public key (65 bytes: 0x04 || x[32] || y[32]).
79 pub fn uncompressedPublicKey(self: *const Keypair) ![65]u8 {
80 switch (self.key_type) {
81 .secp256k1 => {
82 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256;
83 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey;
84 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey;
85 return kp.public_key.toUncompressedSec1();
86 },
87 .p256 => {
88 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256;
89 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey;
90 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey;
91 return kp.public_key.toUncompressedSec1();
92 },
93 }
94 }
95
96 /// format the public key as a JWK JSON string.
97 /// includes kty, crv, x, y, kid (thumbprint), use, alg.
98 /// caller owns the returned slice.
99 pub fn jwk(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 {
100 const uncompressed = try self.uncompressedPublicKey();
101 const x_b64 = try jwt.base64UrlEncode(allocator, uncompressed[1..33]);
102 defer allocator.free(x_b64);
103 const y_b64 = try jwt.base64UrlEncode(allocator, uncompressed[33..65]);
104 defer allocator.free(y_b64);
105
106 const crv = switch (self.key_type) {
107 .p256 => "P-256",
108 .secp256k1 => "secp256k1",
109 };
110 const alg = @tagName(self.algorithm());
111
112 // RFC 7638 thumbprint inline — avoids re-deriving the public key
113 const canonical = try std.fmt.allocPrint(allocator,
114 \\{{"crv":"{s}","kty":"EC","x":"{s}","y":"{s}"}}
115 , .{ crv, x_b64, y_b64 });
116 defer allocator.free(canonical);
117
118 var hash: [32]u8 = undefined;
119 crypto.hash.sha2.Sha256.hash(canonical, &hash, .{});
120 const kid = try jwt.base64UrlEncode(allocator, &hash);
121 defer allocator.free(kid);
122
123 return std.fmt.allocPrint(allocator,
124 \\{{"kty":"EC","crv":"{s}","x":"{s}","y":"{s}","kid":"{s}","use":"sig","alg":"{s}"}}
125 , .{ crv, x_b64, y_b64, kid, alg });
126 }
127
128 /// compute the JWK thumbprint (RFC 7638) as a base64url string.
129 /// canonical form: {"crv":"...","kty":"EC","x":"...","y":"..."}
130 /// caller owns the returned slice.
131 pub fn jwkThumbprint(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 {
132 const uncompressed = try self.uncompressedPublicKey();
133 const x_b64 = try jwt.base64UrlEncode(allocator, uncompressed[1..33]);
134 defer allocator.free(x_b64);
135 const y_b64 = try jwt.base64UrlEncode(allocator, uncompressed[33..65]);
136 defer allocator.free(y_b64);
137
138 const crv = switch (self.key_type) {
139 .p256 => "P-256",
140 .secp256k1 => "secp256k1",
141 };
142
143 // RFC 7638: required members in lexicographic order
144 const canonical = try std.fmt.allocPrint(allocator,
145 \\{{"crv":"{s}","kty":"EC","x":"{s}","y":"{s}"}}
146 , .{ crv, x_b64, y_b64 });
147 defer allocator.free(canonical);
148
149 var hash: [32]u8 = undefined;
150 crypto.hash.sha2.Sha256.hash(canonical, &hash, .{});
151 return jwt.base64UrlEncode(allocator, &hash);
152 }
153};
154
155// === tests ===
156
157test "keypair secp256k1 sign and verify round-trip" {
158 const alloc = std.testing.allocator;
159
160 const kp = try Keypair.fromSecretKey(.secp256k1, .{
161 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
162 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
163 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
164 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
165 });
166
167 const message = "keypair round-trip test";
168 const sig = try kp.sign(message);
169
170 // verify via did:key
171 const did_str = try kp.did(alloc);
172 defer alloc.free(did_str);
173
174 try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes);
175}
176
177test "keypair p256 sign and verify round-trip" {
178 const alloc = std.testing.allocator;
179
180 const kp = try Keypair.fromSecretKey(.p256, .{
181 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
182 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
183 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
184 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
185 });
186
187 const message = "keypair p256 round-trip";
188 const sig = try kp.sign(message);
189
190 const did_str = try kp.did(alloc);
191 defer alloc.free(did_str);
192
193 try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes);
194}
195
196test "keypair did:key format is correct" {
197 const alloc = std.testing.allocator;
198
199 const kp = try Keypair.fromSecretKey(.secp256k1, .{
200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
201 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
202 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
203 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
204 });
205
206 const did_str = try kp.did(alloc);
207 defer alloc.free(did_str);
208
209 // must start with did:key:z (base58btc multibase prefix)
210 try std.testing.expect(std.mem.startsWith(u8, did_str, "did:key:z"));
211
212 // must round-trip back to the same public key
213 const parsed = try multicodec.parseDidKey(alloc, did_str);
214 defer alloc.free(parsed.raw);
215
216 const pk = try kp.publicKey();
217 try std.testing.expectEqual(multicodec.KeyType.secp256k1, parsed.key_type);
218 try std.testing.expectEqualSlices(u8, &pk, parsed.raw);
219}
220
221test "keypair algorithm matches key type" {
222 const secp = try Keypair.fromSecretKey(.secp256k1, .{0x01} ** 32);
223 try std.testing.expectEqual(jwt.Algorithm.ES256K, secp.algorithm());
224
225 const p256 = try Keypair.fromSecretKey(.p256, .{0x21} ** 32);
226 try std.testing.expectEqual(jwt.Algorithm.ES256, p256.algorithm());
227}
228
229test "keypair rejects invalid secret key" {
230 // all-zeros is not a valid scalar for either curve
231 try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.secp256k1, .{0x00} ** 32));
232 try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.p256, .{0x00} ** 32));
233}
234
235test "keypair jwk p256 round-trip" {
236 const alloc = std.testing.allocator;
237 const kp = try Keypair.fromSecretKey(.p256, .{
238 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
239 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
240 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
241 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
242 });
243
244 const jwk_json = try kp.jwk(alloc);
245 defer alloc.free(jwk_json);
246
247 const parsed = try std.json.parseFromSlice(std.json.Value, alloc, jwk_json, .{});
248 defer parsed.deinit();
249
250 const obj = parsed.value.object;
251 try std.testing.expectEqualStrings("EC", obj.get("kty").?.string);
252 try std.testing.expectEqualStrings("P-256", obj.get("crv").?.string);
253 try std.testing.expectEqualStrings("ES256", obj.get("alg").?.string);
254 try std.testing.expectEqualStrings("sig", obj.get("use").?.string);
255 try std.testing.expect(obj.get("x") != null);
256 try std.testing.expect(obj.get("y") != null);
257 try std.testing.expect(obj.get("kid") != null);
258}
259
260test "keypair jwk secp256k1 round-trip" {
261 const alloc = std.testing.allocator;
262 const kp = try Keypair.fromSecretKey(.secp256k1, .{
263 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
264 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
265 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
266 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
267 });
268
269 const jwk_json = try kp.jwk(alloc);
270 defer alloc.free(jwk_json);
271
272 const parsed = try std.json.parseFromSlice(std.json.Value, alloc, jwk_json, .{});
273 defer parsed.deinit();
274
275 const obj = parsed.value.object;
276 try std.testing.expectEqualStrings("EC", obj.get("kty").?.string);
277 try std.testing.expectEqualStrings("secp256k1", obj.get("crv").?.string);
278 try std.testing.expectEqualStrings("ES256K", obj.get("alg").?.string);
279 try std.testing.expectEqualStrings("sig", obj.get("use").?.string);
280}
281
282test "keypair jwk thumbprint matches kid" {
283 const alloc = std.testing.allocator;
284 const kp = try Keypair.fromSecretKey(.p256, .{
285 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
286 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
287 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
288 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
289 });
290
291 // get thumbprint directly
292 const thumbprint = try kp.jwkThumbprint(alloc);
293 defer alloc.free(thumbprint);
294
295 // get kid from JWK
296 const jwk_json = try kp.jwk(alloc);
297 defer alloc.free(jwk_json);
298
299 const parsed = try std.json.parseFromSlice(std.json.Value, alloc, jwk_json, .{});
300 defer parsed.deinit();
301
302 const kid = parsed.value.object.get("kid").?.string;
303 try std.testing.expectEqualStrings(thumbprint, kid);
304}
305
306test "keypair cross-verify: sign with keypair, verify with jwt.verify" {
307 // sign with Keypair, verify through the JWT multibase path (existing code)
308 const alloc = std.testing.allocator;
309 const multibase = @import("multibase.zig");
310
311 const sk_bytes = [_]u8{
312 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
313 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
314 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
315 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
316 };
317
318 const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes);
319 const message = "cross-verify test";
320 const sig = try kp.sign(message);
321
322 // get the multibase-encoded key (as it would appear in a DID document)
323 const pk = try kp.publicKey();
324 const mc_bytes = try multicodec.encodePublicKey(alloc, .secp256k1, &pk);
325 defer alloc.free(mc_bytes);
326 const multibase_key = try multibase.encode(alloc, .base58btc, mc_bytes);
327 defer alloc.free(multibase_key);
328
329 // verify through the old path
330 try jwt.verifySecp256k1(message, &sig.bytes, &pk);
331}