atproto pds in zig pds.zat.dev
pds atproto
Something went wrong. Try again.
Zig
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402const std = @import("std");const clock = @import("../core/clock.zig");const config = @import("../core/config.zig");const http_api = @import("../http/api.zig");const store = @import("../storage/store.zig");const zat = @import("zat");
const ProofMaxAgeSecs: i64 = 300;const NonceRotationSecs: i64 = 60;
pub const Error = error{ MissingProof, InvalidProof, UseDpopNonce, Replay, KeyBindingMismatch,} || std.mem.Allocator.Error;
pub const Proof = struct { jkt: []const u8, jti: []const u8,};
pub fn nextNonce(allocator: std.mem.Allocator) ![]u8 { return nonceForCounter(allocator, nonceCounter(now()) + 1);}
pub fn attachNonce(allocator: std.mem.Allocator) !void { const nonce = try nextNonce(allocator); defer allocator.free(nonce); try http_api.addResponseHeader("dpop-nonce", nonce);}
pub fn challengeResource(request: *http_api.Request, allocator: std.mem.Allocator) !void { const nonce = try nextNonce(allocator); defer allocator.free(nonce); try http_api.addResponseHeader("dpop-nonce", nonce); try http_api.addResponseHeader("www-authenticate", "DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\""); try http_api.xrpcError(request, .unauthorized, "UseDpopNonce", "Resource server requires nonce in DPoP proof");}
pub fn challengeAuthorizationServer(request: *http_api.Request, allocator: std.mem.Allocator) !void { const nonce = try nextNonce(allocator); defer allocator.free(nonce); try http_api.addResponseHeader("dpop-nonce", nonce); const body = try allocator.dupe(u8, "{\"error\":\"use_dpop_nonce\",\"error_description\":\"Authorization server requires nonce in DPoP proof\"}"); try http_api.json(request, .bad_request, body);}
pub fn verifyRequest( allocator: std.mem.Allocator, request: *const http_api.Request, access_token: ?[]const u8, expected_jkt: ?[]const u8,) Error!Proof { return verifyRequestWithPolicy(allocator, request, access_token, expected_jkt, true, ProofMaxAgeSecs, ProofMaxAgeSecs, false);}
fn verifyRequestWithPolicy( allocator: std.mem.Allocator, request: *const http_api.Request, access_token: ?[]const u8, expected_jkt: ?[]const u8, require_nonce: bool, max_age_secs: i64, future_skew_secs: i64, require_es256: bool,) Error!Proof { const dpop_header = http_api.headerValue(request, "dpop") orelse return error.MissingProof; const htu = try htuForRequest(allocator, request); defer allocator.free(htu); return verifyProof(allocator, dpop_header, methodName(request.method), htu, access_token, expected_jkt, require_nonce, max_age_secs, future_skew_secs, require_es256);}
pub fn maybeVerifyRequest( allocator: std.mem.Allocator, request: *const http_api.Request, access_token: ?[]const u8, expected_jkt: ?[]const u8,) Error!?Proof { if (http_api.headerValue(request, "dpop") == null) return null; return try verifyRequest(allocator, request, access_token, expected_jkt);}
fn verifyProof( allocator: std.mem.Allocator, proof: []const u8, method: []const u8, expected_htu: []const u8, access_token: ?[]const u8, expected_jkt: ?[]const u8, require_nonce: bool, max_age_secs: i64, future_skew_secs: i64, require_es256: bool,) Error!Proof { var parts = std.mem.splitScalar(u8, proof, '.'); const header_part = parts.next() orelse return error.InvalidProof; const payload_part = parts.next() orelse return error.InvalidProof; const signature_part = parts.next() orelse return error.InvalidProof; if (parts.next() != null or header_part.len == 0 or payload_part.len == 0 or signature_part.len == 0) return error.InvalidProof;
const header_json = zat.jwt.base64UrlDecode(allocator, header_part) catch return error.InvalidProof; defer allocator.free(header_json); const payload_json = zat.jwt.base64UrlDecode(allocator, payload_part) catch return error.InvalidProof; defer allocator.free(payload_json); const signature = zat.jwt.base64UrlDecode(allocator, signature_part) catch return error.InvalidProof; defer allocator.free(signature);
const header = std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}) catch return error.InvalidProof; defer header.deinit(); const payload = std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}) catch return error.InvalidProof; defer payload.deinit();
if (!std.mem.eql(u8, zat.json.getString(header.value, "typ") orelse return error.InvalidProof, "dpop+jwt")) return error.InvalidProof; const alg_text = zat.json.getString(header.value, "alg") orelse return error.InvalidProof; const alg = zat.jwt.Algorithm.fromString(alg_text) orelse return error.InvalidProof; if (require_es256 and alg != .ES256) return error.InvalidProof;
const jwk = zat.json.getPath(header.value, "jwk") orelse return error.InvalidProof; const public_key = try publicKeyFromJwk(allocator, alg, jwk); defer allocator.free(public_key);
const signing_input = proof[0 .. header_part.len + 1 + payload_part.len]; zat.jwt.verifyJose(alg, signing_input, signature, public_key) catch return error.InvalidProof;
const jti = zat.json.getString(payload.value, "jti") orelse return error.InvalidProof; if (jti.len == 0) return error.InvalidProof; if (!std.mem.eql(u8, zat.json.getString(payload.value, "htm") orelse return error.InvalidProof, method)) return error.InvalidProof;
const htu = zat.json.getString(payload.value, "htu") orelse return error.InvalidProof; const normalized_htu = normalizeHtu(htu) orelse return error.InvalidProof; if (!std.mem.eql(u8, normalized_htu, expected_htu)) return error.InvalidProof;
const iat = zat.json.getInt(payload.value, "iat") orelse return error.InvalidProof; const ts = now(); if (iat < ts - max_age_secs or iat > ts + future_skew_secs) return error.InvalidProof;
if (require_nonce) { const nonce = zat.json.getString(payload.value, "nonce") orelse return error.UseDpopNonce; if (!validNonce(nonce, ts)) return error.UseDpopNonce; }
if (access_token) |token| { const expected_ath = try zat.oauth.accessTokenHash(allocator, token); defer allocator.free(expected_ath); if (!std.mem.eql(u8, zat.json.getString(payload.value, "ath") orelse return error.InvalidProof, expected_ath)) return error.InvalidProof; } else if (zat.json.getString(payload.value, "ath") != null) { return error.InvalidProof; }
const jkt = try jwkThumbprint(allocator, alg, jwk); errdefer allocator.free(jkt); if (expected_jkt) |expected| { if (!std.mem.eql(u8, jkt, expected)) return error.KeyBindingMismatch; } const expires_at = @max(iat + max_age_secs, ts + max_age_secs); if (!(store.recordDpopJti(jti, expires_at) catch return error.InvalidProof)) return error.Replay;
return .{ .jkt = jkt, .jti = try allocator.dupe(u8, jti), };}
fn publicKeyFromJwk(allocator: std.mem.Allocator, alg: zat.jwt.Algorithm, jwk: std.json.Value) ![]u8 { const crv = zat.json.getString(jwk, "crv") orelse return error.InvalidProof; switch (alg) { .ES256 => if (!std.mem.eql(u8, crv, "P-256")) return error.InvalidProof, .ES256K => if (!std.mem.eql(u8, crv, "secp256k1")) return error.InvalidProof, } if (!std.mem.eql(u8, zat.json.getString(jwk, "kty") orelse return error.InvalidProof, "EC")) return error.InvalidProof; const x = try decodeCoordinate(allocator, zat.json.getString(jwk, "x") orelse return error.InvalidProof); defer allocator.free(x); const y = try decodeCoordinate(allocator, zat.json.getString(jwk, "y") orelse return error.InvalidProof); defer allocator.free(y); const public_key = try allocator.alloc(u8, 33); public_key[0] = if ((y[31] & 1) == 0) 0x02 else 0x03; @memcpy(public_key[1..33], x); return public_key;}
fn decodeCoordinate(allocator: std.mem.Allocator, text: []const u8) ![]u8 { const raw = zat.jwt.base64UrlDecode(allocator, text) catch return error.InvalidProof; errdefer allocator.free(raw); if (raw.len != 32) return error.InvalidProof; return raw;}
fn jwkThumbprint(allocator: std.mem.Allocator, alg: zat.jwt.Algorithm, jwk: std.json.Value) ![]u8 { const crv = switch (alg) { .ES256 => "P-256", .ES256K => "secp256k1", }; const x = zat.json.getString(jwk, "x") orelse return error.InvalidProof; const y = zat.json.getString(jwk, "y") orelse return error.InvalidProof; const canonical = try std.fmt.allocPrint(allocator, "{{\"crv\":\"{s}\",\"kty\":\"EC\",\"x\":\"{s}\",\"y\":\"{s}\"}}", .{ crv, x, y }); defer allocator.free(canonical); var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(canonical, &digest, .{}); return zat.jwt.base64UrlEncode(allocator, &digest);}
fn htuForRequest(allocator: std.mem.Allocator, request: *const http_api.Request) ![]u8 { const path = stripQueryAndFragment(request.url.raw); return std.fmt.allocPrint(allocator, "{s}{s}", .{ config.publicUrl(), path });}
fn normalizeHtu(htu: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, htu, "http://") and !std.mem.startsWith(u8, htu, "https://")) return null; const start = if (std.mem.startsWith(u8, htu, "http://")) "http://".len else "https://".len; const slash = std.mem.indexOfScalarPos(u8, htu, start, '/') orelse return htu; const end = blk: { const query = std.mem.indexOfAnyPos(u8, htu, slash, "?#") orelse break :blk htu.len; break :blk query; }; return htu[0..end];}
fn validNonce(nonce: []const u8, ts: i64) bool { const counter = nonceCounter(ts); return nonceMatches(nonce, counter - 1) or nonceMatches(nonce, counter) or nonceMatches(nonce, counter + 1);}
fn nonceMatches(nonce: []const u8, counter: i64) bool { var buf: [64]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); writeNonceForCounter(&stream, counter) catch return false; return std.mem.eql(u8, nonce, stream.buffered());}
fn nonceForCounter(allocator: std.mem.Allocator, counter: i64) ![]u8 { var buf: [64]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try writeNonceForCounter(&stream, counter); return allocator.dupe(u8, stream.buffered());}
fn writeNonceForCounter(writer: *std.Io.Writer, counter: i64) !void { var counter_bytes: [8]u8 = undefined; std.mem.writeInt(i64, &counter_bytes, counter, .big); var mac: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; std.crypto.auth.hmac.sha2.HmacSha256.create(&mac, &counter_bytes, config.dpopSecret()); const encoded = try zat.jwt.base64UrlEncode(std.heap.page_allocator, &mac); defer std.heap.page_allocator.free(encoded); try writer.writeAll(encoded);}
fn nonceCounter(ts: i64) i64 { return @divFloor(ts, NonceRotationSecs);}
fn stripQueryAndFragment(value: []const u8) []const u8 { const end = std.mem.indexOfAny(u8, value, "?#") orelse value.len; return value[0..end];}
fn methodName(method: anytype) []const u8 { return switch (method) { .GET => "GET", .HEAD => "HEAD", .POST => "POST", .PUT => "PUT", .PATCH => "PATCH", .DELETE => "DELETE", .OPTIONS => "OPTIONS", else => "GET", };}
fn now() i64 { return clock.nowSeconds();}
test "validates DPoP proof and rejects replay" { const allocator = std.testing.allocator; try store.init(std.Options.debug_io, ":memory:"); defer store.close();
const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, }); const access_token = "access-token"; const ath = try zat.oauth.accessTokenHash(allocator, access_token); defer allocator.free(ath); const nonce = try nextNonce(allocator); defer allocator.free(nonce); const proof = try zat.oauth.createDpopProof( allocator, std.Options.debug_io, &keypair, "GET", "https://pds.example/xrpc/com.atproto.repo.getRecord?repo=x", nonce, ath, ); defer allocator.free(proof); const expected_jkt = try keypair.jwkThumbprint(allocator); defer allocator.free(expected_jkt);
const verified = try verifyProof(allocator, proof, "GET", "https://pds.example/xrpc/com.atproto.repo.getRecord", access_token, expected_jkt, true, ProofMaxAgeSecs, ProofMaxAgeSecs, false); defer allocator.free(verified.jkt); defer allocator.free(verified.jti); try std.testing.expectEqualStrings(expected_jkt, verified.jkt); try std.testing.expectError(error.Replay, verifyProof(allocator, proof, "GET", "https://pds.example/xrpc/com.atproto.repo.getRecord", access_token, expected_jkt, true, ProofMaxAgeSecs, ProofMaxAgeSecs, false));}
test "rejects DPoP proof with wrong token binding" { const allocator = std.testing.allocator; try store.init(std.Options.debug_io, ":memory:"); defer store.close();
const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, }); const nonce = try nextNonce(allocator); defer allocator.free(nonce); const ath = try zat.oauth.accessTokenHash(allocator, "access-token-a"); defer allocator.free(ath); const proof = try zat.oauth.createDpopProof( allocator, std.Options.debug_io, &keypair, "POST", "https://pds.example/xrpc/com.atproto.repo.createRecord", nonce, ath, ); defer allocator.free(proof); const expected_jkt = try keypair.jwkThumbprint(allocator); defer allocator.free(expected_jkt);
try std.testing.expectError(error.InvalidProof, verifyProof(allocator, proof, "POST", "https://pds.example/xrpc/com.atproto.repo.createRecord", "access-token-b", expected_jkt, true, ProofMaxAgeSecs, ProofMaxAgeSecs, false));}
test "rejects DPoP proof with mismatched JKT" { const allocator = std.testing.allocator; try store.init(std.Options.debug_io, ":memory:"); defer store.close();
const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x80, }); const nonce = try nextNonce(allocator); defer allocator.free(nonce); const proof = try zat.oauth.createDpopProof( allocator, std.Options.debug_io, &keypair, "POST", "https://pds.example/oauth/token", nonce, null, ); defer allocator.free(proof);
try std.testing.expectError(error.KeyBindingMismatch, verifyProof(allocator, proof, "POST", "https://pds.example/oauth/token", null, "not-the-key", true, ProofMaxAgeSecs, ProofMaxAgeSecs, false));}
test "space DPoP proof does not require an OAuth nonce" { const allocator = std.testing.allocator; try store.init(std.Options.debug_io, ":memory:"); defer store.close(); const keypair = try zat.Keypair.fromSecretKey(.p256, @splat(0x31)); const proof = try zat.oauth.createDpopProof( allocator, std.Options.debug_io, &keypair, "POST", "https://space.example/xrpc/com.atproto.space.getSpaceCredential", "", null, ); defer allocator.free(proof); const expected_jkt = try keypair.jwkThumbprint(allocator); defer allocator.free(expected_jkt); const verified = try verifyProof( allocator, proof, "POST", "https://space.example/xrpc/com.atproto.space.getSpaceCredential", null, null, false, 60, 5, true, ); defer allocator.free(verified.jkt); defer allocator.free(verified.jti); try std.testing.expectEqualStrings(expected_jkt, verified.jkt);}