adopt zat.Jwt for service auth verification

removes ~240 lines of hand-rolled JWT/crypto code:
- base58 decoding
- multibase/multicodec parsing
- ECDSA signature verification
- base64url decoding

now uses zat.Jwt.parse() + jwt.verify() + zat.DidResolver

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+49 -286
src
stream
+1 -1
build.zig.zon
··· 14 14 }, 15 15 .zat = .{ 16 16 .url = "https://tangled.org/zzstoatzz.io/zat/archive/main", 17 - .hash = "zat-0.0.1-alpha-5PuC7tP4AAAUmi43Fx2cjUQc-yfJHWpz2aDzEV5b82bK", 17 + .hash = "zat-0.0.1-alpha-5PuC7nREAQA1hXSr6gqWnbctnysuiJBht_B7RqqbWFDU", 18 18 }, 19 19 }, 20 20 .paths = .{
+48 -285
src/stream/atproto.zig
··· 1 1 const std = @import("std"); 2 2 const mem = std.mem; 3 - const base64 = std.base64; 4 3 const Allocator = mem.Allocator; 5 4 const zat = @import("zat"); 6 5 7 6 // ============================================================================= 8 7 // atproto utilities 9 8 // 10 - // JWT verification and API helpers. 11 - // DID resolution and XRPC now handled by zat. 9 + // JWT verification and API helpers using zat SDK. 12 10 // ============================================================================= 13 11 14 - pub const ServiceJwtPayload = struct { 15 - iss: []const u8, // issuer - the requester's DID 16 - aud: []const u8, // audience - the service DID 17 - exp: i64, // expiration timestamp 18 - iat: ?i64 = null, // issued at 19 - lxm: ?[]const u8 = null, // lexicon method 20 - jti: ?[]const u8 = null, // JWT ID 21 - }; 22 - 23 - pub const JwtError = error{ 24 - MalformedJwt, 25 - InvalidBase64, 26 - InvalidJson, 27 - MissingField, 28 - Expired, 29 - InvalidAudience, 30 - InvalidSignature, 31 - DidResolutionFailed, 32 - }; 12 + const log = std.log.scoped(.atproto); 33 13 34 - /// parse a JWT without verifying the signature. 35 - pub fn parseJwtUnsafe(allocator: Allocator, jwt: []const u8) !ServiceJwtPayload { 36 - var parts = mem.splitScalar(u8, jwt, '.'); 14 + /// extract requester DID from an HTTP Authorization header (claims-only verification). 15 + pub fn getRequesterDid(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 16 + const auth = auth_header orelse return null; 17 + if (!mem.startsWith(u8, auth, "Bearer ")) return null; 37 18 38 - // skip header 39 - _ = parts.next() orelse return JwtError.MalformedJwt; 40 - 41 - // decode payload 42 - const payload_b64 = parts.next() orelse return JwtError.MalformedJwt; 43 - const payload_json = try decodeBase64Url(allocator, payload_b64); 44 - defer allocator.free(payload_json); 45 - 46 - // parse JSON 47 - const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}) catch { 48 - return JwtError.InvalidJson; 49 - }; 50 - defer parsed.deinit(); 19 + const token = auth["Bearer ".len..]; 51 20 52 - const obj = parsed.value.object; 21 + var jwt = zat.Jwt.parse(allocator, token) catch return null; 22 + defer jwt.deinit(); 53 23 54 - // extract required fields 55 - const iss = obj.get("iss") orelse return JwtError.MissingField; 56 - if (iss != .string) return JwtError.MissingField; 24 + // check claims 25 + if (jwt.isExpired()) return null; 26 + if (!mem.eql(u8, jwt.payload.aud, service_did)) return null; 57 27 58 - const aud = obj.get("aud") orelse return JwtError.MissingField; 59 - if (aud != .string) return JwtError.MissingField; 28 + return allocator.dupe(u8, jwt.payload.iss) catch null; 29 + } 60 30 61 - const exp = obj.get("exp") orelse return JwtError.MissingField; 62 - if (exp != .integer) return JwtError.MissingField; 31 + /// extract requester DID with full signature verification. 32 + pub fn getRequesterDidVerified(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 33 + const auth = auth_header orelse return null; 34 + if (!mem.startsWith(u8, auth, "Bearer ")) return null; 63 35 64 - // optional fields 65 - const iat: ?i64 = if (obj.get("iat")) |v| if (v == .integer) v.integer else null else null; 66 - const lxm: ?[]const u8 = if (obj.get("lxm")) |v| if (v == .string) v.string else null else null; 67 - const jti: ?[]const u8 = if (obj.get("jti")) |v| if (v == .string) v.string else null else null; 36 + const token = auth["Bearer ".len..]; 68 37 69 - return .{ 70 - .iss = try allocator.dupe(u8, iss.string), 71 - .aud = try allocator.dupe(u8, aud.string), 72 - .exp = exp.integer, 73 - .iat = iat, 74 - .lxm = if (lxm) |s| try allocator.dupe(u8, s) else null, 75 - .jti = if (jti) |s| try allocator.dupe(u8, s) else null, 38 + var jwt = zat.Jwt.parse(allocator, token) catch |err| { 39 + log.debug("jwt parse failed: {}", .{err}); 40 + return null; 76 41 }; 77 - } 42 + defer jwt.deinit(); 78 43 79 - /// verify a JWT's claims (expiration, audience) without signature verification. 80 - pub fn verifyJwtClaims( 81 - allocator: Allocator, 82 - jwt: []const u8, 83 - expected_audience: []const u8, 84 - ) !ServiceJwtPayload { 85 - const payload = try parseJwtUnsafe(allocator, jwt); 86 - 87 - if (payload.exp < std.time.timestamp()) { 88 - return JwtError.Expired; 44 + // check claims 45 + if (jwt.isExpired()) { 46 + log.debug("jwt expired", .{}); 47 + return null; 89 48 } 90 - 91 - if (!mem.eql(u8, payload.aud, expected_audience)) { 92 - return JwtError.InvalidAudience; 49 + if (!mem.eql(u8, jwt.payload.aud, service_did)) { 50 + log.debug("jwt audience mismatch", .{}); 51 + return null; 93 52 } 94 53 95 - return payload; 96 - } 97 - 98 - /// verify a JWT fully, including cryptographic signature. 99 - pub fn verifyJwt( 100 - allocator: Allocator, 101 - jwt: []const u8, 102 - expected_audience: []const u8, 103 - ) !ServiceJwtPayload { 104 - const payload = try verifyJwtClaims(allocator, jwt, expected_audience); 54 + // resolve issuer's DID document to get signing key 55 + const did = zat.Did.parse(jwt.payload.iss) orelse { 56 + log.debug("invalid issuer DID: {s}", .{jwt.payload.iss}); 57 + return null; 58 + }; 105 59 106 - // resolve DID using zat 107 - const did = zat.Did.parse(payload.iss) orelse return JwtError.DidResolutionFailed; 108 60 var resolver = zat.DidResolver.init(allocator); 109 61 defer resolver.deinit(); 110 62 111 - var doc = resolver.resolve(did) catch return JwtError.DidResolutionFailed; 63 + var doc = resolver.resolve(did) catch |err| { 64 + log.debug("DID resolution failed: {}", .{err}); 65 + return null; 66 + }; 112 67 defer doc.deinit(); 113 68 114 - const signing_key = doc.signingKey() orelse return JwtError.DidResolutionFailed; 115 - 116 - verifyJwtSignature(allocator, jwt, signing_key.public_key_multibase) catch { 117 - return JwtError.InvalidSignature; 69 + const signing_key = doc.signingKey() orelse { 70 + log.debug("no signing key in DID document", .{}); 71 + return null; 118 72 }; 119 73 120 - return payload; 121 - } 122 - 123 - /// extract requester DID from an HTTP Authorization header. 124 - pub fn getRequesterDid(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 125 - const auth = auth_header orelse return null; 126 - if (!mem.startsWith(u8, auth, "Bearer ")) return null; 127 - 128 - const jwt = auth[7..]; 129 - const payload = verifyJwtClaims(allocator, jwt, service_did) catch return null; 130 - 131 - return payload.iss; 132 - } 133 - 134 - /// extract requester DID with full signature verification. 135 - pub fn getRequesterDidVerified(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 136 - const auth = auth_header orelse return null; 137 - if (!mem.startsWith(u8, auth, "Bearer ")) return null; 138 - 139 - const jwt = auth[7..]; 140 - const payload = verifyJwt(allocator, jwt, service_did) catch |err| { 141 - std.debug.print("jwt verification failed: {}\n", .{err}); 74 + // verify signature 75 + jwt.verify(signing_key.public_key_multibase) catch |err| { 76 + log.debug("jwt signature verification failed: {}", .{err}); 142 77 return null; 143 78 }; 144 79 145 - return payload.iss; 146 - } 147 - 148 - // ----------------------------------------------------------------------------- 149 - // JWT signature verification (crypto) 150 - // ----------------------------------------------------------------------------- 151 - 152 - const ecdsa = std.crypto.sign.ecdsa; 153 - const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 154 - 155 - fn decodeBase58(allocator: Allocator, input: []const u8) ![]u8 { 156 - if (input.len == 0) return allocator.alloc(u8, 0); 157 - 158 - var leading_zeros: usize = 0; 159 - for (input) |c| { 160 - if (c == '1') { 161 - leading_zeros += 1; 162 - } else { 163 - break; 164 - } 165 - } 166 - 167 - const output_size = input.len; 168 - var output = try allocator.alloc(u8, output_size); 169 - @memset(output, 0); 170 - 171 - var output_len: usize = 0; 172 - 173 - for (input) |c| { 174 - const val: u8 = for (BASE58_ALPHABET, 0..) |a, i| { 175 - if (a == c) break @intCast(i); 176 - } else return error.InvalidBase58; 177 - 178 - var carry: u32 = val; 179 - var i: usize = 0; 180 - while (i < output_len or carry != 0) : (i += 1) { 181 - if (i >= output.len) return error.InvalidBase58; 182 - const idx = output.len - 1 - i; 183 - carry += @as(u32, output[idx]) * 58; 184 - output[idx] = @truncate(carry & 0xff); 185 - carry >>= 8; 186 - } 187 - output_len = @max(output_len, i); 188 - } 189 - 190 - const start = output.len - output_len; 191 - const result_len = leading_zeros + output_len; 192 - const result = try allocator.alloc(u8, result_len); 193 - @memset(result[0..leading_zeros], 0); 194 - @memcpy(result[leading_zeros..], output[start..]); 195 - allocator.free(output); 196 - 197 - return result; 198 - } 199 - 200 - const KeyType = enum { p256, secp256k1 }; 201 - 202 - fn decodeMultibaseKey(allocator: Allocator, multibase: []const u8) !struct { key: []u8, key_type: KeyType } { 203 - if (multibase.len == 0) return error.InvalidMultibase; 204 - if (multibase[0] != 'z') return error.UnsupportedMultibase; 205 - 206 - const decoded = try decodeBase58(allocator, multibase[1..]); 207 - errdefer allocator.free(decoded); 208 - 209 - if (decoded.len < 2) return error.InvalidMulticodec; 210 - 211 - if (decoded.len >= 2 and decoded[0] == 0xe7 and decoded[1] == 0x01) { 212 - const key = try allocator.dupe(u8, decoded[2..]); 213 - allocator.free(decoded); 214 - return .{ .key = key, .key_type = .secp256k1 }; 215 - } else if (decoded.len >= 2 and decoded[0] == 0x80 and decoded[1] == 0x24) { 216 - const key = try allocator.dupe(u8, decoded[2..]); 217 - allocator.free(decoded); 218 - return .{ .key = key, .key_type = .p256 }; 219 - } else { 220 - return error.UnsupportedKeyType; 221 - } 222 - } 223 - 224 - pub fn verifyJwtSignature(allocator: Allocator, jwt: []const u8, public_key_multibase: []const u8) !void { 225 - var parts = mem.splitScalar(u8, jwt, '.'); 226 - const header_b64 = parts.next() orelse return error.MalformedJwt; 227 - const payload_b64 = parts.next() orelse return error.MalformedJwt; 228 - const sig_b64 = parts.next() orelse return error.MalformedJwt; 229 - 230 - const header_end = @intFromPtr(header_b64.ptr) - @intFromPtr(jwt.ptr) + header_b64.len; 231 - const payload_end = header_end + 1 + payload_b64.len; 232 - const message = jwt[0..payload_end]; 233 - 234 - const sig_bytes = try decodeBase64Url(allocator, sig_b64); 235 - defer allocator.free(sig_bytes); 236 - 237 - const key_info = try decodeMultibaseKey(allocator, public_key_multibase); 238 - defer allocator.free(key_info.key); 239 - 240 - switch (key_info.key_type) { 241 - .p256 => { 242 - const pubkey = ecdsa.EcdsaP256Sha256.PublicKey.fromSec1(key_info.key) catch { 243 - return error.InvalidSignature; 244 - }; 245 - if (sig_bytes.len != ecdsa.EcdsaP256Sha256.Signature.encoded_length) { 246 - return error.InvalidSignature; 247 - } 248 - const sig = ecdsa.EcdsaP256Sha256.Signature.fromBytes(sig_bytes[0..64].*); 249 - sig.verify(message, pubkey) catch return error.InvalidSignature; 250 - }, 251 - .secp256k1 => { 252 - const pubkey = ecdsa.EcdsaSecp256k1Sha256.PublicKey.fromSec1(key_info.key) catch { 253 - return error.InvalidSignature; 254 - }; 255 - if (sig_bytes.len != ecdsa.EcdsaSecp256k1Sha256.Signature.encoded_length) { 256 - return error.InvalidSignature; 257 - } 258 - const sig = ecdsa.EcdsaSecp256k1Sha256.Signature.fromBytes(sig_bytes[0..64].*); 259 - sig.verify(message, pubkey) catch return error.InvalidSignature; 260 - }, 261 - } 262 - } 263 - 264 - fn decodeBase64Url(allocator: Allocator, input: []const u8) ![]u8 { 265 - var buf = try allocator.alloc(u8, input.len + 4); 266 - defer allocator.free(buf); 267 - 268 - var i: usize = 0; 269 - for (input) |c| { 270 - buf[i] = switch (c) { 271 - '-' => '+', 272 - '_' => '/', 273 - else => c, 274 - }; 275 - i += 1; 276 - } 277 - 278 - const padding = (4 - (i % 4)) % 4; 279 - for (0..padding) |_| { 280 - buf[i] = '='; 281 - i += 1; 282 - } 283 - 284 - const decoder = base64.standard.Decoder; 285 - const decoded_len = decoder.calcSizeForSlice(buf[0..i]) catch return JwtError.InvalidBase64; 286 - const result = try allocator.alloc(u8, decoded_len); 287 - decoder.decode(result, buf[0..i]) catch return JwtError.InvalidBase64; 288 - 289 - return result; 80 + return allocator.dupe(u8, jwt.payload.iss) catch null; 290 81 } 291 82 292 83 // ----------------------------------------------------------------------------- ··· 320 111 } 321 112 322 113 var response = client.query(nsid, params) catch |err| { 323 - std.debug.print("getFollows API error: {}\n", .{err}); 114 + log.err("getFollows API error: {}", .{err}); 324 115 return error.ApiFailed; 325 116 }; 326 117 defer response.deinit(); ··· 406 197 try params.put("filter", "posts_no_replies"); 407 198 408 199 var response = client.query(nsid, params) catch |err| { 409 - std.debug.print("getAuthorFeed API error for {s}: {}\n", .{ actor_did, err }); 200 + log.err("getAuthorFeed API error for {s}: {}", .{ actor_did, err }); 410 201 return error.ApiFailed; 411 202 }; 412 203 defer response.deinit(); ··· 435 226 436 227 return try posts.toOwnedSlice(allocator); 437 228 } 438 - 439 - // ----------------------------------------------------------------------------- 440 - // tests 441 - // ----------------------------------------------------------------------------- 442 - 443 - test "parseJwtUnsafe" { 444 - const jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6d2ViOmZlZWQuZXhhbXBsZSIsImV4cCI6OTk5OTk5OTk5OX0.fake_signature"; 445 - 446 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 447 - defer arena.deinit(); 448 - 449 - const payload = try parseJwtUnsafe(arena.allocator(), jwt); 450 - try std.testing.expectEqualStrings("did:plc:test", payload.iss); 451 - try std.testing.expectEqualStrings("did:web:feed.example", payload.aud); 452 - } 453 - 454 - test "decodeBase58" { 455 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 456 - defer arena.deinit(); 457 - 458 - const result1 = try decodeBase58(arena.allocator(), "1"); 459 - try std.testing.expectEqual(@as(usize, 1), result1.len); 460 - try std.testing.expectEqual(@as(u8, 0), result1[0]); 461 - 462 - const result2 = try decodeBase58(arena.allocator(), "2"); 463 - try std.testing.expectEqual(@as(usize, 1), result2.len); 464 - try std.testing.expectEqual(@as(u8, 1), result2[0]); 465 - }