const enc = new TextEncoder();
const b64url = (x: ArrayBuffer | Uint8Array) => {
const bytes = x instanceof Uint8Array ? x : new Uint8Array(x);
let raw = "";
for (const byte of bytes) raw += String.fromCharCode(byte);
return btoa(raw).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
};
const unb64url = (x: string) => {
const raw = atob(
x.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - (x.length % 4)) % 4)
);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
};
const keyFor = async (secret: string) =>
crypto.subtle.importKey(
"raw",
await crypto.subtle.digest("SHA-256", enc.encode(secret)),
"AES-GCM",
false,
["encrypt", "decrypt"]
);
export const sha256Base64Url = async (value: string) =>
b64url(await crypto.subtle.digest("SHA-256", enc.encode(value)));
export const seal = async (value: unknown, secret: string) => {
if (!secret) throw new Error("session secret is not configured");
const iv = crypto.getRandomValues(new Uint8Array(12));
const body = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
await keyFor(secret),
enc.encode(JSON.stringify(value))
);
return `${b64url(iv)}.${b64url(body)}`;
};
export const open = async <T>(value: string | undefined, secret: string): Promise<T | null> => {
if (!value || !secret) return null;
try {
const [iv, body] = value.split(".");
if (!iv || !body) return null;
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: unb64url(iv) },
await keyFor(secret),
unb64url(body)
);
return JSON.parse(new TextDecoder().decode(plain)) as T;
} catch {
return null;
}
};