import { env } from "$env/dynamic/private"; // Authenticated encryption (AES-256-GCM) for the RSS feed token. The plaintext // is the user's long-lived AniList refresh token; the ciphertext is what travels // in the public feed URL (?feed=). Without FEED_TOKEN_KEY (server-only) the blob // is opaque and tamper-evident, so a leaked feed URL exposes no usable // credential — unlike the old ?token=&refresh= form that carried them in clear. // // WebCrypto (not node:crypto) so the byte types stay Uint8Array — the repo's // bun-types make node's Buffer structurally incompatible with crypto params. const IV_BYTES = 12; const TAG_BYTES = 16; const KEY_BYTES = 32; // WebCrypto's BufferSource requires the non-shared ArrayBuffer variant; the bare // Uint8Array generic defaults to ArrayBufferLike, which the crypto types reject. type KeyBytes = Uint8Array; const bytesFromBase64 = (text: string) => Uint8Array.from(atob(text), (character) => character.charCodeAt(0)); const base64UrlFromBytes = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)) .replaceAll("+", "-") .replaceAll("/", "_") .replaceAll("=", ""); const bytesFromBase64Url = (text: string) => bytesFromBase64(text.replaceAll("-", "+").replaceAll("_", "/")); const requireKeyBytes = (): KeyBytes => { const encoded = env.FEED_TOKEN_KEY; if (!encoded) throw new Error("FEED_TOKEN_KEY is not set"); const bytes = bytesFromBase64(encoded); if (bytes.length !== KEY_BYTES) throw new Error(`FEED_TOKEN_KEY must decode to ${KEY_BYTES} bytes`); return bytes; }; const importKey = (keyBytes: KeyBytes): Promise => crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [ "encrypt", "decrypt", ]); // The optional keyBytes parameter lets the unit test exercise the cipher with a // fixed key; production callers omit it and the env key is loaded. export const encryptFeedToken = async ( refreshToken: string, keyBytes: KeyBytes = requireKeyBytes(), ): Promise => { const key = await importKey(keyBytes); const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); const ciphertext = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, new TextEncoder().encode(refreshToken), ), ); const framed = new Uint8Array(iv.length + ciphertext.length); framed.set(iv, 0); framed.set(ciphertext, iv.length); return base64UrlFromBytes(framed); }; export const decryptFeedToken = async ( blob: string, keyBytes: KeyBytes = requireKeyBytes(), ): Promise => { try { const framed = bytesFromBase64Url(blob); if (framed.length < IV_BYTES + TAG_BYTES) return null; const key = await importKey(keyBytes); const iv = framed.subarray(0, IV_BYTES); const ciphertext = framed.subarray(IV_BYTES); const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, ciphertext, ); return new TextDecoder().decode(plaintext); } catch { return null; } };