Ctrl K
ComunidadGuías…

src/keystore.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 };
}