From f0398c1b8835022ce0489f0cfb5283d4178c48f6 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 2 Jun 2026 12:46:03 +0000 Subject: feat(security): add AES-GCM feed-token helper (M5) --- src/lib/Utility/feedToken.test.ts | 44 ++++++++++++++++++ src/lib/Utility/feedToken.ts | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/lib/Utility/feedToken.test.ts create mode 100644 src/lib/Utility/feedToken.ts (limited to 'src/lib/Utility') diff --git a/src/lib/Utility/feedToken.test.ts b/src/lib/Utility/feedToken.test.ts new file mode 100644 index 00000000..ffcb5fd7 --- /dev/null +++ b/src/lib/Utility/feedToken.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decryptFeedToken, encryptFeedToken } from "./feedToken"; + +// Fixed 32-byte keys so the cipher is exercised without the FEED_TOKEN_KEY env +// var (production omits the key argument and reads it from env). +const key = new Uint8Array(32).fill(7); +const otherKey = new Uint8Array(32).fill(9); + +describe("feed token", () => { + // Behaviour gate: the feed must still resolve to the same refresh token it + // was minted from, and the cleartext must never appear in the URL blob. + it("round-trips the refresh token without leaking it", async () => { + const refreshToken = "anilist-refresh-token-abc123"; + const sealed = await encryptFeedToken(refreshToken, key); + + expect(sealed).not.toContain(refreshToken); + expect(await decryptFeedToken(sealed, key)).toBe(refreshToken); + }); + + it("produces a fresh ciphertext each time (random IV)", async () => { + expect(await encryptFeedToken("same-token", key)).not.toBe( + await encryptFeedToken("same-token", key), + ); + }); + + it("rejects a token sealed with a different key", async () => { + const sealed = await encryptFeedToken("secret", otherKey); + + expect(await decryptFeedToken(sealed, key)).toBeNull(); + }); + + it("rejects a tampered ciphertext", async () => { + const sealed = await encryptFeedToken("secret", key); + const tampered = `${sealed[0] === "A" ? "B" : "A"}${sealed.slice(1)}`; + + expect(await decryptFeedToken(tampered, key)).toBeNull(); + }); + + it("rejects malformed input", async () => { + expect(await decryptFeedToken("", key)).toBeNull(); + expect(await decryptFeedToken("short", key)).toBeNull(); + expect(await decryptFeedToken("not valid base64url!!", key)).toBeNull(); + }); +}); diff --git a/src/lib/Utility/feedToken.ts b/src/lib/Utility/feedToken.ts new file mode 100644 index 00000000..1d26a443 --- /dev/null +++ b/src/lib/Utility/feedToken.ts @@ -0,0 +1,96 @@ +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; + } +}; -- cgit v1.2.3