/** * 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 })); }, }; }