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;
}
};
|