atproto utils for zig zat.dev
atproto sdk zig
at main 332 lines 13 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("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 164fn 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 173fn parseHeader(allocator: std.mem.Allocator, header_json: []const u8) !Header { 174 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); 175 defer parsed.deinit(); 176 177 const alg_str = json.getString(parsed.value, "alg") orelse return error.MissingAlgorithm; 178 const alg = Algorithm.fromString(alg_str) orelse return error.UnsupportedAlgorithm; 179 180 return .{ 181 .alg = alg, 182 .typ = "JWT", // static string, no need to dupe 183 }; 184} 185 186fn parsePayload(allocator: std.mem.Allocator, payload_json: []const u8) !Payload { 187 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); 188 defer parsed.deinit(); 189 190 const iss_raw = json.getString(parsed.value, "iss") orelse return error.MissingIssuer; 191 const aud_raw = json.getString(parsed.value, "aud") orelse return error.MissingAudience; 192 const exp = json.getInt(parsed.value, "exp") orelse return error.MissingExpiration; 193 194 // dupe strings so they outlive parsed 195 const iss = try allocator.dupe(u8, iss_raw); 196 errdefer allocator.free(iss); 197 198 const aud = try allocator.dupe(u8, aud_raw); 199 errdefer allocator.free(aud); 200 201 const jti: ?[]const u8 = if (json.getString(parsed.value, "jti")) |s| 202 try allocator.dupe(u8, s) 203 else 204 null; 205 errdefer if (jti) |s| allocator.free(s); 206 207 const lxm: ?[]const u8 = if (json.getString(parsed.value, "lxm")) |s| 208 try allocator.dupe(u8, s) 209 else 210 null; 211 212 return .{ 213 .iss = iss, 214 .aud = aud, 215 .exp = exp, 216 .iat = json.getInt(parsed.value, "iat"), 217 .jti = jti, 218 .lxm = lxm, 219 }; 220} 221 222fn verifySecp256k1(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { 223 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; 224 225 // parse signature (r || s, 64 bytes) 226 if (sig_bytes.len != 64) return error.InvalidSignature; 227 const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); 228 229 // parse public key from SEC1 compressed format 230 if (public_key_raw.len != 33) return error.InvalidPublicKey; 231 const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey; 232 233 // verify 234 sig.verify(message, public_key) catch return error.SignatureVerificationFailed; 235} 236 237fn verifyP256(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { 238 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 239 240 // parse signature (r || s, 64 bytes) 241 if (sig_bytes.len != 64) return error.InvalidSignature; 242 const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); 243 244 // parse public key from SEC1 compressed format 245 if (public_key_raw.len != 33) return error.InvalidPublicKey; 246 const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey; 247 248 // verify 249 sig.verify(message, public_key) catch return error.SignatureVerificationFailed; 250} 251 252// === tests === 253 254test "parse jwt structure" { 255 // a minimal valid JWT structure (signature won't verify, just testing parsing) 256 // header: {"alg":"ES256K","typ":"JWT"} 257 // payload: {"iss":"did:plc:test","aud":"did:plc:service","exp":9999999999} 258 const token = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6cGxjOnNlcnZpY2UiLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; 259 260 var jwt = try Jwt.parse(std.testing.allocator, token); 261 defer jwt.deinit(); 262 263 try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg); 264 try std.testing.expectEqualStrings("did:plc:test", jwt.payload.iss); 265 try std.testing.expectEqualStrings("did:plc:service", jwt.payload.aud); 266 try std.testing.expectEqual(@as(i64, 9999999999), jwt.payload.exp); 267} 268 269test "reject invalid jwt format" { 270 // missing parts 271 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "onlyonepart")); 272 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "two.parts")); 273 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "too.many.parts.here")); 274} 275 276test "verify ES256K signature - official fixture" { 277 // test vector from bluesky-social/indigo atproto/auth/jwt_test.go 278 // pubkey: did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4 279 // iss: did:example:iss, aud: did:example:aud, exp: 1713571012 280 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g"; 281 282 // extract multibase key from did:key (strip "did:key:" prefix) 283 const did_key = "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4"; 284 const multibase_key = did_key["did:key:".len..]; 285 286 var jwt = try Jwt.parse(std.testing.allocator, token); 287 defer jwt.deinit(); 288 289 // verify claims 290 try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg); 291 try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss); 292 try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud); 293 294 // verify signature 295 try jwt.verify(multibase_key); 296} 297 298test "verify ES256 signature - official fixture" { 299 // test vector from bluesky-social/indigo atproto/auth/jwt_test.go 300 // pubkey: did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART 301 // iss: did:example:iss, aud: did:example:aud, exp: 1713571554 302 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzE1NTR9.FFRLm7SGbDUp6cL0WoCs0L5oqNkjCXB963TqbgI-KxIjbiqMQATVCalcMJx17JGTjMmfVHJP6Op_V4Z0TTjqog"; 303 304 // extract multibase key from did:key 305 const did_key = "did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART"; 306 const multibase_key = did_key["did:key:".len..]; 307 308 var jwt = try Jwt.parse(std.testing.allocator, token); 309 defer jwt.deinit(); 310 311 // verify claims 312 try std.testing.expectEqual(Algorithm.ES256, jwt.header.alg); 313 try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss); 314 try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud); 315 316 // verify signature 317 try jwt.verify(multibase_key); 318} 319 320test "reject signature with wrong key" { 321 // ES256K token 322 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g"; 323 324 // different ES256K key (second fixture from indigo) 325 const wrong_key = "zQ3shqKrpHzQ5HDfhgcYMWaFcpBK3SS39wZLdTjA5GeakX8G5"; 326 327 var jwt = try Jwt.parse(std.testing.allocator, token); 328 defer jwt.deinit(); 329 330 // should fail verification with wrong key 331 try std.testing.expectError(error.SignatureVerificationFailed, jwt.verify(wrong_key)); 332}