diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ ZDS_PLC_ROTATION_KEY='64-hex-secp256k1-secret-or-private-multikey' \ ZDS_RECOVERY_DID_KEY='did:key:optionalRecoveryKey' \ ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ +ZDS_DPOP_SECRET='32-plus-random-bytes-for-dpop-nonces' \ ZDS_ADMIN_TOKEN='another-random-secret' \ ZDS_INVITE_REQUIRED=true \ zig build run -- \ diff --git a/build.zig b/build.zig --- a/build.zig +++ b/build.zig @@ -3,7 +3,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const version = b.option([]const u8, "version", "Build version reported by /xrpc/_health") orelse "0.0.3"; + const version = b.option([]const u8, "version", "Build version reported by /xrpc/_health") orelse "0.1.0"; const zat = b.dependency("zat", .{ .target = target, diff --git a/build.zig.zon b/build.zig.zon --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .zds, - .version = "0.0.3", + .version = "0.1.0", .fingerprint = 0x6ebabab1f62e1904, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/docs/architecture.md b/docs/architecture.md --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,8 +63,9 @@ OAuth follows the ATProto OAuth profile: PAR is required, redirect URIs are validated against client metadata, permission-set includes must resolve, tokens -enforce granular repo/blob/rpc/account/identity scopes, and revocation affects -resource-server checks. +enforce granular repo/blob/rpc/account/identity scopes, DPoP-bound OAuth access +tokens must present matching proof headers on resource requests, and revocation +affects resource-server checks. Password sessions and app passwords match the reference PDS account model. Session JWTs are accepted only when their JTI is present in the active session diff --git a/docs/operations.md b/docs/operations.md --- a/docs/operations.md +++ b/docs/operations.md @@ -77,6 +77,10 @@ - `ZDS_RECOVERY_DID_KEY`: optional recovery `did:key` returned before the PDS rotation key in recommended DID credentials. - `ZDS_JWT_SECRET`: stable secret for access and refresh JWTs. +- `ZDS_DPOP_SECRET`: stable secret for stateless DPoP nonce generation. If + unset, ZDS uses `ZDS_JWT_SECRET`. A separate value is recommended for + production. Multi-node deployments must configure every node with the same + effective DPoP secret so each node accepts the same nonce window. - `ZDS_HANDLE_DOMAINS`: comma-separated domains advertised by `describeServer`. - `ZDS_MAIL_PROVIDER`: email delivery provider. Default: `comail`. Supported: diff --git a/src/main.zig b/src/main.zig --- a/src/main.zig +++ b/src/main.zig @@ -43,6 +43,7 @@ if (options.proxy_service_id) |value| zds.core.config.setProxyServiceId(value); if (options.proxy_service_url) |value| zds.core.config.setProxyServiceUrl(value); if (options.jwt_secret) |value| zds.core.config.setJwtSecret(value); + zds.core.config.setDpopSecret(options.dpop_secret); zds.core.config.setAdminToken(options.admin_token); zds.core.config.setInviteRequired(options.invite_required); zds.core.config.setPermissionedData(options.permissioned_data); diff --git a/src/root.zig b/src/root.zig --- a/src/root.zig +++ b/src/root.zig @@ -35,6 +35,7 @@ pub const api_reference = @import("internal/api_reference/root.zig"); pub const cli = @import("internal/cli.zig"); pub const email_tokens = @import("internal/email_tokens.zig"); + pub const dpop = @import("internal/dpop.zig"); pub const passkeys = @import("internal/passkeys.zig"); pub const permissioned_data = @import("internal/permissioned_data.zig"); pub const scopes = @import("internal/scopes.zig"); diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -1,6 +1,7 @@ const std = @import("std"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); +const dpop = @import("../internal/dpop.zig"); const log = @import("../core/log.zig"); const http_api = @import("../http/api.zig"); const permission_sets = @import("oauth/permission_sets.zig"); @@ -121,7 +122,13 @@ const response_mode = try params.value(allocator, "response_mode") orelse "query"; if (!validResponseMode(response_mode)) return oauthError(request, .bad_request, "invalid_request", "Invalid response_mode"); const login_hint = try params.value(allocator, "login_hint"); - const dpop_jkt = try params.value(allocator, "dpop_jkt"); + var dpop_jkt = try params.value(allocator, "dpop_jkt"); + const maybe_dpop_proof = dpop.maybeVerifyRequest(allocator, request, null, dpop_jkt) catch |err| { + return handleAuthorizationDpopError(request, allocator, err); + }; + if (maybe_dpop_proof) |proof| { + if (dpop_jkt == null) dpop_jkt = proof.jkt; + } if (try params.value(allocator, "prompt")) |prompt| { if (!validPrompt(prompt)) return oauthError(request, .bad_request, "invalid_request", "Invalid prompt"); } @@ -434,7 +441,12 @@ } const did = oauth_request.sub orelse return oauthError(request, .bad_request, "invalid_grant", "Code was not authorized"); const account = (try store.findAccount(allocator, did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); - try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null, oauth_request.auth_method); + if (oauth_request.dpop_jkt) |expected_jkt| { + _ = dpop.verifyRequest(allocator, request, null, expected_jkt) catch |err| { + return handleAuthorizationDpopError(request, allocator, err); + }; + } + try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null, oauth_request.dpop_jkt, oauth_request.auth_method); } fn refreshToken(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { @@ -451,23 +463,42 @@ log.err("oauth refresh invalid_grant: revoked={} access_expires_at={d} client_id_match={} row_client={s} request_client={s} did={s}\n", .{ token_row.revoked, token_row.expires_at, client_id_match, token_row.client_id, client_id, token_row.did }); return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); } + if (token_row.dpop_jkt) |expected_jkt| { + _ = dpop.verifyRequest(allocator, request, null, expected_jkt) catch |err| { + return handleAuthorizationDpopError(request, allocator, err); + }; + } const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); - try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh, token_row.auth_method); + try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh, token_row.dpop_jkt, token_row.auth_method); } -fn issueTokenResponse(request: *http_api.Request, allocator: std.mem.Allocator, account: auth.Account, client_id: []const u8, scope: []const u8, revoke_old: ?[]const u8, auth_method: ?[]const u8) !void { +fn issueTokenResponse( + request: *http_api.Request, + allocator: std.mem.Allocator, + account: auth.Account, + client_id: []const u8, + scope: []const u8, + revoke_old: ?[]const u8, + dpop_jkt: ?[]const u8, + auth_method: ?[]const u8, +) !void { if (revoke_old) |old| try store.revokeOAuthToken(old); - const access = try auth.createSessionJwt(allocator, "access", account); + const access = if (dpop_jkt) |jkt| + try auth.createDpopSessionJwt(allocator, "access", account, jkt) + else + try auth.createSessionJwt(allocator, "access", account); const refresh = try auth.createSessionJwt(allocator, "refresh", account); const expires_at = now() + token_expires_in; const token_scope = permission_sets.expandScopes(allocator, scope) catch |err| { log.err("oauth token scope_expand failed client={s} did={s} scope={s} err={s}\n", .{ client_id, account.did, scope, @errorName(err) }); return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); }; - try store.putOAuthToken(account.did, client_id, token_scope, access, refresh, expires_at, auth_method); - const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":\"DPoP\",\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ + try store.putOAuthToken(account.did, client_id, token_scope, access, refresh, expires_at, dpop_jkt, auth_method); + const token_type = if (dpop_jkt != null) "DPoP" else "Bearer"; + const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":{f},\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ std.json.fmt(access, .{}), std.json.fmt(refresh, .{}), + std.json.fmt(token_type, .{}), token_expires_in, std.json.fmt(token_scope, .{}), std.json.fmt(account.did, .{}), @@ -1118,6 +1149,15 @@ var buf: [512]u8 = undefined; const body = try std.fmt.bufPrint(&buf, "{{\"error\":{f},\"error_description\":{f}}}", .{ std.json.fmt(err, .{}), std.json.fmt(description, .{}) }); try http_api.json(request, status, body); +} + +fn handleAuthorizationDpopError(request: *http_api.Request, allocator: std.mem.Allocator, err: anyerror) !void { + return switch (err) { + error.UseDpopNonce, error.MissingProof => dpop.challengeAuthorizationServer(request, allocator), + error.KeyBindingMismatch => oauthError(request, .bad_request, "invalid_grant", "DPoP proof does not match the expected JKT"), + error.Replay, error.InvalidProof => oauthError(request, .unauthorized, "invalid_dpop_proof", "Invalid DPoP proof"), + else => err, + }; } fn respondHtml(request: *http_api.Request, status: http.Status, body: []const u8) !void { diff --git a/src/atproto/server.zig b/src/atproto/server.zig --- a/src/atproto/server.zig +++ b/src/atproto/server.zig @@ -794,6 +794,7 @@ defer allocator.free(old_claims.did); defer allocator.free(old_claims.scope); defer allocator.free(old_claims.jti); + defer if (old_claims.cnf_jkt) |jkt| allocator.free(jkt); const auth_method = try store.sessionAuthMethod(allocator, account.did, old_claims.jti) orelse { return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); }; @@ -1191,6 +1192,7 @@ defer allocator.free(claims.did); defer allocator.free(claims.scope); defer allocator.free(claims.jti); + defer if (claims.cnf_jkt) |jkt| allocator.free(jkt); if (!std.mem.eql(u8, claims.did, did)) { try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); return error.HandledResponse; diff --git a/src/auth/tokens.zig b/src/auth/tokens.zig --- a/src/auth/tokens.zig +++ b/src/auth/tokens.zig @@ -13,6 +13,7 @@ did: []const u8, scope: []const u8, jti: []const u8, + cnf_jkt: ?[]const u8 = null, exp: i64, }; @@ -69,6 +70,12 @@ return token.token; } +pub fn createDpopSessionJwt(allocator: std.mem.Allocator, kind: []const u8, account: Account, dpop_jkt: []const u8) ![]const u8 { + const token = try createSessionTokenWithScopeAndCnf(allocator, account, kind, tryScope(kind), dpop_jkt); + allocator.free(token.jti); + return token.token; +} + pub fn createSessionPair(allocator: std.mem.Allocator, account: Account) !SessionPair { return createSessionPairWithAccessScope(allocator, account, "com.atproto.access"); } @@ -90,6 +97,16 @@ } fn createSessionTokenWithScope(allocator: std.mem.Allocator, account: Account, kind: []const u8, scope: []const u8) !SessionToken { + return createSessionTokenWithScopeAndCnf(allocator, account, kind, scope, null); +} + +fn createSessionTokenWithScopeAndCnf( + allocator: std.mem.Allocator, + account: Account, + kind: []const u8, + scope: []const u8, + dpop_jkt: ?[]const u8, +) !SessionToken { const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); var ts: std.posix.timespec = undefined; @@ -102,11 +119,18 @@ const exp = iat + lifetime; const jti_text = try std.fmt.allocPrint(allocator, "zds-{d}-{d}-{d}", .{ timestamp.sec, timestamp.nsec, jti }); errdefer allocator.free(jti_text); - const payload = try std.fmt.allocPrint( - allocator, - "{{\"sub\":{f},\"scope\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", - .{ std.json.fmt(account.did, .{}), std.json.fmt(scope, .{}), iat, exp, std.json.fmt(jti_text, .{}) }, - ); + const payload = if (dpop_jkt) |jkt| + try std.fmt.allocPrint( + allocator, + "{{\"sub\":{f},\"scope\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f},\"cnf\":{{\"jkt\":{f}}}}}", + .{ std.json.fmt(account.did, .{}), std.json.fmt(scope, .{}), iat, exp, std.json.fmt(jti_text, .{}), std.json.fmt(jkt, .{}) }, + ) + else + try std.fmt.allocPrint( + allocator, + "{{\"sub\":{f},\"scope\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", + .{ std.json.fmt(account.did, .{}), std.json.fmt(scope, .{}), iat, exp, std.json.fmt(jti_text, .{}) }, + ); defer allocator.free(payload); const header_b64 = try zat.jwt.base64UrlEncode(allocator, header); @@ -126,6 +150,12 @@ .iat = iat, .exp = exp, }; +} + +fn tryScope(kind: []const u8) []const u8 { + if (std.mem.eql(u8, kind, "access")) return "com.atproto.access"; + if (std.mem.eql(u8, kind, "refresh")) return "com.atproto.refresh"; + return "com.atproto.access"; } pub fn createServiceJwt( @@ -194,6 +224,7 @@ const claims = claimsFromSessionJwt(allocator, token) orelse return null; allocator.free(claims.scope); allocator.free(claims.jti); + if (claims.cnf_jkt) |jkt| allocator.free(jkt); return claims.did; } @@ -236,10 +267,15 @@ const jti = zat.json.getString(parsed.value, "jti") orelse return null; const exp = zat.json.getInt(parsed.value, "exp") orelse return null; if (exp < unixNow()) return null; + const cnf_jkt = if (zat.json.getPath(parsed.value, "cnf.jkt")) |jkt_value| switch (jkt_value) { + .string => |jkt| allocator.dupe(u8, jkt) catch return null, + else => return null, + } else null; return .{ .did = allocator.dupe(u8, subject) catch return null, .scope = allocator.dupe(u8, scope) catch return null, .jti = allocator.dupe(u8, jti) catch return null, + .cnf_jkt = cnf_jkt, .exp = exp, }; } @@ -253,6 +289,7 @@ defer std.testing.allocator.free(claims.did); defer std.testing.allocator.free(claims.scope); defer std.testing.allocator.free(claims.jti); + defer if (claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); try std.testing.expectEqualStrings(alice.did, claims.did); } @@ -267,12 +304,26 @@ defer std.testing.allocator.free(access_claims.did); defer std.testing.allocator.free(access_claims.scope); defer std.testing.allocator.free(access_claims.jti); + defer if (access_claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); try std.testing.expectEqualStrings("com.atproto.appPass", access_claims.scope); const refresh_claims = claimsFromSessionJwt(std.testing.allocator, pair.refresh.token).?; defer std.testing.allocator.free(refresh_claims.did); defer std.testing.allocator.free(refresh_claims.scope); defer std.testing.allocator.free(refresh_claims.jti); + defer if (refresh_claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); try std.testing.expectEqualStrings("com.atproto.refresh", refresh_claims.scope); +} + +test "dpop session token carries cnf jkt" { + const alice = testAccount(); + const token = try createDpopSessionJwt(std.testing.allocator, "access", alice, "test-jkt"); + defer std.testing.allocator.free(token); + const claims = claimsFromSessionJwt(std.testing.allocator, token).?; + defer std.testing.allocator.free(claims.did); + defer std.testing.allocator.free(claims.scope); + defer std.testing.allocator.free(claims.jti); + defer if (claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); + try std.testing.expectEqualStrings("test-jkt", claims.cnf_jkt.?); } test "creates signed service auth token" { diff --git a/src/core/config.zig b/src/core/config.zig --- a/src/core/config.zig +++ b/src/core/config.zig @@ -18,6 +18,7 @@ var proxy_service_id_value: []const u8 = "bsky_appview"; var proxy_service_url_value: []const u8 = "https://api.bsky.app"; var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me"; +var dpop_secret_value: ?[]const u8 = null; var admin_token_value: ?[]const u8 = null; var invite_required_value: bool = false; var permissioned_data_value: bool = false; @@ -104,6 +105,10 @@ pub fn jwtSecret() []const u8 { return jwt_secret_value; +} + +pub fn dpopSecret() []const u8 { + return dpop_secret_value orelse jwt_secret_value; } pub fn adminToken() ?[]const u8 { @@ -196,6 +201,10 @@ pub fn setJwtSecret(value: []const u8) void { jwt_secret_value = value; +} + +pub fn setDpopSecret(value: ?[]const u8) void { + dpop_secret_value = value; } pub fn setAdminToken(value: ?[]const u8) void { diff --git a/src/http/api.zig b/src/http/api.zig --- a/src/http/api.zig +++ b/src/http/api.zig @@ -1,5 +1,6 @@ const std = @import("std"); const auth = @import("../auth/tokens.zig"); +const dpop = @import("../internal/dpop.zig"); const log = @import("../core/log.zig"); const store = @import("../storage/store.zig"); const httpz = @import("httpz"); @@ -45,13 +46,14 @@ return error.AuthRequired; }; - const token_start = + const token_scheme: enum { bearer, dpop } = if (std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) - "bearer ".len + .bearer else if (std.ascii.startsWithIgnoreCase(auth_header, "dpop ")) - "dpop ".len + .dpop else return error.AuthRequired; + const token_start: usize = if (token_scheme == .bearer) "bearer ".len else "dpop ".len; const token = std.mem.trim(u8, auth_header[token_start..], " \t"); const claims = auth.claimsFromSessionJwt(allocator, token) orelse { @@ -61,6 +63,7 @@ defer allocator.free(claims.did); defer allocator.free(claims.scope); defer allocator.free(claims.jti); + defer if (claims.cnf_jkt) |jkt| allocator.free(jkt); if (!scopeAllows(claims.scope, scope)) { log.err("bearer auth invalid: session scope reject claims_scope={s} required={s} did={s}\n", .{ claims.scope, scope, claims.did }); return error.InvalidToken; @@ -87,7 +90,35 @@ log.err("bearer auth invalid: oauth row did mismatch row_did={s} claims_did={s}\n", .{ row.did, claims.did }); return error.InvalidToken; } + if (row.dpop_jkt) |expected_jkt| { + if (token_scheme != .dpop) { + log.err("oauth auth invalid: dpop-bound token used without DPoP auth scheme did={s}\n", .{claims.did}); + return error.InvalidToken; + } + if (claims.cnf_jkt == null or !std.mem.eql(u8, claims.cnf_jkt.?, expected_jkt)) { + log.err("oauth auth invalid: token cnf mismatch did={s}\n", .{claims.did}); + return error.InvalidToken; + } + _ = dpop.verifyRequest(allocator, request, token, expected_jkt) catch |err| switch (err) { + error.UseDpopNonce, error.MissingProof => { + dpop.challengeResource(@constCast(request), allocator) catch {}; + return error.InvalidToken; + }, + else => { + log.err("oauth auth invalid: dpop proof failed did={s} err={s}\n", .{ claims.did, @errorName(err) }); + return error.InvalidToken; + }, + }; + } else if (token_scheme != .bearer or claims.cnf_jkt != null) { + log.err("oauth auth invalid: bearer token/proof binding mismatch did={s}\n", .{claims.did}); + return error.InvalidToken; + } return .{ .account = account, .oauth_scope = row.scope, .oauth_client_id = row.client_id }; + } + + if (claims.cnf_jkt != null) { + log.err("bearer auth invalid: cnf-bound token had no oauth row did={s}\n", .{claims.did}); + return error.InvalidToken; } const active = store.sessionTokenIsActive(claims.did, claims.jti, claims.scope) catch |err| { @@ -322,6 +353,10 @@ fn setHeaders(res: *Response, headers: []const http.Header) !void { for (headers) |header| try addHeader(res, header.name, header.value); +} + +pub fn addResponseHeader(name: []const u8, value: []const u8) !void { + try addHeader(response(), name, value); } fn addHeader(res: *Response, name: []const u8, value: []const u8) !void { diff --git a/src/internal/cli.zig b/src/internal/cli.zig --- a/src/internal/cli.zig +++ b/src/internal/cli.zig @@ -22,6 +22,7 @@ proxy_service_id: ?[]const u8 = null, proxy_service_url: ?[]const u8 = null, jwt_secret: ?[]const u8 = null, + dpop_secret: ?[]const u8 = null, admin_token: ?[]const u8 = null, invite_required: bool = false, permissioned_data: bool = false, @@ -43,6 +44,7 @@ MissingHost, MissingAdminToken, MissingJwtSecret, + MissingDpopSecret, MissingLogLevel, MissingPlcDirectory, MissingPlcRotationKey, @@ -80,6 +82,7 @@ .proxy_service_id = env("ZDS_PROXY_SERVICE_ID"), .proxy_service_url = env("ZDS_PROXY_SERVICE_URL"), .jwt_secret = env("ZDS_JWT_SECRET"), + .dpop_secret = env("ZDS_DPOP_SECRET"), .admin_token = env("ZDS_ADMIN_TOKEN"), .invite_required = envBool("ZDS_INVITE_REQUIRED"), .permissioned_data = envBool("ZDS_PERMISSIONED_DATA"), @@ -106,6 +109,7 @@ \\ [--mail-provider comail|resend] [--email-from ADDRESS] \\ [--blob-upload-limit BYTES] [--blobstore-path PATH] \\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET] + \\ [--dpop-secret SECRET] \\ [--proxy-service-did DID] [--proxy-service-id ID] [--proxy-service-url URL] \\ [--admin-token TOKEN] [--invite-required] \\ [--log-level error|info|debug] [--debug] @@ -211,6 +215,10 @@ options.jwt_secret = args.next() orelse return error.MissingJwtSecret; return true; } + if (std.mem.eql(u8, arg, "--dpop-secret")) { + options.dpop_secret = args.next() orelse return error.MissingDpopSecret; + return true; + } if (std.mem.eql(u8, arg, "--admin-token")) { options.admin_token = args.next() orelse return error.MissingAdminToken; return true; @@ -265,6 +273,7 @@ .{ .flag = "--proxy-service-id=", .field = "proxy_service_id" }, .{ .flag = "--proxy-service-url=", .field = "proxy_service_url" }, .{ .flag = "--jwt-secret=", .field = "jwt_secret" }, + .{ .flag = "--dpop-secret=", .field = "dpop_secret" }, .{ .flag = "--admin-token=", .field = "admin_token" }, .{ .flag = "--log-level=", .field = "log_level" }, }; diff --git a/src/internal/dpop.zig b/src/internal/dpop.zig new file mode 100644 --- /dev/null +++ b/src/internal/dpop.zig @@ -0,0 +1,342 @@ +const std = @import("std"); +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 challengeResource(request: *http_api.Request, allocator: std.mem.Allocator) !void { + const nonce = try nextNonce(allocator); + 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); + 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 { + 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); +} + +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, +) 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; + + 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 - ProofMaxAgeSecs or iat > ts + ProofMaxAgeSecs) return error.InvalidProof; + + 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 + ProofMaxAgeSecs, ts + ProofMaxAgeSecs); + 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 { + var ts: std.posix.timespec = undefined; + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { + .SUCCESS => ts.sec, + else => 0, + }; +} + +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); + 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)); +} + +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)); +} + +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")); +} diff --git a/src/storage/store.zig b/src/storage/store.zig --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -89,6 +89,7 @@ refresh_token: []const u8, expires_at: i64, revoked: bool, + dpop_jkt: ?[]const u8, auth_method: ?[]const u8, }; @@ -1012,15 +1013,16 @@ access_token: []const u8, refresh_token: []const u8, expires_at: i64, + dpop_jkt: ?[]const u8, auth_method: ?[]const u8, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( - \\INSERT INTO oauth_tokens (access_token, refresh_token, did, client_id, scope, expires_at, auth_method) - \\VALUES (?, ?, ?, ?, ?, ?, ?) - , .{ access_token, refresh_token, did, client_id, scope, expires_at, auth_method }); + \\INSERT INTO oauth_tokens (access_token, refresh_token, did, client_id, scope, expires_at, dpop_jkt, auth_method) + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?) + , .{ access_token, refresh_token, did, client_id, scope, expires_at, dpop_jkt, auth_method }); } pub fn getOAuthToken(allocator: std.mem.Allocator, token: []const u8) !?OAuthToken { @@ -1028,7 +1030,7 @@ defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( - \\SELECT did, client_id, scope, access_token, refresh_token, expires_at, revoked_at, auth_method + \\SELECT did, client_id, scope, access_token, refresh_token, expires_at, revoked_at, dpop_jkt, auth_method \\FROM oauth_tokens \\WHERE access_token = ? OR refresh_token = ? \\ORDER BY created_at DESC @@ -1044,8 +1046,26 @@ .refresh_token = try allocator.dupe(u8, row.?.text(4)), .expires_at = row.?.int(5), .revoked = row.?.nullableInt(6) != null, - .auth_method = if (row.?.nullableText(7)) |text| try allocator.dupe(u8, text) else null, + .dpop_jkt = if (row.?.nullableText(7)) |text| try allocator.dupe(u8, text) else null, + .auth_method = if (row.?.nullableText(8)) |text| try allocator.dupe(u8, text) else null, }; +} + +pub fn recordDpopJti(jti: []const u8, expires_at: i64) !bool { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + try conn.exec("DELETE FROM dpop_jtis WHERE expires_at < unixepoch()", .{}); + const row = try conn.row("SELECT 1 FROM dpop_jtis WHERE jti = ?", .{jti}); + if (row) |found| { + found.deinit(); + return false; + } + try conn.exec( + \\INSERT INTO dpop_jtis (jti, expires_at) + \\VALUES (?, ?) + , .{ jti, expires_at }); + return true; } pub fn revokeOAuthToken(token: []const u8) !void { @@ -3308,6 +3328,7 @@ conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN response_mode TEXT NOT NULL DEFAULT 'query'") catch {}; conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN auth_method TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN auth_method TEXT") catch {}; + conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN dpop_jkt TEXT") catch {}; conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT") catch {}; conn.execNoArgs("ALTER TABLE permissioned_space_records ADD COLUMN repo_rev TEXT NOT NULL DEFAULT ''") catch {}; try migrateBlobTable(); @@ -4860,8 +4881,15 @@ \\ client_id TEXT NOT NULL, \\ scope TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, + \\ dpop_jkt TEXT, \\ auth_method TEXT, \\ revoked_at INTEGER, + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) + \\) + , + \\CREATE TABLE IF NOT EXISTS dpop_jtis ( + \\ jti TEXT PRIMARY KEY, + \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) ,