← back to Kickbacks Ai Upstream

src/auth/vault.ts

175 lines

import { execFile } from "node:child_process";

/** Cross-platform credential at-rest protection.
 *
 *  WHY THIS EXISTS: VS Code `ctx.secrets` is durable on macOS (Keychain) and
 *  Windows (DPAPI) but a coin-flip on Linux (no Secret Service on headless /
 *  WSL / devcontainer / code-server / Remote-SSH). Any design that *depends*
 *  on it is broken on Linux. So the universal floor is a file on disk; this
 *  module upgrades the at-rest protection of that file's payload using an
 *  OS-native primitive when one is reachable, with ZERO native npm deps:
 *
 *    macOS   → `security` Keychain CLI            (store model)
 *    Windows → DPAPI via PowerShell ProtectedData (encrypt-blob model)
 *    Linux   → `secret-tool` (libsecret) if present
 *    any     → plaintext (the always-available floor)
 *
 *  Every operation is best-effort and NEVER throws or blocks (prime
 *  directive). On ANY failure `seal()` returns a `plain:` envelope so the
 *  caller still has a durable credential — degraded, never broken. */

export type RunResult = { code: number; stdout: string; stderr: string };
export type Run = (
  cmd: string,
  args: string[],
  opts?: { input?: string; env?: Record<string, string> },
) => Promise<RunResult>;

export type VaultScheme = "keychain" | "dpapi" | "libsecret" | "plain";

export interface SecretVault {
  /** Best guess of the durable scheme for this platform (actual durability is
   *  re-validated at seal() time — this is for status/diagnostics only). */
  scheme(): VaultScheme;
  /** Persist `secret` for `account`; returns an opaque envelope to store in
   *  the universal file. Never throws. */
  seal(account: string, secret: string): Promise<string>;
  /** Recover the secret from an envelope, or null if unrecoverable (entry
   *  wiped externally, tool missing, garbage). Never throws. */
  open(envelope: string): Promise<string | null>;
  /** Best-effort removal of any OS-store-held secret. Never throws. */
  clear(envelope: string): Promise<void>;
}

const SERVICE = "vibe-ads";
const V = "1"; // envelope version

// --- default production runner (never throws; ENOENT => code 127) ----------
export const defaultRun: Run = (cmd, args, opts) =>
  new Promise<RunResult>((resolve) => {
    try {
      const child = execFile(cmd, args, {
        timeout: 4000, windowsHide: true, maxBuffer: 1 << 20,
        env: { ...process.env, ...(opts?.env || {}) },
      }, (err, stdout, stderr) => {
        const code = err && typeof (err as { code?: unknown }).code === "number"
          ? (err as { code: number }).code : err ? 1 : 0;
        resolve({ code, stdout: String(stdout || ""), stderr: String(stderr || "") });
      });
      child.on("error", () => resolve({ code: 127, stdout: "", stderr: "spawn" }));
      if (opts?.input != null) { try { child.stdin?.end(opts.input); } catch { /* */ } }
    } catch { resolve({ code: 127, stdout: "", stderr: "throw" }); }
  });

const stripNL = (s: string): string => s.replace(/\r?\n$/, "");
const env = (e: string): { s: VaultScheme | string; p: string } | null => {
  const i1 = e.indexOf(":"); if (i1 < 0) return null;
  const i2 = e.indexOf(":", i1 + 1); if (i2 < 0) return null;
  if (e.slice(i1 + 1, i2) !== V) return null; // unknown version => unrecoverable
  return { s: e.slice(0, i1), p: e.slice(i2 + 1) };
};
const plainEnv = (secret: string) => `plain:${V}:${secret}`;

// PowerShell DPAPI (CurrentUser). Secret is passed via env (NOT argv — argv is
// world-readable in the process list); `Add-Type` covers PS Core where the
// System.Security assembly isn't auto-loaded.
const PS_PROTECT =
  "Add-Type -AssemblyName System.Security;" +
  "$b=[Text.Encoding]::UTF8.GetBytes($env:VIBE_ADS_SECRET);" +
  "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser'))";
const PS_UNPROTECT =
  "Add-Type -AssemblyName System.Security;" +
  "$e=[Convert]::FromBase64String($env:VIBE_ADS_SECRET);" +
  "[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($e,$null,'CurrentUser'))";

export function createVault(
  platform: NodeJS.Platform | string,
  run: Run = defaultRun,
): SecretVault {
  const scheme: VaultScheme =
    platform === "darwin" ? "keychain" :
    platform === "win32" ? "dpapi" :
    platform === "linux" ? "libsecret" : "plain";

  const safe = async <T>(fn: () => Promise<T>, fb: T): Promise<T> => {
    try { return await fn(); } catch { return fb; }
  };

  async function sealKeychain(acct: string, secret: string): Promise<string | null> {
    // NOTE: `security` has no stdin password input; `-w <secret>` is briefly
    // visible in argv to the SAME user only (sub-second). Accepted vs. the
    // alternative of plaintext-at-rest. `-U` upserts.
    const r = await run("security", ["add-generic-password", "-U",
      "-s", SERVICE, "-a", acct, "-w", secret]);
    return r.code === 0 ? `keychain:${V}:${acct}` : null;
  }
  async function sealDpapi(secret: string): Promise<string | null> {
    const r = await run("powershell",
      ["-NoProfile", "-NonInteractive", "-Command", PS_PROTECT],
      { env: { VIBE_ADS_SECRET: secret } });
    const ct = stripNL(r.stdout).trim();
    return r.code === 0 && ct ? `dpapi:${V}:${ct}` : null;
  }
  async function sealLibsecret(acct: string, secret: string): Promise<string | null> {
    const r = await run("secret-tool",
      ["store", "--label", SERVICE, "service", SERVICE, "account", acct],
      { input: secret }); // secret via stdin — no argv leak
    return r.code === 0 ? `libsecret:${V}:${acct}` : null;
  }

  return {
    scheme: () => scheme,

    async seal(account, secret) {
      const got = await safe<string | null>(async () => {
        if (scheme === "keychain") return sealKeychain(account, secret);
        if (scheme === "dpapi") return sealDpapi(secret);
        if (scheme === "libsecret") return sealLibsecret(account, secret);
        return null;
      }, null);
      return got ?? plainEnv(secret); // any failure => universal plaintext floor
    },

    async open(envelope) {
      const e = env(envelope);
      if (!e) return null;
      if (e.s === "plain") return e.p; // no exec — works even with no tools
      return safe<string | null>(async () => {
        if (e.s === "keychain") {
          const r = await run("security", ["find-generic-password",
            "-s", SERVICE, "-a", e.p, "-w"]);
          const v = stripNL(r.stdout);
          return r.code === 0 && v ? v : null;
        }
        if (e.s === "dpapi") {
          const r = await run("powershell",
            ["-NoProfile", "-NonInteractive", "-Command", PS_UNPROTECT],
            { env: { VIBE_ADS_SECRET: e.p } });
          const v = stripNL(r.stdout);
          return r.code === 0 && v ? v : null;
        }
        if (e.s === "libsecret") {
          const r = await run("secret-tool",
            ["lookup", "service", SERVICE, "account", e.p]);
          const v = stripNL(r.stdout);
          return r.code === 0 && v ? v : null;
        }
        return null; // unknown scheme
      }, null);
    },

    async clear(envelope) {
      const e = env(envelope);
      if (!e || e.s === "plain") return; // plain: nothing OS-side to remove
      await safe(async () => {
        if (e.s === "keychain")
          await run("security", ["delete-generic-password", "-s", SERVICE, "-a", e.p]);
        else if (e.s === "libsecret")
          await run("secret-tool", ["clear", "service", SERVICE, "account", e.p]);
        // dpapi ciphertext lives only in the file — caller drops the file.
        return null;
      }, null);
    },
  };
}