1import { DpopProof } from "@t3tools/client-runtime/relay";
2import { computeDpopJwkThumbprint, DpopPublicJwk } from "@t3tools/shared/dpop";
3import * as Digest from "effect/crypto/Digest";
4import * as SecureRandom from "effect/crypto/SecureRandom";
5import * as Signature from "effect/crypto/Signature";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10export interface BrowserDpopKey {
11 readonly privateKey: CryptoKey;
12 readonly publicJwk: DpopPublicJwk;
13 readonly thumbprint: string;
14}
15
16export class BrowserDpopError extends Schema.TaggedError<BrowserDpopError>()("BrowserDpopError", {
17 message: Schema.String,
18 cause: Schema.optional(Schema.Defect()),
19}) {}
20
21const isBrowserDpopError = Schema.is(BrowserDpopError);
22
23const DPOP_DATABASE_NAME = "t3code:cloud-auth";
24const DPOP_DATABASE_VERSION = 1;
25const DPOP_KEY_STORE_NAME = "keys";
26const DPOP_KEY_ID = "relay-dpop-proof-key";
27const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk);
28
29export const layer = Layer.mergeAll(Digest.layerWebCrypto, Signature.layerWebCrypto).pipe(
30 Layer.provideMerge(SecureRandom.layerWebCrypto),
31);
32
33function dpopError(message: string, cause?: unknown) {
34 return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
35}
36
37function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {
38 return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {
39 const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);
40 request.addEventListener("error", () =>
41 resume(
42 Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)),
43 ),
44 );
45 request.addEventListener("upgradeneeded", () => {
46 if (!request.result.objectStoreNames.contains(DPOP_KEY_STORE_NAME)) {
47 request.result.createObjectStore(DPOP_KEY_STORE_NAME);
48 }
49 });
50 request.addEventListener("success", () => resume(Effect.succeed(request.result)));
51 });
52}
53
54function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, BrowserDpopError> {
55 if (typeof indexedDB === "undefined") {
56 return Effect.succeed(null);
57 }
58 return Effect.acquireUseRelease(
59 openDpopDatabase(),
60 (database) =>
61 Effect.callback<BrowserDpopKey | null, BrowserDpopError>((resume) => {
62 const request = database
63 .transaction(DPOP_KEY_STORE_NAME, "readonly")
64 .objectStore(DPOP_KEY_STORE_NAME)
65 .get(DPOP_KEY_ID);
66 request.addEventListener("error", () =>
67 resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))),
68 );
69 request.addEventListener("success", () =>
70 resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),
71 );
72 }),
73 (database) => Effect.sync(() => database.close()),
74 );
75}
76
77function writeStoredBrowserDpopKey(key: BrowserDpopKey): Effect.Effect<void, BrowserDpopError> {
78 if (typeof indexedDB === "undefined") {
79 return Effect.void;
80 }
81 return Effect.acquireUseRelease(
82 openDpopDatabase(),
83 (database) =>
84 Effect.callback<void, BrowserDpopError>((resume) => {
85 const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");
86 transaction.addEventListener("error", () =>
87 resume(
88 Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)),
89 ),
90 );
91 transaction.addEventListener("complete", () => resume(Effect.void));
92 transaction.objectStore(DPOP_KEY_STORE_NAME).put(key, DPOP_KEY_ID);
93 }),
94 (database) => Effect.sync(() => database.close()),
95 );
96}
97
98const generateBrowserDpopKey = Effect.gen(function* () {
99 const generated = yield* Effect.tryPromise({
100 try: () =>
101 crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
102 "sign",
103 "verify",
104 ]) as Promise<CryptoKeyPair>,
105 catch: (cause) => dpopError("Could not generate DPoP proof key.", cause),
106 });
107 const privateJwk = yield* Effect.tryPromise({
108 try: () => crypto.subtle.exportKey("jwk", generated.privateKey),
109 catch: (cause) => dpopError("Could not export DPoP private key.", cause),
110 });
111 const publicJwk = yield* Effect.tryPromise({
112 try: () => crypto.subtle.exportKey("jwk", generated.publicKey),
113 catch: (cause) => dpopError("Could not export DPoP public key.", cause),
114 }).pipe(
115 Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),
116 Effect.mapError((cause) =>
117 isBrowserDpopError(cause) ? cause : dpopError("Generated DPoP public key is invalid.", cause),
118 ),
119 );
120 const privateKey = yield* Effect.tryPromise({
121 try: () =>
122 crypto.subtle.importKey("jwk", privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, [
123 "sign",
124 ]),
125 catch: (cause) => dpopError("Could not import DPoP private key.", cause),
126 });
127 return {
128 privateKey,
129 publicJwk,
130 thumbprint: computeDpopJwkThumbprint(publicJwk),
131 };
132});
133
134/** The stored key, or a new one persisted on first use, as a DPoP proof key. */
135export const loadOrCreateProofKey = Effect.fn("dpop.loadOrCreateProofKey")(function* () {
136 const signature = yield* Signature.Signature;
137 const stored = yield* readStoredBrowserDpopKey();
138 const browserKey = stored ?? (yield* generateBrowserDpopKey);
139 if (stored === null) yield* writeStoredBrowserDpopKey(browserKey);
140 const privateKey = yield* Signature.privateKeyFromCryptoKey(
141 Signature.ecdsaP256,
142 browserKey.privateKey,
143 ).pipe(Effect.mapError((cause) => dpopError("Could not load DPoP proof key.", cause)));
144 return {
145 publicJwk: browserKey.publicJwk,
146 sign: (signingInput) =>
147 signature
148 .sign(privateKey, signingInput)
149 .pipe(
150 Effect.mapError(
151 (cause) =>
152 new DpopProof.DpopProofError({ message: "Could not sign DPoP proof.", cause }),
153 ),
154 ),
155 } satisfies DpopProof.DpopProofKey;
156});