78 lines
2.0 KiB
TypeScript
78 lines
2.0 KiB
TypeScript
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHash,
|
|
randomBytes,
|
|
timingSafeEqual,
|
|
} from 'node:crypto';
|
|
|
|
const ALGORITHM = 'aes-256-gcm';
|
|
const IV_BYTES = 12;
|
|
const KEY_BYTES = 32;
|
|
|
|
const loadKey = (): Buffer => {
|
|
const raw = process.env.TOKEN_ENCRYPTION_KEY;
|
|
if (!raw) {
|
|
throw new Error('TOKEN_ENCRYPTION_KEY is not set');
|
|
}
|
|
|
|
const key = Buffer.from(raw, 'base64');
|
|
if (key.length !== KEY_BYTES) {
|
|
throw new Error(
|
|
`TOKEN_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes, got ${key.length}`
|
|
);
|
|
}
|
|
|
|
return key;
|
|
};
|
|
|
|
/** Serialized as base64(iv):base64(authTag):base64(ciphertext). */
|
|
export const encryptSecret = (plaintext: string): string => {
|
|
const iv = randomBytes(IV_BYTES);
|
|
const cipher = createCipheriv(ALGORITHM, loadKey(), iv);
|
|
|
|
const payload = Buffer.concat([
|
|
cipher.update(plaintext, 'utf8'),
|
|
cipher.final(),
|
|
]);
|
|
|
|
return [
|
|
iv.toString('base64'),
|
|
cipher.getAuthTag().toString('base64'),
|
|
payload.toString('base64'),
|
|
].join(':');
|
|
};
|
|
|
|
export const decryptSecret = (ciphertext: string): string => {
|
|
const parts = ciphertext.split(':');
|
|
if (parts.length !== 3) {
|
|
throw new Error('Ciphertext is not in the expected iv:tag:payload form');
|
|
}
|
|
|
|
const [iv, tag, payload] = parts;
|
|
const decipher = createDecipheriv(
|
|
ALGORITHM,
|
|
loadKey(),
|
|
Buffer.from(iv, 'base64')
|
|
);
|
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
|
|
|
return Buffer.concat([
|
|
decipher.update(Buffer.from(payload, 'base64')),
|
|
decipher.final(),
|
|
]).toString('utf8');
|
|
};
|
|
|
|
export const hashApiKey = (key: string): string =>
|
|
createHash('sha256').update(key, 'utf8').digest('hex');
|
|
|
|
/**
|
|
* Constant-time string comparison. Both sides are hashed first so that
|
|
* unequal lengths cannot short-circuit the comparison or throw.
|
|
*/
|
|
export const safeEquals = (a: string, b: string): boolean => {
|
|
const digestA = createHash('sha256').update(a, 'utf8').digest();
|
|
const digestB = createHash('sha256').update(b, 'utf8').digest();
|
|
return timingSafeEqual(digestA, digestB);
|
|
};
|