1import { p256 } from "@noble/curves/nist";
2import { sha256 } from "@noble/hashes/sha2";
3import * as Base64Url from "effect/encoding/Base64Url";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7
8import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts";
9import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts";
10import { stableStringify } from "./relaySigning.ts";
11
12const DPOP_TYP = "dpop+jwt";
13const DPOP_ALG = "ES256";
14const DEFAULT_MAX_AGE_SECONDS = 300;
15
16export const DpopPublicJwk = DpopPublicJwkSchema;
17export type DpopPublicJwk = DpopPublicJwkType;
18export { normalizeDpopHtu };
19
20export const DpopVerificationFailureCode = Schema.Literals([
21 "missing_proof",
22 "malformed_proof",
23 "key_mismatch",
24 "method_mismatch",
25 "url_mismatch",
26 "access_token_hash_mismatch",
27 "time_window",
28 "invalid_signature",
29 "invalid_proof",
30]);
31export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type;
32
33const DpopJwtHeaderPublicJwk = Schema.Struct({
34 ...DpopPublicJwkSchema.fields,
35 d: Schema.optionalKey(Schema.Never),
36});
37
38const DpopJwtHeaderJson = Schema.fromJsonString(
39 Schema.Struct({
40 typ: Schema.Literal(DPOP_TYP),
41 alg: Schema.Literal(DPOP_ALG),
42 jwk: DpopJwtHeaderPublicJwk,
43 }),
44);
45const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson);
46
47const DpopJwtPayloadJson = Schema.fromJsonString(
48 Schema.Struct({
49 htm: Schema.String.check(Schema.isNonEmpty()),
50 htu: Schema.String.check(Schema.isNonEmpty()),
51 jti: Schema.String.check(Schema.isNonEmpty()),
52 iat: Schema.Int,
53 ath: Schema.optionalKey(Schema.String),
54 }),
55);
56const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson);
57
58export type DpopVerificationResult =
59 | {
60 readonly ok: true;
61 readonly thumbprint: string;
62 readonly jti: string;
63 readonly iat: number;
64 }
65 | {
66 readonly ok: false;
67 readonly code: DpopVerificationFailureCode;
68 readonly reason: string;
69 };
70
71function base64UrlToBytes(value: string): Uint8Array {
72 return Result.getOrThrow(Base64Url.decode(value));
73}
74
75function decodeBase64UrlDpopJwtHeader(value: string) {
76 return decodeDpopJwtHeaderJson(Result.getOrThrow(Base64Url.decodeString(value)));
77}
78
79function decodeBase64UrlDpopJwtPayload(value: string) {
80 return decodeDpopJwtPayloadJson(Result.getOrThrow(Base64Url.decodeString(value)));
81}
82
83function dpopThumbprintInput(jwk: DpopPublicJwkType): string {
84 return stableStringify({
85 crv: jwk.crv,
86 kty: jwk.kty,
87 x: jwk.x,
88 y: jwk.y,
89 });
90}
91
92export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string {
93 return Base64Url.encode(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk))));
94}
95
96export function computeDpopAccessTokenHash(accessToken: string): string {
97 return Base64Url.encode(sha256(new TextEncoder().encode(accessToken)));
98}
99
100function publicKeyBytesFromJwk(jwk: DpopPublicJwkType): Uint8Array {
101 const x = base64UrlToBytes(jwk.x);
102 const y = base64UrlToBytes(jwk.y);
103 if (x.length !== 32 || y.length !== 32) {
104 throw new Error("Invalid P-256 public key coordinate length.");
105 }
106 const publicKey = new Uint8Array(65);
107 publicKey[0] = 0x04;
108 publicKey.set(x, 1);
109 publicKey.set(y, 33);
110 return publicKey;
111}
112
113export function verifyDpopProof(input: {
114 readonly proof: string | null | undefined;
115 readonly method: string;
116 readonly url: string;
117 readonly nowEpochSeconds: number;
118 readonly expectedThumbprint?: string;
119 readonly expectedAccessToken?: string;
120 readonly maxAgeSeconds?: number;
121}): DpopVerificationResult {
122 if (!input.proof?.trim()) {
123 return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." };
124 }
125
126 const parts = input.proof.split(".");
127 if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
128 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP compact JWT." };
129 }
130
131 try {
132 const header = decodeBase64UrlDpopJwtHeader(parts[0]);
133 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
134 if (Option.isNone(header)) {
135 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT header." };
136 }
137 if (Option.isNone(payload)) {
138 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT payload." };
139 }
140
141 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
142 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
143 return { ok: false, code: "key_mismatch", reason: "DPoP key thumbprint mismatch." };
144 }
145 if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {
146 return { ok: false, code: "method_mismatch", reason: "DPoP method mismatch." };
147 }
148 const normalizedHtu = normalizeDpopHtu(input.url);
149 if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {
150 return { ok: false, code: "url_mismatch", reason: "DPoP URL mismatch." };
151 }
152 if (input.expectedAccessToken) {
153 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
154 if (payload.value.ath !== expectedAth) {
155 return {
156 ok: false,
157 code: "access_token_hash_mismatch",
158 reason: "DPoP access token hash mismatch.",
159 };
160 }
161 }
162
163 const signature = base64UrlToBytes(parts[2]);
164 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
165 const verified = p256.verify(
166 signature,
167 signatureInputHash,
168 publicKeyBytesFromJwk(header.value.jwk),
169 {
170 prehash: false,
171 format: "compact",
172 },
173 );
174 if (!verified) {
175 return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." };
176 }
177
178 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
179 if (
180 payload.value.iat > input.nowEpochSeconds + 5 ||
181 input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
182 ) {
183 return {
184 ok: false,
185 code: "time_window",
186 reason: "DPoP proof is outside the allowed time window.",
187 };
188 }
189
190 return {
191 ok: true,
192 thumbprint,
193 jti: payload.value.jti,
194 iat: payload.value.iat,
195 };
196 } catch {
197 return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." };
198 }
199}