All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
66 lines
1.9 KiB
TypeScript
66 lines
1.9 KiB
TypeScript
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHash,
|
|
randomBytes,
|
|
scrypt,
|
|
} from 'crypto';
|
|
|
|
const ALGORITHM = 'aes-256-gcm';
|
|
const KEY_LEN = 32;
|
|
const IV_LEN = 16;
|
|
const SALT_LEN = 16;
|
|
const TAG_LEN = 16;
|
|
|
|
function getMasterPassphrase(): string {
|
|
const master = process.env.MASTER_ENCRYPTION_KEY;
|
|
if (!master || master.length < 32) {
|
|
throw new Error('MASTER_ENCRYPTION_KEY must be set (minimum 32 characters)');
|
|
}
|
|
return master;
|
|
}
|
|
|
|
async function deriveKey(salt: Buffer): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
scrypt(getMasterPassphrase(), salt, KEY_LEN, (err, key) => {
|
|
if (err) reject(err);
|
|
else resolve(key);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function encryptApiKey(plaintext: string): Promise<string> {
|
|
const salt = randomBytes(SALT_LEN);
|
|
const iv = randomBytes(IV_LEN);
|
|
const key = await deriveKey(salt);
|
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(plaintext, 'utf8'),
|
|
cipher.final(),
|
|
]);
|
|
const tag = cipher.getAuthTag();
|
|
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
|
|
}
|
|
|
|
export async function decryptApiKey(payload: string): Promise<string> {
|
|
const buf = Buffer.from(payload, 'base64');
|
|
if (buf.length < SALT_LEN + IV_LEN + TAG_LEN + 1) {
|
|
throw new Error('Invalid encrypted key payload');
|
|
}
|
|
const salt = buf.subarray(0, SALT_LEN);
|
|
const iv = buf.subarray(SALT_LEN, SALT_LEN + IV_LEN);
|
|
const tag = buf.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
|
|
const ciphertext = buf.subarray(SALT_LEN + IV_LEN + TAG_LEN);
|
|
const key = await deriveKey(salt);
|
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([
|
|
decipher.update(ciphertext),
|
|
decipher.final(),
|
|
]).toString('utf8');
|
|
}
|
|
|
|
export function hashApiKey(plaintext: string): string {
|
|
return createHash('sha256').update(plaintext, 'utf8').digest('hex');
|
|
}
|