218 lines · 7.1 KB · TypeScript
RawDownload
import {  computeDpopAccessTokenHash,  computeDpopJwkThumbprint,  DpopPublicJwk,} from "@t3tools/shared/dpop";import * as Crypto from "effect/Crypto";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 Schema.TaggedErrorClass<BrowserDpopError>()(  "BrowserDpopError",  {    operation: Schema.Literals([      "open-key-storage",      "read-key",      "write-key",      "generate-key",      "export-private-key",      "export-public-key",      "decode-public-key",      "import-private-key",      "normalize-proof-url",      "generate-proof-id",      "sign-proof",    ]),    cause: Schema.optional(Schema.Defect()),  },) {  override get message(): string {    return `Browser DPoP operation "${this.operation}" failed.`;  }} export const isBrowserDpopError = Schema.is(BrowserDpopError); 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 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(          new BrowserDpopError({            operation: "open-key-storage",            ...(request.error === null ? {} : { cause: request.error }),          }),        ),      ),    );    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(              new BrowserDpopError({                operation: "read-key",                ...(request.error === null ? {} : { cause: request.error }),              }),            ),          ),        );        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(              new BrowserDpopError({                operation: "write-key",                ...(transaction.error === null ? {} : { cause: transaction.error }),              }),            ),          ),        );        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) => new BrowserDpopError({ operation: "generate-key", cause }),  });  const privateJwk = yield* Effect.tryPromise({    try: () => crypto.subtle.exportKey("jwk", generated.privateKey),    catch: (cause) => new BrowserDpopError({ operation: "export-private-key", cause }),  });  const publicJwk = yield* Effect.tryPromise({    try: () => crypto.subtle.exportKey("jwk", generated.publicKey),    catch: (cause) => new BrowserDpopError({ operation: "export-public-key", cause }),  }).pipe(    Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),    Effect.mapError((cause) =>      isBrowserDpopError(cause)        ? cause        : new BrowserDpopError({ operation: "decode-public-key", cause }),    ),  );  const privateKey = yield* Effect.tryPromise({    try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>,    catch: (cause) => new BrowserDpopError({ operation: "import-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) => new BrowserDpopError({ operation: "normalize-proof-url", cause }),    });    normalizedUrl.search = "";    normalizedUrl.hash = "";    const jti = yield* Crypto.Crypto.pipe(      Effect.flatMap((crypto) => crypto.randomUUIDv4),      Effect.mapError((cause) => new BrowserDpopError({ operation: "generate-proof-id", 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) => new BrowserDpopError({ operation: "sign-proof", cause }),    });    return { proof, thumbprint: input.proofKey.thumbprint };  });}