atproto pds in zig pds.zat.dev
pds atproto
Something went wrong. Try again.
Zig
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128//! Verification for permissioned-space client attestations.
const std = @import("std");const clock = @import("../core/clock.zig");const zat = @import("zat");
const max_json_bytes = 256 * 1024;
pub const Claims = struct { client_id: []const u8, jti: []const u8, exp: i64,};
pub fn verify( allocator: std.mem.Allocator, io: std.Io, token: []const u8, expected_audience: []const u8,) !Claims { var parts = std.mem.splitScalar(u8, token, '.'); const header_part = parts.next() orelse return error.InvalidJwt; const payload_part = parts.next() orelse return error.InvalidJwt; const signature_part = parts.next() orelse return error.InvalidJwt; if (parts.next() != null) return error.InvalidJwt; const signing_input_len = header_part.len + 1 + payload_part.len; if (token.len < signing_input_len or token[header_part.len] != '.') return error.InvalidJwt;
const header_json = try zat.jwt.base64UrlDecode(allocator, header_part); defer allocator.free(header_json); const header = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); defer header.deinit(); if (!std.mem.eql(u8, zat.json.getString(header.value, "typ") orelse return error.InvalidJwt, "atproto-client-attestation+jwt")) return error.InvalidJwt; const alg = zat.json.getString(header.value, "alg") orelse return error.InvalidJwt; const jwt_alg = zat.jwt.Algorithm.fromString(alg) orelse return error.InvalidJwt; const kid = zat.json.getString(header.value, "kid") orelse return error.InvalidJwt;
const payload_json = try zat.jwt.base64UrlDecode(allocator, payload_part); defer allocator.free(payload_json); const payload = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); defer payload.deinit(); const issuer = zat.json.getString(payload.value, "iss") orelse return error.InvalidJwt; const subject = zat.json.getString(payload.value, "sub") orelse return error.InvalidJwt; const audience = zat.json.getString(payload.value, "aud") orelse return error.InvalidJwt; const issued_at = zat.json.getInt(payload.value, "iat") orelse return error.InvalidJwt; const expires_at = zat.json.getInt(payload.value, "exp") orelse return error.InvalidJwt; const jti = zat.json.getString(payload.value, "jti") orelse return error.InvalidJwt; if (!std.mem.eql(u8, issuer, subject)) return error.InvalidJwt; if (!std.mem.eql(u8, audience, expected_audience)) return error.AudienceMismatch; if (!fresh(now(), issued_at, expires_at)) return error.ExpiredJwt; if (!std.mem.startsWith(u8, issuer, "https://")) return error.InvalidClientId;
const metadata = try fetchJson(allocator, io, issuer); defer metadata.deinit(); if (!std.mem.eql(u8, zat.json.getString(metadata.value, "client_id") orelse issuer, issuer)) return error.InvalidClientMetadata; var fetched_jwks: ?std.json.Parsed(std.json.Value) = null; defer if (fetched_jwks) |parsed| parsed.deinit(); const jwks = if (metadata.value == .object and metadata.value.object.get("jwks") != null) metadata.value.object.get("jwks").? else blk: { const uri = zat.json.getString(metadata.value, "jwks_uri") orelse return error.InvalidClientMetadata; fetched_jwks = try fetchJson(allocator, io, uri); break :blk fetched_jwks.?.value; }; const public_key = try publicKeyFromJwks(allocator, jwks, kid, alg); defer allocator.free(public_key); const signature = try zat.jwt.base64UrlDecode(allocator, signature_part); defer allocator.free(signature); try zat.jwt.verifyJose(jwt_alg, token[0..signing_input_len], signature, public_key); return .{ .client_id = try allocator.dupe(u8, issuer), .jti = try allocator.dupe(u8, jti), .exp = expires_at, };}
fn fetchJson(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.json.Parsed(std.json.Value) { var transport = zat.HttpTransport.init(io, allocator); defer transport.deinit(); const result = try transport.fetch(.{ .url = url, .method = .GET, .accept = "application/json", .max_response_size = max_json_bytes, .on_redirect = .refuse, }); const status = @backingInt(result.status); if (status < 200 or status >= 300) return error.HttpStatus; return std.json.parseFromSlice(std.json.Value, allocator, result.body, .{});}
fn publicKeyFromJwks(allocator: std.mem.Allocator, jwks: std.json.Value, kid: []const u8, alg: []const u8) ![]u8 { const keys = zat.json.getArray(jwks, "keys") orelse return error.InvalidJwks; for (keys) |key| { if (!std.mem.eql(u8, zat.json.getString(key, "kid") orelse continue, kid)) continue; if (!std.mem.eql(u8, zat.json.getString(key, "kty") orelse "", "EC")) continue; const curve = zat.json.getString(key, "crv") orelse continue; if (std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, curve, "P-256")) continue; if (std.mem.eql(u8, alg, "ES256K") and !std.mem.eql(u8, curve, "secp256k1")) continue; const x = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "x") orelse return error.InvalidJwks); defer allocator.free(x); const y = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "y") orelse return error.InvalidJwks); defer allocator.free(y); if (x.len != 32 or y.len != 32) return error.InvalidJwks; const public_key = try allocator.alloc(u8, 33); public_key[0] = if ((y[31] & 1) == 1) 0x03 else 0x02; @memcpy(public_key[1..], x); return public_key; } return error.KeyNotFound;}
fn fresh(current: i64, issued_at: i64, expires_at: i64) bool { if (issued_at > current + 60 or expires_at < current) return false; return expires_at >= issued_at and expires_at - issued_at <= 300;}
fn now() i64 { return clock.nowSeconds();}
test "client attestation freshness is short lived" { try std.testing.expect(fresh(1000, 940, 1060)); try std.testing.expect(!fresh(1000, 600, 1100)); try std.testing.expect(!fresh(1000, 1061, 1100)); try std.testing.expect(!fresh(1000, 900, 999));}