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",
);
}