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 { return Effect.callback((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 { if (typeof indexedDB === "undefined") { return Effect.succeed(null); } return Effect.acquireUseRelease( openDpopDatabase(), (database) => Effect.callback((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 { if (typeof indexedDB === "undefined") { return Effect.void; } return Effect.acquireUseRelease( openDpopDatabase(), (database) => Effect.callback((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, 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, 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 }; }); }