atproto utils for zig zat.dev
atproto sdk zig
at main 455 lines 18 kB view raw
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}