Files
AriWeb-Suite/lib/vault.mjs

42 lines
2.9 KiB
JavaScript

/**
* lib/vault.mjs — 비밀값 보관소(AES-256-GCM).
*
* 서비스 키·API 키·Agent 비밀·SSO 공유 비밀처럼 "저장 후 다시 볼 수 없고 재발급만 되는" 값을 둔다(§37).
* - 암호화 키: 데이터 디렉터리의 `vault.key`(32바이트, 0600). 없으면 처음 열 때 만든다. 환경 변수 ARIWEB_VAULT_KEY(hex) 가 있으면 그것을 쓴다(재해복구 때 같은 키로 복원).
* - 저장 파일: `vault.json` — { items: { name: { iv, tag, data, hint, updatedAt, kind } } }. 원자적 쓰기(tmp → rename).
* - API: set(name, secret, {kind}) / get(name) / has(name) / remove(name) / list() (이름·힌트·시각만, 값 없음)
*/
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { hint } from './redact.mjs';
export function openVault(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const keyFile = join(dir, 'vault.key'); const file = join(dir, 'vault.json');
let key;
if (process.env.ARIWEB_VAULT_KEY) key = Buffer.from(process.env.ARIWEB_VAULT_KEY, 'hex');
else if (existsSync(keyFile)) key = Buffer.from(readFileSync(keyFile, 'utf8').trim(), 'hex');
else { key = randomBytes(32); writeFileSync(keyFile, key.toString('hex'), { mode: 0o600 }); }
if (key.length !== 32) throw new Error('vault 키 길이가 올바르지 않습니다(32바이트 hex 필요)');
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (_) { return { items: {} }; } };
const save = (d) => { const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); renameSync(tmp, file); try { chmodSync(file, 0o600); } catch (_) {} };
return {
dir, file,
set(name, secret, { kind = 'secret' } = {}) {
const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', key, iv);
const data = Buffer.concat([c.update(String(secret), 'utf8'), c.final()]);
const d = load(); d.items[name] = { kind, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64'), hint: hint(secret), updatedAt: new Date().toISOString() }; save(d);
return { name, hint: d.items[name].hint };
},
get(name) {
const it = load().items[name]; if (!it) return null;
const dc = createDecipheriv('aes-256-gcm', key, Buffer.from(it.iv, 'base64')); dc.setAuthTag(Buffer.from(it.tag, 'base64'));
return Buffer.concat([dc.update(Buffer.from(it.data, 'base64')), dc.final()]).toString('utf8');
},
has: (name) => !!load().items[name],
remove(name) { const d = load(); const had = !!d.items[name]; delete d.items[name]; save(d); return had; },
list() { return Object.entries(load().items).map(([name, it]) => ({ name, kind: it.kind, hint: it.hint, updatedAt: it.updatedAt })); },
};
}