Files

91 lines
3.5 KiB
JavaScript

import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const ENCRYPTED_STATE_VERSION = 1;
function keyFromInput(value) {
const input = String(value || '').trim();
if (!input) return null;
if (/^[a-f\d]{64}$/i.test(input)) return Buffer.from(input, 'hex');
try {
const decoded = Buffer.from(input, 'base64');
if (decoded.length === 32) return decoded;
} catch {
// Fall through to deriving a stable key from the supplied passphrase.
}
return createHash('sha256').update(input, 'utf8').digest();
}
async function localStateKey(dataDir) {
const keyFile = path.join(dataDir, '.state-key');
try {
const key = keyFromInput(await readFile(keyFile, 'utf8'));
if (key) return key;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
await mkdir(dataDir, { recursive: true });
const key = randomBytes(32);
await writeFile(keyFile, key.toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' }).catch(async (error) => {
if (error?.code !== 'EEXIST') throw error;
});
await chmod(keyFile, 0o600).catch(() => {});
const stored = keyFromInput(await readFile(keyFile, 'utf8'));
if (!stored) throw new Error('state_encryption_key_invalid');
return stored;
}
export async function createStateStorage(dataDir) {
const key = keyFromInput(process.env.TEAM_ROTATION_DATA_KEY) || await localStateKey(dataDir);
let legacyPlaintextLoaded = false;
return {
get legacyPlaintextLoaded() { return legacyPlaintextLoaded; },
decode(text) {
const parsed = JSON.parse(text);
if (parsed?.encrypted !== true) {
legacyPlaintextLoaded = true;
return parsed;
}
if (parsed.version !== ENCRYPTED_STATE_VERSION || parsed.algorithm !== 'aes-256-gcm') throw new Error('state_encryption_format_unsupported');
try {
const iv = Buffer.from(String(parsed.iv || ''), 'base64');
const tag = Buffer.from(String(parsed.tag || ''), 'base64');
const ciphertext = Buffer.from(String(parsed.ciphertext || ''), 'base64');
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return JSON.parse(Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'));
} catch (error) {
const wrapped = new Error('state_decryption_failed');
wrapped.code = 'STATE_DECRYPTION_FAILED';
wrapped.cause = error;
throw wrapped;
}
},
encode(value) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
return JSON.stringify({
version: ENCRYPTED_STATE_VERSION,
encrypted: true,
algorithm: 'aes-256-gcm',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
}, null, 2);
},
};
}
export function isLoopbackHost(value) {
const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
}
export function secureTokenEqual(left, right) {
const supplied = Buffer.from(String(left || ''), 'utf8');
const expected = Buffer.from(String(right || ''), 'utf8');
return supplied.length === expected.length && supplied.length > 0 && timingSafeEqual(supplied, expected);
}