import * as Effect from "effect/Effect"; import { OperationFailure } from "./operation-result.ts"; import { Secret } from "../configuration/secrets.ts"; // HKDF separates stored credentials from cookie signing. Keep this byte format // stable so existing customer storage remains readable after an upgrade. export class CredentialFailure extends OperationFailure {} function credentialCall( read: () => PromiseLike, code: "credential_unreadable" | "credential_save_failed", ) { return Effect.tryPromise({ try: read, catch: () => new CredentialFailure(code), }); } const unreadable = "credential_unreadable"; function encryptionKey( secret: Secret, installationId: string, purpose: string, ) { return Effect.gen(function* () { const material = yield* credentialCall( () => crypto.subtle.importKey( "raw", new TextEncoder().encode(secret.reveal()), "HKDF", false, ["deriveKey"], ), unreadable, ); return yield* credentialCall( () => crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(installationId), info: new TextEncoder().encode(purpose), }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ), unreadable, ); }); } export function encryptCredential( value: Secret, secret: Secret, installationId: string, purpose: string, context = installationId, ) { return Effect.gen(function* () { const iv = crypto.getRandomValues(new Uint8Array(12)); const key = yield* encryptionKey(secret, installationId, purpose); const ciphertext = yield* credentialCall( () => crypto.subtle.encrypt( { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(context), }, key, new TextEncoder().encode(value.reveal()), ), "credential_save_failed", ); return JSON.stringify({ version: 1, iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(ciphertext)), }); }); } export function decryptCredential( value: string, secret: Secret, installationId: string, purpose: string, context = installationId, ) { return Effect.gen(function* () { const envelope = yield* Effect.try({ try: () => { const parsed = JSON.parse(value); if (parsed?.version !== 1) throw new CredentialFailure(unreadable); return { iv: new Uint8Array(parsed.iv), ciphertext: new Uint8Array(parsed.ciphertext), }; }, catch: () => new CredentialFailure(unreadable), }); const key = yield* encryptionKey(secret, installationId, purpose); const plaintext = yield* credentialCall( () => crypto.subtle.decrypt( { name: "AES-GCM", iv: envelope.iv, additionalData: new TextEncoder().encode(context), }, key, envelope.ciphertext, ), unreadable, ); return new Secret(new TextDecoder().decode(plaintext)); }); } export function encryptProviderKey( value: Secret, secret: Secret, installationId: string, ) { return encryptCredential( value, secret, installationId, "flarebot/provider-credentials/v1", ); } export function decryptProviderKey( value: string, secret: Secret, installationId: string, ) { return decryptCredential( value, secret, installationId, "flarebot/provider-credentials/v1", ); }