import { p256 } from "@noble/curves/nist"; import { sha256 } from "@noble/hashes/sha2"; import * as Encoding from "effect/Encoding"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtuOption } from "./dpopCommon.ts"; import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts"; import { stableStringify } from "./relaySigning.ts"; export const DpopPublicJwk = DpopPublicJwkSchema; export type DpopPublicJwk = DpopPublicJwkType; export { normalizeDpopHtu, normalizeDpopHtuOption } from "./dpopCommon.ts"; const DPOP_TYP = "dpop+jwt"; const DPOP_ALG = "ES256"; const DEFAULT_MAX_AGE_SECONDS = 300; const DpopJwtHeader = Schema.Struct({ typ: Schema.Literal(DPOP_TYP), alg: Schema.Literal(DPOP_ALG), jwk: DpopPublicJwk, }); type DpopJwtHeader = typeof DpopJwtHeader.Type; const DpopJwtPayload = Schema.Struct({ htm: Schema.String.check(Schema.isNonEmpty()), htu: Schema.String.check(Schema.isNonEmpty()), jti: Schema.String.check(Schema.isNonEmpty()), iat: Schema.Int, ath: Schema.optionalKey(Schema.String), }); type DpopJwtPayload = typeof DpopJwtPayload.Type; const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(Schema.fromJsonString(DpopJwtHeader), { onExcessProperty: "preserve", }); const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(Schema.fromJsonString(DpopJwtPayload)); export class MissingDpopProofError extends Schema.TaggedErrorClass()( "MissingDpopProofError", {}, ) { override get message(): string { return "Missing DPoP proof."; } } export class MalformedDpopCompactJwtError extends Schema.TaggedErrorClass()( "MalformedDpopCompactJwtError", {}, ) { override get message(): string { return "Invalid DPoP compact JWT."; } } export class InvalidDpopJwtHeaderError extends Schema.TaggedErrorClass()( "InvalidDpopJwtHeaderError", {}, ) { override get message(): string { return "Invalid DPoP JWT header."; } } export class InvalidDpopJwtPayloadError extends Schema.TaggedErrorClass()( "InvalidDpopJwtPayloadError", {}, ) { override get message(): string { return "Invalid DPoP JWT payload."; } } export class DpopThumbprintMismatchError extends Schema.TaggedErrorClass()( "DpopThumbprintMismatchError", { expectedThumbprint: Schema.String, actualThumbprint: Schema.String, }, ) { override get message(): string { return "DPoP key thumbprint mismatch."; } } export class DpopMethodMismatchError extends Schema.TaggedErrorClass()( "DpopMethodMismatchError", { expectedMethod: Schema.String, actualMethod: Schema.String, }, ) { override get message(): string { return "DPoP method mismatch."; } } export class DpopUrlMismatchError extends Schema.TaggedErrorClass()( "DpopUrlMismatchError", { requestUrl: Schema.String, proofUrl: Schema.String, }, ) { override get message(): string { return "DPoP URL mismatch."; } } export class DpopAccessTokenHashMismatchError extends Schema.TaggedErrorClass()( "DpopAccessTokenHashMismatchError", {}, ) { override get message(): string { return "DPoP access token hash mismatch."; } } export class DpopTimeWindowError extends Schema.TaggedErrorClass()( "DpopTimeWindowError", { iat: Schema.Int, nowEpochSeconds: Schema.Int, maxAgeSeconds: Schema.Int, }, ) { override get message(): string { return "DPoP proof is outside the allowed time window."; } } export class InvalidDpopSignatureError extends Schema.TaggedErrorClass()( "InvalidDpopSignatureError", {}, ) { override get message(): string { return "Invalid DPoP signature."; } } export const DpopVerificationError = Schema.Union([ MissingDpopProofError, MalformedDpopCompactJwtError, InvalidDpopJwtHeaderError, InvalidDpopJwtPayloadError, DpopThumbprintMismatchError, DpopMethodMismatchError, DpopUrlMismatchError, DpopAccessTokenHashMismatchError, DpopTimeWindowError, InvalidDpopSignatureError, ]); export type DpopVerificationError = typeof DpopVerificationError.Type; export type DpopVerificationResult = | { readonly ok: true; readonly thumbprint: string; readonly jti: string; readonly iat: number; } | { readonly ok: false; readonly reason: string; readonly error: DpopVerificationError; }; function rejectDpopProof(error: DpopVerificationError): DpopVerificationResult { return { ok: false, reason: error.message, error }; } function compactJwtParts(proof: string): Option.Option { const parts = proof.split("."); return parts.length === 3 && parts[0] && parts[1] && parts[2] ? Option.some([parts[0], parts[1], parts[2]] as const) : Option.none(); } function base64UrlToBytesOption(value: string): Option.Option { const result = Encoding.decodeBase64Url(value); return Result.isSuccess(result) ? Option.some(result.success) : Option.none(); } function base64UrlToStringOption(value: string): Option.Option { const result = Encoding.decodeBase64UrlString(value); return Result.isSuccess(result) ? Option.some(result.success) : Option.none(); } function decodeBase64UrlJsonOption( value: string, decodeJson: (input: unknown) => Option.Option, ): Option.Option { return Option.flatMap(base64UrlToStringOption(value), decodeJson); } function hasPrivateJwkMaterial(header: DpopJwtHeader): boolean { return "d" in header.jwk; } function proofInput(input: { readonly proof: string | null | undefined }): Option.Option { return Option.fromNullishOr(input.proof).pipe( Option.map((proof) => proof.trim()), Option.filter((proof) => proof.length > 0), ); } function dpopThumbprintInput(jwk: DpopPublicJwk): string { return stableStringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y, }); } export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); } export function computeDpopAccessTokenHash(accessToken: string): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); } function publicKeyBytesFromJwkOption(jwk: DpopPublicJwk): Option.Option { const x = base64UrlToBytesOption(jwk.x); const y = base64UrlToBytesOption(jwk.y); if (Option.isNone(x) || Option.isNone(y)) { return Option.none(); } if (x.value.length !== 32 || y.value.length !== 32) { return Option.none(); } const publicKey = new Uint8Array(65); publicKey[0] = 0x04; publicKey.set(x.value, 1); publicKey.set(y.value, 33); return Option.some(publicKey); } export function verifyDpopProof(input: { readonly proof: string | null | undefined; readonly method: string; readonly url: string; readonly nowEpochSeconds: number; readonly expectedThumbprint?: string; readonly expectedAccessToken?: string; readonly maxAgeSeconds?: number; }): DpopVerificationResult { const proof = proofInput(input); if (Option.isNone(proof)) { return rejectDpopProof(new MissingDpopProofError({})); } const parts = compactJwtParts(proof.value); if (Option.isNone(parts)) { return rejectDpopProof(new MalformedDpopCompactJwtError({})); } const [headerPart, payloadPart, signaturePart] = parts.value; const header = decodeBase64UrlJsonOption(headerPart, decodeDpopJwtHeaderJson); if (Option.isNone(header)) { return rejectDpopProof(new InvalidDpopJwtHeaderError({})); } if (hasPrivateJwkMaterial(header.value)) { return rejectDpopProof(new InvalidDpopJwtHeaderError({})); } const payload = decodeBase64UrlJsonOption(payloadPart, decodeDpopJwtPayloadJson); if (Option.isNone(payload)) { return rejectDpopProof(new InvalidDpopJwtPayloadError({})); } const publicKey = publicKeyBytesFromJwkOption(header.value.jwk); if (Option.isNone(publicKey)) { return rejectDpopProof(new InvalidDpopJwtHeaderError({})); } const thumbprint = computeDpopJwkThumbprint(header.value.jwk); const expectedThumbprint = Option.fromUndefinedOr(input.expectedThumbprint); if (Option.isSome(expectedThumbprint) && thumbprint !== expectedThumbprint.value) { return rejectDpopProof( new DpopThumbprintMismatchError({ expectedThumbprint: expectedThumbprint.value, actualThumbprint: thumbprint, }), ); } if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { return rejectDpopProof( new DpopMethodMismatchError({ expectedMethod: input.method, actualMethod: payload.value.htm, }), ); } const normalizedHtu = normalizeDpopHtuOption(input.url); if (Option.isNone(normalizedHtu)) { return rejectDpopProof( new DpopUrlMismatchError({ requestUrl: input.url, proofUrl: payload.value.htu, }), ); } if (payload.value.htu !== normalizedHtu.value) { return rejectDpopProof( new DpopUrlMismatchError({ requestUrl: input.url, proofUrl: payload.value.htu, }), ); } const expectedAccessToken = Option.fromUndefinedOr(input.expectedAccessToken); if (Option.isSome(expectedAccessToken)) { const expectedAth = computeDpopAccessTokenHash(expectedAccessToken.value); if (payload.value.ath !== expectedAth) { return rejectDpopProof(new DpopAccessTokenHashMismatchError({})); } } const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; if ( payload.value.iat > input.nowEpochSeconds + 5 || input.nowEpochSeconds - payload.value.iat > maxAgeSeconds ) { return rejectDpopProof( new DpopTimeWindowError({ iat: payload.value.iat, nowEpochSeconds: input.nowEpochSeconds, maxAgeSeconds, }), ); } const signature = base64UrlToBytesOption(signaturePart); if (Option.isNone(signature)) { return rejectDpopProof(new InvalidDpopSignatureError({})); } try { const signatureInputHash = sha256(new TextEncoder().encode(`${headerPart}.${payloadPart}`)); const verified = p256.verify(signature.value, signatureInputHash, publicKey.value, { prehash: false, format: "compact", }); return verified ? { ok: true, thumbprint, jti: payload.value.jti, iat: payload.value.iat, } : rejectDpopProof(new InvalidDpopSignatureError({})); } catch { return rejectDpopProof(new InvalidDpopSignatureError({})); } }