185 lines · 6.4 KB · TypeScript
RawDownload
import {  computeDpopAccessTokenHash,  computeDpopJwkThumbprint,  DpopPublicJwk,} from "@t3tools/shared/dpop";import * as Crypto from "effect/Crypto";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Schema from "effect/Schema";import { importJWK, SignJWT, type JWK } from "jose"; export interface BrowserDpopKey {  readonly privateKey: CryptoKey;  readonly publicJwk: DpopPublicJwk;  readonly thumbprint: string;} export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{  readonly message: string;  readonly cause?: unknown;}> {} const DPOP_DATABASE_NAME = "t3code:cloud-auth";const DPOP_DATABASE_VERSION = 1;const DPOP_KEY_STORE_NAME = "keys";const DPOP_KEY_ID = "relay-dpop-proof-key";const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); export const browserCryptoLayer = Layer.succeed(  Crypto.Crypto,  Crypto.make({    randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),    digest: (algorithm, data) =>      Effect.promise(async () => {        const input = new Uint8Array(data.length);        input.set(data);        return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer));      }),  }),); function dpopError(message: string, cause?: unknown) {  return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });} function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {  return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {    const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);    request.addEventListener("error", () =>      resume(        Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)),      ),    );    request.addEventListener("upgradeneeded", () => {      if (!request.result.objectStoreNames.contains(DPOP_KEY_STORE_NAME)) {        request.result.createObjectStore(DPOP_KEY_STORE_NAME);      }    });    request.addEventListener("success", () => resume(Effect.succeed(request.result)));  });} export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, BrowserDpopError> {  if (typeof indexedDB === "undefined") {    return Effect.succeed(null);  }  return Effect.acquireUseRelease(    openDpopDatabase(),    (database) =>      Effect.callback<BrowserDpopKey | null, BrowserDpopError>((resume) => {        const request = database          .transaction(DPOP_KEY_STORE_NAME, "readonly")          .objectStore(DPOP_KEY_STORE_NAME)          .get(DPOP_KEY_ID);        request.addEventListener("error", () =>          resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))),        );        request.addEventListener("success", () =>          resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),        );      }),    (database) => Effect.sync(() => database.close()),  );} export function writeStoredBrowserDpopKey(  key: BrowserDpopKey,): Effect.Effect<void, BrowserDpopError> {  if (typeof indexedDB === "undefined") {    return Effect.void;  }  return Effect.acquireUseRelease(    openDpopDatabase(),    (database) =>      Effect.callback<void, BrowserDpopError>((resume) => {        const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");        transaction.addEventListener("error", () =>          resume(            Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)),          ),        );        transaction.addEventListener("complete", () => resume(Effect.void));        transaction.objectStore(DPOP_KEY_STORE_NAME).put(key, DPOP_KEY_ID);      }),    (database) => Effect.sync(() => database.close()),  );} export const generateBrowserDpopKey = Effect.gen(function* () {  const generated = yield* Effect.tryPromise({    try: () =>      crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [        "sign",        "verify",      ]) as Promise<CryptoKeyPair>,    catch: (cause) => dpopError("Could not generate DPoP proof key.", cause),  });  const privateJwk = yield* Effect.tryPromise({    try: () => crypto.subtle.exportKey("jwk", generated.privateKey),    catch: (cause) => dpopError("Could not export DPoP private key.", cause),  });  const publicJwk = yield* Effect.tryPromise({    try: () => crypto.subtle.exportKey("jwk", generated.publicKey),    catch: (cause) => dpopError("Could not export DPoP public key.", cause),  }).pipe(    Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),    Effect.mapError((cause) =>      cause instanceof BrowserDpopError        ? cause        : dpopError("Generated DPoP public key is invalid.", cause),    ),  );  const privateKey = yield* Effect.tryPromise({    try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>,    catch: (cause) => dpopError("Could not import DPoP private key.", cause),  });  return {    privateKey,    publicJwk,    thumbprint: computeDpopJwkThumbprint(publicJwk),  };}); export function createBrowserDpopProof(input: {  readonly method: string;  readonly url: string;  readonly accessToken?: string;  readonly proofKey: BrowserDpopKey;}): Effect.Effect<  { readonly proof: string; readonly thumbprint: string },  BrowserDpopError,  Crypto.Crypto> {  return Effect.gen(function* () {    const normalizedUrl = yield* Effect.try({      try: () => new URL(input.url),      catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause),    });    normalizedUrl.search = "";    normalizedUrl.hash = "";    const jti = yield* Crypto.Crypto.pipe(      Effect.flatMap((crypto) => crypto.randomUUIDv4),      Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)),    );    const proof = yield* Effect.tryPromise({      try: () =>        new SignJWT({          htm: input.method.toUpperCase(),          htu: normalizedUrl.toString(),          jti,          ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),        })          .setProtectedHeader({            typ: "dpop+jwt",            alg: "ES256",            jwk: input.proofKey.publicJwk,          })          .setIssuedAt()          .sign(input.proofKey.privateKey),      catch: (cause) => dpopError("Could not sign DPoP proof.", cause),    });    return { proof, thumbprint: input.proofKey.thumbprint };  });}