363 lines · 10.6 KB · TypeScript
RawDownload
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>()(  "MissingDpopProofError",  {},) {  override get message(): string {    return "Missing DPoP proof.";  }} export class MalformedDpopCompactJwtError extends Schema.TaggedErrorClass<MalformedDpopCompactJwtError>()(  "MalformedDpopCompactJwtError",  {},) {  override get message(): string {    return "Invalid DPoP compact JWT.";  }} export class InvalidDpopJwtHeaderError extends Schema.TaggedErrorClass<InvalidDpopJwtHeaderError>()(  "InvalidDpopJwtHeaderError",  {},) {  override get message(): string {    return "Invalid DPoP JWT header.";  }} export class InvalidDpopJwtPayloadError extends Schema.TaggedErrorClass<InvalidDpopJwtPayloadError>()(  "InvalidDpopJwtPayloadError",  {},) {  override get message(): string {    return "Invalid DPoP JWT payload.";  }} export class DpopThumbprintMismatchError extends Schema.TaggedErrorClass<DpopThumbprintMismatchError>()(  "DpopThumbprintMismatchError",  {    expectedThumbprint: Schema.String,    actualThumbprint: Schema.String,  },) {  override get message(): string {    return "DPoP key thumbprint mismatch.";  }} export class DpopMethodMismatchError extends Schema.TaggedErrorClass<DpopMethodMismatchError>()(  "DpopMethodMismatchError",  {    expectedMethod: Schema.String,    actualMethod: Schema.String,  },) {  override get message(): string {    return "DPoP method mismatch.";  }} export class DpopUrlMismatchError extends Schema.TaggedErrorClass<DpopUrlMismatchError>()(  "DpopUrlMismatchError",  {    requestUrl: Schema.String,    proofUrl: Schema.String,  },) {  override get message(): string {    return "DPoP URL mismatch.";  }} export class DpopAccessTokenHashMismatchError extends Schema.TaggedErrorClass<DpopAccessTokenHashMismatchError>()(  "DpopAccessTokenHashMismatchError",  {},) {  override get message(): string {    return "DPoP access token hash mismatch.";  }} export class DpopTimeWindowError extends Schema.TaggedErrorClass<DpopTimeWindowError>()(  "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>()(  "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<readonly [string, string, string]> {  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<Uint8Array> {  const result = Encoding.decodeBase64Url(value);  return Result.isSuccess(result) ? Option.some(result.success) : Option.none();} function base64UrlToStringOption(value: string): Option.Option<string> {  const result = Encoding.decodeBase64UrlString(value);  return Result.isSuccess(result) ? Option.some(result.success) : Option.none();} function decodeBase64UrlJsonOption<A>(  value: string,  decodeJson: (input: unknown) => Option.Option<A>,): Option.Option<A> {  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<string> {  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<Uint8Array> {  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({}));  }}