atproto utils for zig
zat.dev
atproto
sdk
zig
1//! JWT parsing and verification for AT Protocol
2//!
3//! parses and verifies JWTs used in AT Protocol service auth.
4//! supports ES256 (P-256) and ES256K (secp256k1) signing.
5//!
6//! see: https://atproto.com/specs/xrpc#service-auth
7
8const std = @import("std");
9const crypto = std.crypto;
10const json = @import("../xrpc/json.zig");
11const multibase = @import("multibase.zig");
12const multicodec = @import("multicodec.zig");
13
14/// JWT signing algorithm
15pub const Algorithm = enum {
16 ES256, // P-256 / secp256r1
17 ES256K, // secp256k1
18
19 pub fn fromString(s: []const u8) ?Algorithm {
20 if (std.mem.eql(u8, s, "ES256")) return .ES256;
21 if (std.mem.eql(u8, s, "ES256K")) return .ES256K;
22 return null;
23 }
24};
25
26/// parsed JWT header
27pub const Header = struct {
28 alg: Algorithm,
29 typ: []const u8,
30};
31
32/// parsed JWT payload (AT Protocol service auth claims)
33pub const Payload = struct {
34 /// issuer DID (account making the request)
35 iss: []const u8,
36 /// audience DID (service receiving the request)
37 aud: []const u8,
38 /// expiration timestamp (unix seconds)
39 exp: i64,
40 /// issued-at timestamp (unix seconds)
41 iat: ?i64 = null,
42 /// unique nonce for replay prevention
43 jti: ?[]const u8 = null,
44 /// lexicon method (optional, may become required)
45 lxm: ?[]const u8 = null,
46};
47
48/// parsed JWT with raw components
49pub const Jwt = struct {
50 allocator: std.mem.Allocator,
51
52 /// decoded header
53 header: Header,
54 /// decoded payload
55 payload: Payload,
56 /// raw signature bytes (r || s, 64 bytes)
57 signature: []u8,
58 /// the signed portion (header.payload) for verification
59 signed_input: []const u8,
60 /// original token for reference
61 raw_token: []const u8,
62
63 /// parse a JWT token string
64 pub fn parse(allocator: std.mem.Allocator, token: []const u8) !Jwt {
65 // split on dots: header.payload.signature
66 var parts: [3][]const u8 = undefined;
67 var part_idx: usize = 0;
68 var it = std.mem.splitScalar(u8, token, '.');
69
70 while (it.next()) |part| {
71 if (part_idx >= 3) return error.InvalidJwt;
72 parts[part_idx] = part;
73 part_idx += 1;
74 }
75
76 if (part_idx != 3) return error.InvalidJwt;
77
78 const header_b64 = parts[0];
79 const payload_b64 = parts[1];
80 const sig_b64 = parts[2];
81
82 // find signed input (everything before last dot)
83 const last_dot = std.mem.lastIndexOfScalar(u8, token, '.') orelse return error.InvalidJwt;
84 const signed_input = token[0..last_dot];
85
86 // decode header
87 const header_json = try base64UrlDecode(allocator, header_b64);
88 defer allocator.free(header_json);
89
90 const header = try parseHeader(allocator, header_json);
91
92 // decode payload
93 const payload_json = try base64UrlDecode(allocator, payload_b64);
94 defer allocator.free(payload_json);
95
96 const payload = try parsePayload(allocator, payload_json);
97
98 // decode signature
99 const signature = try base64UrlDecode(allocator, sig_b64);
100 errdefer allocator.free(signature);
101
102 // JWT signatures should be 64 bytes (r || s)
103 if (signature.len != 64) {
104 allocator.free(signature);
105 return error.InvalidSignatureLength;
106 }
107
108 return .{
109 .allocator = allocator,
110 .header = header,
111 .payload = payload,
112 .signature = signature,
113 .signed_input = signed_input,
114 .raw_token = token,
115 };
116 }
117
118 /// verify the JWT signature against a public key
119 /// public_key should be multibase-encoded (from DID document)
120 pub fn verify(self: *const Jwt, public_key_multibase: []const u8) !void {
121 // decode multibase key
122 const key_bytes = try multibase.decode(self.allocator, public_key_multibase);
123 defer self.allocator.free(key_bytes);
124
125 // parse multicodec to get key type and raw bytes
126 const parsed_key = try multicodec.parsePublicKey(key_bytes);
127
128 // verify key type matches algorithm
129 switch (self.header.alg) {
130 .ES256K => {
131 if (parsed_key.key_type != .secp256k1) return error.AlgorithmKeyMismatch;
132 try verifySecp256k1(self.signed_input, self.signature, parsed_key.raw);
133 },
134 .ES256 => {
135 if (parsed_key.key_type != .p256) return error.AlgorithmKeyMismatch;
136 try verifyP256(self.signed_input, self.signature, parsed_key.raw);
137 },
138 }
139 }
140
141 /// check if the token is expired
142 pub fn isExpired(self: *const Jwt) bool {
143 const now = std.time.timestamp();
144 return now > self.payload.exp;
145 }
146
147 /// check if the token is expired with clock skew tolerance (in seconds)
148 pub fn isExpiredWithSkew(self: *const Jwt, skew_seconds: i64) bool {
149 const now = std.time.timestamp();
150 return now > (self.payload.exp + skew_seconds);
151 }
152
153 pub fn deinit(self: *Jwt) void {
154 self.allocator.free(self.signature);
155 self.allocator.free(self.payload.iss);
156 self.allocator.free(self.payload.aud);
157 if (self.payload.jti) |s| self.allocator.free(s);
158 if (self.payload.lxm) |s| self.allocator.free(s);
159 }
160};
161
162// === internal helpers ===
163
164pub fn base64UrlDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
165 const decoder = &std.base64.url_safe_no_pad.Decoder;
166 const size = try decoder.calcSizeForSlice(input);
167 const output = try allocator.alloc(u8, size);
168 errdefer allocator.free(output);
169 try decoder.decode(output, input);
170 return output;
171}
172
173pub fn base64UrlEncode(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
174 const encoder = &std.base64.url_safe_no_pad.Encoder;
175 const len = encoder.calcSize(data.len);
176 const buf = try allocator.alloc(u8, len);
177 _ = encoder.encode(buf, data);
178 return buf;
179}
180
181fn parseHeader(allocator: std.mem.Allocator, header_json: []const u8) !Header {
182 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{});
183 defer parsed.deinit();
184
185 const alg_str = json.getString(parsed.value, "alg") orelse return error.MissingAlgorithm;
186 const alg = Algorithm.fromString(alg_str) orelse return error.UnsupportedAlgorithm;
187
188 return .{
189 .alg = alg,
190 .typ = "JWT", // static string, no need to dupe
191 };
192}
193
194fn parsePayload(allocator: std.mem.Allocator, payload_json: []const u8) !Payload {
195 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{});
196 defer parsed.deinit();
197
198 const iss_raw = json.getString(parsed.value, "iss") orelse return error.MissingIssuer;
199 const aud_raw = json.getString(parsed.value, "aud") orelse return error.MissingAudience;
200 const exp = json.getInt(parsed.value, "exp") orelse return error.MissingExpiration;
201
202 // dupe strings so they outlive parsed
203 const iss = try allocator.dupe(u8, iss_raw);
204 errdefer allocator.free(iss);
205
206 const aud = try allocator.dupe(u8, aud_raw);
207 errdefer allocator.free(aud);
208
209 const jti: ?[]const u8 = if (json.getString(parsed.value, "jti")) |s|
210 try allocator.dupe(u8, s)
211 else
212 null;
213 errdefer if (jti) |s| allocator.free(s);
214
215 const lxm: ?[]const u8 = if (json.getString(parsed.value, "lxm")) |s|
216 try allocator.dupe(u8, s)
217 else
218 null;
219
220 return .{
221 .iss = iss,
222 .aud = aud,
223 .exp = exp,
224 .iat = json.getInt(parsed.value, "iat"),
225 .jti = jti,
226 .lxm = lxm,
227 };
228}
229
230/// compare two 32-byte big-endian values: true if a > b
231fn bigEndianGt(a: [32]u8, b: [32]u8) bool {
232 for (a, b) |ab, bb| {
233 if (ab > bb) return true;
234 if (ab < bb) return false;
235 }
236 return false;
237}
238
239/// reject high-S signatures (atproto requires low-S normalization).
240/// s is high-S if s > curve_order / 2.
241fn rejectHighS(comptime half_order: [32]u8, s_bytes: [32]u8) error{HighSSignature}!void {
242 if (bigEndianGt(s_bytes, half_order)) return error.HighSSignature;
243}
244
245// secp256k1 order/2 (big-endian)
246// order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
247const secp256k1_half_order: [32]u8 = .{
248 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
249 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
250 0x5D, 0x57, 0x6E, 0x73, 0x57, 0xA4, 0x50, 0x1D,
251 0xDF, 0xE9, 0x2F, 0x46, 0x68, 0x1B, 0x20, 0xA0,
252};
253
254// P-256 order/2 (big-endian)
255// order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
256const p256_half_order: [32]u8 = .{
257 0x7F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00,
258 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
259 0xDE, 0x73, 0x7D, 0x56, 0xD3, 0x8B, 0xCF, 0x42,
260 0x79, 0xDC, 0xE5, 0x61, 0x7E, 0x31, 0x92, 0xA8,
261};
262
263/// ECDSA signature (r || s, 64 bytes)
264pub const Signature = struct {
265 bytes: [64]u8,
266};
267
268/// sign a message with deterministic RFC 6979 ECDSA and low-S normalization
269fn signEcdsa(comptime Scheme: type, comptime Curve: type, comptime half_order: [32]u8, message: []const u8, secret_key_bytes: []const u8) !Signature {
270 if (secret_key_bytes.len != 32) return error.InvalidSecretKey;
271 const sk = Scheme.SecretKey.fromBytes(secret_key_bytes[0..32].*) catch return error.InvalidSecretKey;
272 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey;
273
274 var sig = kp.sign(message, null) catch return error.SigningFailed;
275
276 if (bigEndianGt(sig.s, half_order)) {
277 sig.s = Curve.scalar.neg(sig.s, .big) catch return error.SigningFailed;
278 }
279
280 return .{ .bytes = sig.toBytes() };
281}
282
283/// verify an ECDSA signature, rejecting high-S
284fn verifyEcdsa(comptime Scheme: type, comptime half_order: [32]u8, message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void {
285 if (sig_bytes.len != 64) return error.InvalidSignature;
286 const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*);
287
288 rejectHighS(half_order, sig.s) catch return error.SignatureVerificationFailed;
289
290 if (public_key_raw.len != 33) return error.InvalidPublicKey;
291 const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey;
292
293 sig.verify(message, public_key) catch return error.SignatureVerificationFailed;
294}
295
296pub fn signSecp256k1(message: []const u8, secret_key_bytes: []const u8) !Signature {
297 return signEcdsa(crypto.sign.ecdsa.EcdsaSecp256k1Sha256, crypto.ecc.Secp256k1, secp256k1_half_order, message, secret_key_bytes);
298}
299
300pub fn signP256(message: []const u8, secret_key_bytes: []const u8) !Signature {
301 return signEcdsa(crypto.sign.ecdsa.EcdsaP256Sha256, crypto.ecc.P256, p256_half_order, message, secret_key_bytes);
302}
303
304pub fn verifySecp256k1(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void {
305 return verifyEcdsa(crypto.sign.ecdsa.EcdsaSecp256k1Sha256, secp256k1_half_order, message, sig_bytes, public_key_raw);
306}
307
308pub fn verifyP256(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void {
309 return verifyEcdsa(crypto.sign.ecdsa.EcdsaP256Sha256, p256_half_order, message, sig_bytes, public_key_raw);
310}
311
312// === tests ===
313
314test "parse jwt structure" {
315 // a minimal valid JWT structure (signature won't verify, just testing parsing)
316 // header: {"alg":"ES256K","typ":"JWT"}
317 // payload: {"iss":"did:plc:test","aud":"did:plc:service","exp":9999999999}
318 const token = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6cGxjOnNlcnZpY2UiLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
319
320 var jwt = try Jwt.parse(std.testing.allocator, token);
321 defer jwt.deinit();
322
323 try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg);
324 try std.testing.expectEqualStrings("did:plc:test", jwt.payload.iss);
325 try std.testing.expectEqualStrings("did:plc:service", jwt.payload.aud);
326 try std.testing.expectEqual(@as(i64, 9999999999), jwt.payload.exp);
327}
328
329test "reject invalid jwt format" {
330 // missing parts
331 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "onlyonepart"));
332 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "two.parts"));
333 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "too.many.parts.here"));
334}
335
336test "verify ES256K signature - official fixture" {
337 // test vector from bluesky-social/indigo atproto/auth/jwt_test.go
338 // pubkey: did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4
339 // iss: did:example:iss, aud: did:example:aud, exp: 1713571012
340 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g";
341
342 // extract multibase key from did:key (strip "did:key:" prefix)
343 const did_key = "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4";
344 const multibase_key = did_key["did:key:".len..];
345
346 var jwt = try Jwt.parse(std.testing.allocator, token);
347 defer jwt.deinit();
348
349 // verify claims
350 try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg);
351 try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss);
352 try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud);
353
354 // verify signature
355 try jwt.verify(multibase_key);
356}
357
358test "verify ES256 signature - official fixture" {
359 // test vector from bluesky-social/indigo atproto/auth/jwt_test.go
360 // pubkey: did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART
361 // iss: did:example:iss, aud: did:example:aud, exp: 1713571554
362 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzE1NTR9.FFRLm7SGbDUp6cL0WoCs0L5oqNkjCXB963TqbgI-KxIjbiqMQATVCalcMJx17JGTjMmfVHJP6Op_V4Z0TTjqog";
363
364 // extract multibase key from did:key
365 const did_key = "did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART";
366 const multibase_key = did_key["did:key:".len..];
367
368 var jwt = try Jwt.parse(std.testing.allocator, token);
369 defer jwt.deinit();
370
371 // verify claims
372 try std.testing.expectEqual(Algorithm.ES256, jwt.header.alg);
373 try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss);
374 try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud);
375
376 // verify signature
377 try jwt.verify(multibase_key);
378}
379
380test "reject signature with wrong key" {
381 // ES256K token
382 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g";
383
384 // different ES256K key (second fixture from indigo)
385 const wrong_key = "zQ3shqKrpHzQ5HDfhgcYMWaFcpBK3SS39wZLdTjA5GeakX8G5";
386
387 var jwt = try Jwt.parse(std.testing.allocator, token);
388 defer jwt.deinit();
389
390 // should fail verification with wrong key
391 try std.testing.expectError(error.SignatureVerificationFailed, jwt.verify(wrong_key));
392}
393
394test "sign and verify round-trip - secp256k1" {
395 // generate a deterministic keypair using a fixed seed
396 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256;
397 const sk_bytes = [_]u8{
398 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
399 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
400 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
401 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
402 };
403
404 const message = "hello atproto";
405 const sig = try signSecp256k1(message, &sk_bytes);
406
407 // verify low-S: s must be <= half_order
408 const s = sig.bytes[32..64].*;
409 try std.testing.expect(!bigEndianGt(s, secp256k1_half_order));
410
411 // verify with the corresponding public key
412 const sk = try Scheme.SecretKey.fromBytes(sk_bytes);
413 const kp = try Scheme.KeyPair.fromSecretKey(sk);
414 const pk_bytes = kp.public_key.toCompressedSec1();
415
416 try verifySecp256k1(message, &sig.bytes, &pk_bytes);
417}
418
419test "sign and verify round-trip - P-256" {
420 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256;
421 const sk_bytes = [_]u8{
422 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
423 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
424 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
425 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
426 };
427
428 const message = "hello atproto p256";
429 const sig = try signP256(message, &sk_bytes);
430
431 // verify low-S
432 const s = sig.bytes[32..64].*;
433 try std.testing.expect(!bigEndianGt(s, p256_half_order));
434
435 // verify with the corresponding public key
436 const sk = try Scheme.SecretKey.fromBytes(sk_bytes);
437 const kp = try Scheme.KeyPair.fromSecretKey(sk);
438 const pk_bytes = kp.public_key.toCompressedSec1();
439
440 try verifyP256(message, &sig.bytes, &pk_bytes);
441}
442
443test "sign produces deterministic signatures" {
444 const sk_bytes = [_]u8{
445 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
446 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
447 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
448 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
449 };
450 const message = "deterministic test";
451
452 const sig1 = try signSecp256k1(message, &sk_bytes);
453 const sig2 = try signSecp256k1(message, &sk_bytes);
454 try std.testing.expectEqualSlices(u8, &sig1.bytes, &sig2.bytes);
455}