forked from Xeio/IdleCodeRedeemer
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcrypto.ts
More file actions
72 lines (65 loc) · 3.03 KB
/
crypto.ts
File metadata and controls
72 lines (65 loc) · 3.03 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
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
// Unambiguous prefix for all values produced by encrypt().
// Using a fixed prefix makes detection a simple startsWith() with no false-positive
// risk from plaintext values that happen to match a hex-triplet pattern, and makes
// the format forward-compatible if the algorithm changes (bump to enc2: etc.).
const ENCRYPTION_PREFIX = 'enc1:';
// Validate and cache the key at module load time so a missing or malformed
// ENCRYPTION_KEY fails fast on startup rather than on the first user interaction.
function loadKey(): Buffer {
// Trim to guard against trailing newlines/spaces from env files or copy-paste;
// JS regex $ matches before a trailing \n, so without trim a 64-hex + \n string
// would pass the pattern check but Buffer.from would include the garbage byte.
const raw = (process.env.ENCRYPTION_KEY ?? '').trim();
if (!raw) {
throw new Error('ENCRYPTION_KEY environment variable is not set');
}
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
throw new Error('ENCRYPTION_KEY must be exactly a 64-character hex string (32 bytes for AES-256)');
}
return Buffer.from(raw, 'hex');
}
const KEY: Buffer = loadKey();
/**
* Returns true if the value was produced by `encrypt()`, false if it is plaintext.
* Used to migrate rows that were stored before encryption was introduced.
*/
export function isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTION_PREFIX);
}
/**
* Encrypts a plaintext string using AES-256-GCM.
* Returns `enc1:<iv_hex>:<authTag_hex>:<ciphertext_hex>`.
*/
export function encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, KEY, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${ENCRYPTION_PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}
/**
* Decrypts a value produced by `encrypt`.
* Throws if the value lacks the expected prefix or the GCM auth tag mismatches.
*/
export function decrypt(ciphertext: string): string {
if (!ciphertext.startsWith(ENCRYPTION_PREFIX)) {
throw new Error('Invalid encrypted value format');
}
const body = ciphertext.slice(ENCRYPTION_PREFIX.length);
const parts = body.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted value format');
}
const [ivHex, authTagHex, encryptedHex] = parts as [string, string, string];
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const encrypted = Buffer.from(encryptedHex, 'hex');
const decipher = createDecipheriv(ALGORITHM, KEY, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
}