aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/Utility/feedToken.test.ts44
-rw-r--r--src/lib/Utility/feedToken.ts96
2 files changed, 140 insertions, 0 deletions
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<ArrayBuffer>;
+
+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<CryptoKey> =>
+ 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<string> => {
+ 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<string | null> => {
+ 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;
+ }
+};