| 1 | import { p256 } from "@noble/curves/nist"; |
| 2 | import { sha256 } from "@noble/hashes/sha2"; |
| 3 | import * as Base64Url from "effect/encoding/Base64Url"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Result from "effect/Result"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts"; |
| 9 | import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts"; |
| 10 | import { stableStringify } from "./relaySigning.ts"; |
| 11 | |
| 12 | const DPOP_TYP = "dpop+jwt"; |
| 13 | const DPOP_ALG = "ES256"; |
| 14 | const DEFAULT_MAX_AGE_SECONDS = 300; |
| 15 | |
| 16 | export const DpopPublicJwk = DpopPublicJwkSchema; |
| 17 | export type DpopPublicJwk = DpopPublicJwkType; |
| 18 | export { normalizeDpopHtu }; |
| 19 | |
| 20 | export 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 | ]); |
| 31 | export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type; |
| 32 | |
| 33 | const DpopJwtHeaderPublicJwk = Schema.Struct({ |
| 34 | ...DpopPublicJwkSchema.fields, |
| 35 | d: Schema.optionalKey(Schema.Never), |
| 36 | }); |
| 37 | |
| 38 | const DpopJwtHeaderJson = Schema.fromJsonString( |
| 39 | Schema.Struct({ |
| 40 | typ: Schema.Literal(DPOP_TYP), |
| 41 | alg: Schema.Literal(DPOP_ALG), |
| 42 | jwk: DpopJwtHeaderPublicJwk, |
| 43 | }), |
| 44 | ); |
| 45 | const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson); |
| 46 | |
| 47 | const 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 | ); |
| 56 | const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson); |
| 57 | |
| 58 | export 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 | |
| 71 | function base64UrlToBytes(value: string): Uint8Array { |
| 72 | return Result.getOrThrow(Base64Url.decode(value)); |
| 73 | } |
| 74 | |
| 75 | function decodeBase64UrlDpopJwtHeader(value: string) { |
| 76 | return decodeDpopJwtHeaderJson(Result.getOrThrow(Base64Url.decodeString(value))); |
| 77 | } |
| 78 | |
| 79 | function decodeBase64UrlDpopJwtPayload(value: string) { |
| 80 | return decodeDpopJwtPayloadJson(Result.getOrThrow(Base64Url.decodeString(value))); |
| 81 | } |
| 82 | |
| 83 | function 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 | |
| 92 | export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string { |
| 93 | return Base64Url.encode(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); |
| 94 | } |
| 95 | |
| 96 | export function computeDpopAccessTokenHash(accessToken: string): string { |
| 97 | return Base64Url.encode(sha256(new TextEncoder().encode(accessToken))); |
| 98 | } |
| 99 | |
| 100 | function 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 | |
| 113 | export 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 | } |