Repository navigation
Expand file tree
/
Copy pathcredentialCookie.js
More file actions
117 lines (107 loc) · 4 KB
/
Copy pathcredentialCookie.js
File metadata and controls
117 lines (107 loc) · 4 KB
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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/**
* Encrypt/decrypt IRC credential (password) for the HTTP-only cookie.
* AES-256-GCM; IV (12 bytes) stored with ciphertext. Key from IRC_CREDENTIAL_KEY (32 bytes).
* Secure cookie only when not in development.
*/
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const COOKIE_NAME = 'anemoia_irc_cred';
const ALGO = 'aes-256-gcm';
const IV_LEN = 12;
const AUTH_TAG_LEN = 16;
const KEY_LEN = 32;
/** Fixed dev key (32 bytes) when IRC_CREDENTIAL_KEY is not set — development only. */
const DEV_FALLBACK_KEY_B64 = 'ZGV2LWNyZWRlbnRpYWwta2V5LTMyLWJ5dGVzLWxvbmchIQ==';
function getKey() {
let raw = process.env.IRC_CREDENTIAL_KEY;
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
if (process.env.NODE_ENV === 'development') {
raw = DEV_FALLBACK_KEY_B64;
} else {
throw new Error('IRC_CREDENTIAL_KEY is required for credential cookie');
}
}
raw = raw.replace(/\s/g, '').trim(); // strip newlines and spaces (e.g. from .env)
const buf = Buffer.from(raw, 'base64');
if (buf.length < KEY_LEN) {
if (process.env.NODE_ENV === 'development') {
raw = DEV_FALLBACK_KEY_B64;
return Buffer.from(raw, 'base64').subarray(0, KEY_LEN);
}
throw new Error('IRC_CREDENTIAL_KEY must be at least 32 bytes (base64 encoded). Got ' + buf.length + ' bytes.');
}
return buf.subarray(0, KEY_LEN); // use first 32 bytes (some generators produce 33)
}
/**
* Encrypt plaintext password and return a string suitable for cookie value (IV + ciphertext + tag, base64).
* @param {string} password
* @returns {string}
*/
export function encryptPassword(password) {
const key = getKey();
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const enc = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, enc, tag]).toString('base64');
}
/**
* Decrypt cookie value; returns plaintext password or null if invalid.
* @param {string} cookieValue
* @returns {string | null}
*/
export function decryptPassword(cookieValue) {
if (!cookieValue || typeof cookieValue !== 'string') return null;
try {
const key = getKey();
const buf = Buffer.from(cookieValue, 'base64');
if (buf.length < IV_LEN + AUTH_TAG_LEN + 1) return null;
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(buf.length - AUTH_TAG_LEN);
const ciphertext = buf.subarray(IV_LEN, buf.length - AUTH_TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext).toString('utf8') + decipher.final('utf8');
} catch (_) {
return null;
}
}
/**
* Set the credential cookie on the reply. Same expiry as session; Secure only when not development.
* @param {import('fastify').FastifyReply} reply
* @param {string} encryptedValue - From encryptPassword(password)
* @param {string} expiresAt - ISO string, same as session expiry
*/
export function setCredentialCookie(reply, encryptedValue, expiresAt) {
const isDev = process.env.NODE_ENV === 'development';
reply.setCookie(COOKIE_NAME, encryptedValue, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: !isDev,
expires: new Date(expiresAt),
maxAge: Math.max(0, Math.floor((new Date(expiresAt) - Date.now()) / 1000)),
});
}
/**
* Clear the credential cookie (e.g. on logout).
* @param {import('fastify').FastifyReply} reply
*/
export function clearCredentialCookie(reply) {
const isDev = process.env.NODE_ENV === 'development';
reply.clearCookie(COOKIE_NAME, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: !isDev,
});
}
/**
* Read and decrypt the credential from the request cookie. Returns null if missing or invalid.
* @param {import('fastify').FastifyRequest} request
* @returns {string | null} Plaintext password or null
*/
export function getCredentialFromRequest(request) {
const value = request.cookies?.[COOKIE_NAME];
return value ? decryptPassword(value) : null;
}
export { COOKIE_NAME };