import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'; import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { userInfo } from 'node:os'; import { WeaveError } from './store.js'; export const KEYSTORE_KINDS = ['file', 'keychain']; export const SECURITY = '/usr/bin/security'; export const INDEX_SERVICE = 'weave:keystore'; export const KEYCHAIN_ACCOUNT = 'weave'; const MISSING = 44; export const keyFilePath = (path) => path.replace(/\.json$/, '') + '.key'; export const keystoreFilePath = (value) => (value && !KEYSTORE_KINDS.includes(value) ? value : null); let accountHome = null; const realHome = () => (accountHome ??= userInfo().homedir); export function keystoreKind({ path, pick = null, platform = process.platform, home = realHome(), security = SECURITY, exists = existsSync(path) } = {}) { if (KEYSTORE_KINDS.includes(pick)) return pick; if (exists || path !== join(home, '.weave', 'keystore.json')) return 'file'; return platform === 'darwin' && existsSync(security) ? 'keychain' : 'file'; } export function openKeyStore({ path, env = process.env, kind = null } = {}) { const chosen = kind ?? keystoreKind({ path, pick: env?.WEAVE_KEYSTORE ?? process.env.WEAVE_KEYSTORE }); if (!KEYSTORE_KINDS.includes(chosen)) throw new WeaveError(`Unknown key store '${chosen}' (${KEYSTORE_KINDS.join(', ')})`, 'invalid'); return chosen === 'keychain' ? keychainStore({ keychain: env?.WEAVE_KEYCHAIN ?? process.env.WEAVE_KEYCHAIN ?? null }) : fileStore({ path, env }); } export function fileStore({ path, env = process.env }) { const secret = () => { const pass = env?.WEAVE_KEYSTORE_PASSPHRASE; if (pass) return scryptSync(String(pass), 'weave-keystore-v2', 32); const keyPath = keyFilePath(path); try { const b = Buffer.from(readFileSync(keyPath, 'utf8').trim(), 'base64'); if (b.length === 32) return b; } catch {} const fresh = randomBytes(32); mkdirSync(dirname(keyPath), { recursive: true }); writeFileSync(keyPath, fresh.toString('base64'), { mode: 0o600 }); return fresh; }; const seal = (plain) => { const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', secret(), iv); const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]); return { iv: iv.toString('base64'), ct: ct.toString('base64'), tag: c.getAuthTag().toString('base64') }; }; const open = (entry, name) => { try { const d = createDecipheriv('aes-256-gcm', secret(), Buffer.from(entry.iv, 'base64'), { authTagLength: 16 }); d.setAuthTag(Buffer.from(entry.tag, 'base64')); return Buffer.concat([d.update(Buffer.from(entry.ct, 'base64')), d.final()]).toString('utf8'); } catch { throw new WeaveError(`Cannot decrypt '${name}' — wrong passphrase, or the keystore was edited outside weave`, 'invalid'); } }; return { kind: 'file', path, read() { let raw; try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch { return { v: 2, keys: {} }; } if (raw && raw.v === 2 && raw.keys) return raw; const keys = {}; for (const [name, value] of Object.entries(raw ?? {})) keys[name] = { legacy: String(value) }; return { v: 2, keys, migrated: true }; }, write(data) { for (const [name, entry] of Object.entries(data.keys)) { if (!('value' in entry) && !('legacy' in entry)) continue; const { value, legacy, iv, ct, tag, ...rest } = entry; data.keys[name] = { ...rest, ...seal('value' in entry ? value : legacy) }; } delete data.migrated; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(data, null, 1), { mode: 0o600 }); }, value(name, entry) { if ('value' in entry) return String(entry.value); return 'legacy' in entry ? entry.legacy : open(entry, name); }, clear() { rmSync(path, { force: true }); rmSync(keyFilePath(path), { force: true }); }, }; } export function runSecurity(security, args, input = '') { const r = spawnSync(security, args, { input, encoding: 'utf8' }); if (r.error?.code === 'ENOENT') throw new WeaveError(`The keychain store needs ${security}, the macOS security tool, and this machine has none`, 'invalid'); if (r.error) throw r.error; return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; } const readPassword = (stderr) => { const m = /^password: (?:0x([0-9A-Fa-f]*)(?:\s.*)?|"(.*)")?$/m.exec(stderr); if (!m) return null; return m[1] != null ? Buffer.from(m[1], 'hex').toString('utf8') : (m[2] ?? ''); }; export function keychainStore({ keychain = null, security = SECURITY, run = runSecurity } = {}) { const at = keychain ? [keychain] : []; const service = (name, entry) => `weave:${entry?.workspace ?? 'local'}:${name}`; const quoted = (s) => { if (/["\r\n\\]/.test(s)) throw new WeaveError(`The keychain cannot name an item with a quote, a backslash or a line break: '${s}'`, 'invalid'); return `"${s}"`; }; const failed = (what, r) => new WeaveError(`The keychain refused ${what}: ${(r.stderr || r.stdout).trim() || `security exited ${r.status}`}`, 'invalid'); const put = (svc, value) => { const hex = Buffer.from(String(value), 'utf8').toString('hex'); const line = ['add-generic-password', '-U', '-s', quoted(svc), '-a', KEYCHAIN_ACCOUNT, '-X', `"${hex}"`, ...at.map(quoted)].join(' '); const r = run(security, ['-i'], `${line}\n`); if (r.status !== 0 || /returned -?\d+/.test(r.stderr)) throw failed(`to store ${svc}`, r); }; const get = (svc) => { const r = run(security, ['find-generic-password', '-s', svc, '-a', KEYCHAIN_ACCOUNT, '-g', ...at]); if (r.status === MISSING) return null; if (r.status !== 0) throw failed(`to read ${svc}`, r); const value = readPassword(r.stderr); if (value == null) throw failed(`to read ${svc}`, r); return value; }; const drop = (svc) => { const r = run(security, ['delete-generic-password', '-s', svc, '-a', KEYCHAIN_ACCOUNT, ...at]); if (r.status !== 0 && r.status !== MISSING) throw failed(`to delete ${svc}`, r); }; const read = () => { const raw = get(INDEX_SERVICE); if (!raw) return { v: 2, keys: {} }; const index = JSON.parse(raw); return { v: 2, keys: index?.keys ?? {} }; }; return { kind: 'keychain', keychain, service, read, write(data) { const before = read().keys; const keys = {}; for (const [name, entry] of Object.entries(data.keys)) { const { value, legacy, iv, ct, tag, ...meta } = entry; let plain = 'value' in entry ? value : legacy; const was = Object.hasOwn(before, name) ? service(name, before[name]) : null; if (plain === undefined && was && was !== service(name, meta)) plain = get(was); if (plain !== undefined) put(service(name, meta), plain); keys[name] = meta; } put(INDEX_SERVICE, JSON.stringify({ v: 2, keys })); for (const [name, entry] of Object.entries(before)) { if (!Object.hasOwn(keys, name) || service(name, keys[name]) !== service(name, entry)) drop(service(name, entry)); } }, value(name, entry) { if ('value' in entry) return String(entry.value); const v = get(service(name, entry)); if (v == null) throw new WeaveError(`Key '${name}' has no value in the keychain (${service(name, entry)})`, 'not-found'); return v; }, clear() { for (const [name, entry] of Object.entries(read().keys)) drop(service(name, entry)); drop(INDEX_SERVICE); }, }; } export function migrateKeys(from, to) { if (from.kind === to.kind) throw new WeaveError(`The keys already live in the ${to.kind} store`, 'invalid'); const source = from.read(); const names = Object.keys(source.keys).sort(); const values = Object.fromEntries(names.map((n) => [n, from.value(n, source.keys[n])])); const target = to.read(); for (const n of names) { const { iv, ct, tag, legacy, value, ...meta } = source.keys[n]; target.keys[n] = { ...meta, value: values[n] }; } to.write(target); const landed = to.read().keys; const unread = names.filter((n) => !Object.hasOwn(landed, n) || to.value(n, landed[n]) !== values[n]); if (unread.length) throw new WeaveError(`Migration stopped before deleting anything: ${unread.join(', ')} did not read back from the ${to.kind} store`, 'invalid'); from.clear(); return { from: from.kind, to: to.kind, moved: names }; }