aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Utility/feedToken.ts
blob: 1d26a4438c1e46d54c931bb6dbf9e86c919ea301 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
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;
	}
};