SPB Git

Repository Public MIT

credentials.ts 5.9 KB 161 lines typescript

1import { execFileSync } from "node:child_process";2import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";3import { dirname, join } from "node:path";4import type { IO } from "./io";56/**7 * Where the CLI keeps the API token. Lookup order: SKILLS_TOKEN (process-scoped injection) →8 * OS credential store → opt-in 0600 file. The token is never written to argv, logs or the repo.9 */10export const SERVICE = "skills-registry";1112export interface CredentialStore {13  kind: "keychain" | "secret-tool" | "file";14  describe: string;15  get(account: string): string | null;16  set(account: string, token: string): void;17  delete(account: string): boolean;18}1920export class FileStore implements CredentialStore {21  kind = "file" as const;22  describe: string;23  constructor(private path: string) {24    this.describe = `file ${path} (mode 600)`;25  }26  private read(): Record<string, string> {27    if (!existsSync(this.path)) return {};28    try {29      const v = JSON.parse(readFileSync(this.path, "utf8"));30      return v && typeof v === "object" ? (v as Record<string, string>) : {};31    } catch {32      return {};33    }34  }35  private write(v: Record<string, string>) {36    mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });37    writeFileSync(this.path, JSON.stringify(v, null, 2) + "\n", { mode: 0o600 });38    chmodSync(this.path, 0o600);39  }40  get(account: string) {41    return this.read()[account] ?? null;42  }43  set(account: string, token: string) {44    this.write({ ...this.read(), [account]: token });45  }46  delete(account: string) {47    const v = this.read();48    if (!(account in v)) return false;49    delete v[account];50    this.write(v);51    return true;52  }53}5455const SECURITY = "/usr/bin/security";5657export class KeychainStore implements CredentialStore {58  kind = "keychain" as const;59  describe = `macOS Keychain (service "${SERVICE}")`;60  get(account: string) {61    try {62      const out = execFileSync(SECURITY, ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });63      return out.trim() || null;64    } catch {65      return null;66    }67  }68  set(account: string, token: string) {69    if (!/^[A-Za-z0-9_-]+$/.test(token) || !/^[A-Za-z0-9.:_-]+$/.test(account)) throw new Error("refusing to store a credential with unexpected characters");70    // `security -i` reads the command from stdin, so the secret never appears in argv / `ps`.71    execFileSync(SECURITY, ["-i"], { input: `add-generic-password -U -s ${SERVICE} -a ${account} -l "Skills registry (${account})" -w ${token}\n`, stdio: ["pipe", "ignore", "pipe"] });72    if (this.get(account) !== token) throw new Error("the Keychain did not accept the credential");73  }74  delete(account: string) {75    try {76      execFileSync(SECURITY, ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });77      return true;78    } catch {79      return false;80    }81  }82}8384export class SecretToolStore implements CredentialStore {85  kind = "secret-tool" as const;86  describe = `libsecret keyring (service "${SERVICE}")`;87  constructor(private bin: string) {}88  get(account: string) {89    try {90      const out = execFileSync(this.bin, ["lookup", "service", SERVICE, "account", account], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });91      return out.trim() || null;92    } catch {93      return null;94    }95  }96  set(account: string, token: string) {97    execFileSync(this.bin, ["store", `--label=Skills registry (${account})`, "service", SERVICE, "account", account], { input: token, stdio: ["pipe", "ignore", "pipe"] });98  }99  delete(account: string) {100    try {101      execFileSync(this.bin, ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });102      return true;103    } catch {104      return false;105    }106  }107}108109function which(bin: string, io: IO): string | null {110  for (const dir of (io.env.PATH ?? "").split(":")) {111    if (dir && existsSync(join(dir, bin))) return join(dir, bin);112  }113  return null;114}115116export function optInFilePath(io: IO): string {117  return join(io.home, ".config", "skills", "credentials.json");118}119120/** The OS credential manager, or null when none is available (then only the opt-in file remains). */121export function osStore(io: IO): CredentialStore | null {122  const forced = io.env.SKILLS_CREDENTIAL_STORE;123  if (forced) {124    if (!forced.startsWith("file:") || forced.length < 6) throw new Error("SKILLS_CREDENTIAL_STORE must look like file:/absolute/path.json");125    return new FileStore(forced.slice(5));126  }127  if (io.platform === "darwin" && existsSync(SECURITY)) return new KeychainStore();128  if (io.platform === "linux") {129    const bin = which("secret-tool", io);130    if (bin) return new SecretToolStore(bin);131  }132  return null;133}134135export interface Resolved {136  token: string;137  source: "env" | CredentialStore["kind"];138  describe: string;139  store: CredentialStore | null;140}141142export function resolveCredential(io: IO, account: string): Resolved | null {143  const envTok = io.env.SKILLS_TOKEN?.trim();144  if (envTok) return { token: envTok, source: "env", describe: "SKILLS_TOKEN environment variable (process-scoped)", store: null };145  const primary = osStore(io);146  const t = primary?.get(account);147  if (primary && t) return { token: t, source: primary.kind, describe: primary.describe, store: primary };148  const file = new FileStore(optInFilePath(io));149  const ft = file.get(account);150  if (ft) return { token: ft, source: "file", describe: file.describe, store: file };151  return null;152}153154/** Store to write to: the OS store, or the plaintext file only with explicit opt-in. */155export function writableStore(io: IO, insecureFile: boolean): CredentialStore {156  if (insecureFile) return new FileStore(optInFilePath(io));157  const s = osStore(io);158  if (s) return s;159  throw new Error("No OS credential manager found (macOS Keychain or secret-tool). Re-run with --insecure-file to store the token in ~/.config/skills/credentials.json (mode 600), or inject it per process with SKILLS_TOKEN.");160}161
Files main
Menu

Theme