@gorlitzer-labs/comb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # comb
2
+
3
+ Keep your keys out of your conversations.
4
+
5
+ You paste a Cloudflare token to an agent so it can do a job. That token is now in
6
+ a transcript file on your disk, in plaintext, forever. Do it enough times and you
7
+ no longer know which credentials are exposed, or which ones to rotate — so you
8
+ rotate none of them.
9
+
10
+ `comb` is a small front-end over [SOPS](https://github.com/getsops/sops) and
11
+ [age](https://github.com/FiloSottile/age). Secrets are referred to by **name**.
12
+ Values go into a child process's environment and nowhere else: not into a shell,
13
+ not into history, not into the conversation.
14
+
15
+ ```bash
16
+ comb init
17
+ comb set CF_API_TOKEN --url https://dash.cloudflare.com/profile/api-tokens
18
+ comb run --with CF_API_TOKEN -- curl -H "Authorization: Bearer $CF_API_TOKEN" ...
19
+ comb audit
20
+ ```
21
+
22
+ ## Why not a secrets server
23
+
24
+ Infisical and OpenBao are good. They are also a database, a cache, a patch
25
+ cadence, and something that can be down at 3am when an agent needs a key. For one
26
+ person that is overhead you will resent.
27
+
28
+ SOPS and age are two static binaries and an encrypted file. Identical on macOS
29
+ and Linux, identical in a container. "Self-hosted" is literally true: nothing
30
+ leaves the machine, and there is no host to compromise.
31
+
32
+ The encrypted store is safe to commit. Put it in a private repo and you get
33
+ versioning and offsite backup for free, plus a history of when each key changed —
34
+ which is the only rotation record anyone actually keeps.
35
+
36
+ **The cost, stated plainly:** the age private key is the one credential that
37
+ matters. Everything else is recoverable; that is not. Back it up somewhere real.
38
+
39
+ ## Commands
40
+
41
+ | command | |
42
+ |---|---|
43
+ | `comb init` | create the age key and the encrypted store |
44
+ | `comb set <NAME> [--url <where>]` | take a value without echoing it, and store it |
45
+ | `comb ls` | names, dates and notes — never values |
46
+ | `comb run --with A,B -- <cmd>` | run a command with those secrets in its environment |
47
+ | `comb rotate <NAME>` | tell you where to go, then take the new value |
48
+ | `comb audit` | which secrets leaked into transcripts and shell history |
49
+ | `comb rm <NAME>` | forget one (does **not** revoke it at the provider) |
50
+
51
+ Values are never printed, never passed as command-line arguments, never logged.
52
+ `comb get NAME --reveal` exists for when you genuinely need to read one, and
53
+ refuses without the flag.
54
+
55
+ ## `comb audit`
56
+
57
+ This is the half no vault does, and the half that makes manual rotation
58
+ survivable. "Rotate everything just in case" is advice nobody follows twice.
59
+ Knowing that three specific keys are sitting in a transcript turns a day of work
60
+ into ten minutes.
61
+
62
+ ```
63
+ comb audit scanning 5 place(s) credentials go to be forgotten
64
+ · Claude transcripts · Codex sessions · Codex history
65
+ · zsh history · bash history
66
+
67
+ LEAKED CF_API_TOKEN — 2 occurrence(s) across 1 file(s): Claude transcripts
68
+
69
+ credential-shaped strings (not necessarily yours):
70
+ GitHub token — 3 distinct, 3 occurrence(s), 2 file(s)
71
+ private key block — 1 distinct, 3 occurrence(s), 2 file(s)
72
+ ```
73
+
74
+ Two kinds of finding, and the difference matters:
75
+
76
+ - **LEAKED** — a value from your own store, found verbatim. Certain.
77
+ - **shaped** — something that looks like a credential by its prefix. Probable,
78
+ and biased: a GitHub token announces itself with `ghp_`, while a Cloudflare
79
+ token is forty characters of nothing in particular and cannot be recognised at
80
+ all. **Finding none proves nothing.**
81
+
82
+ ## Rotation is manual, on purpose
83
+
84
+ To rotate a credential automatically you need a credential that can rotate
85
+ credentials — a Cloudflare token with Edit-API-Tokens, a GitHub PAT with admin
86
+ scope. That meta-credential is strictly more dangerous than the ones it replaces,
87
+ it lives in the same store, and nothing can rotate *it*. Automating this would
88
+ trade a small risk for a bigger one.
89
+
90
+ So `comb rotate` does the part a tool should: reminds you where to go, takes the
91
+ new value without echoing it, records when it changed, and leaves revoking the
92
+ old one to you — after you have confirmed the new one works.
93
+
94
+ ## What this does not do
95
+
96
+ A process that *uses* a secret can *read* it. `comb` cannot stop an agent seeing
97
+ a token you have handed it to work with. What it stops is the token entering a
98
+ conversation, a file, or your shell history in the first place — and it makes
99
+ rotating one cheap enough that you actually do it.
100
+
101
+ ## Across machines — `comb realm`
102
+
103
+ An agent running on another machine needs credentials too, but the store and its
104
+ key live here. age solves this: a secret can be encrypted to **several
105
+ recipients** at once, each with its own keypair, any one of which decrypts. So
106
+ each realm gets its own age key, the store is encrypted to all of them, and the
107
+ **same encrypted file** can be copied to every realm — over bifrost's sync, or a
108
+ private repo — leaking nothing, because it is ciphertext.
109
+
110
+ ```bash
111
+ # on the realm, once:
112
+ comb init && comb pubkey # → age1realm…
113
+
114
+ # here, register it:
115
+ comb realm add zanpakuto age1realm… # re-encrypts the store to include it
116
+ comb realm ls # who can read the store
117
+ comb realm rm zanpakuto # revoke — re-encrypts WITHOUT it
118
+ ```
119
+
120
+ Revoking a realm re-encrypts the store without its key, so it can no longer
121
+ decrypt the **current** store — no secret has to be rotated.
122
+
123
+ **The honest limit:** revocation seals the current and future store, not the
124
+ past. A copy of the ciphertext a realm decrypted *while it was a recipient* stays
125
+ readable by that realm — you cannot un-share what was already shared. If a realm
126
+ is actually compromised, revoke it here **and** rotate the secrets it held
127
+ (`comb rotate`), exactly as you would for any exposed credential. Revocation
128
+ stops future leaks; rotation closes the ones already out.
129
+
130
+ ## Requires
131
+
132
+ `sops` and `age` on PATH (`brew install sops age`, or your package manager).
133
+ Node 20+.
134
+
135
+ MIT.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@gorlitzer-labs/comb",
3
+ "version": "0.1.0",
4
+ "description": "Keep your keys out of your conversations. A small vault front-end over SOPS + age.",
5
+ "type": "module",
6
+ "bin": {
7
+ "comb": "src/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs"
11
+ },
12
+ "license": "MIT",
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "secrets",
25
+ "sops",
26
+ "age",
27
+ "vault",
28
+ "ai",
29
+ "agents",
30
+ "cli"
31
+ ],
32
+ "author": "Franco Berardi"
33
+ }
package/src/audit.mjs ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Find the secrets you have already leaked.
3
+ *
4
+ * This is the half no vault does, and it is the half that makes manual rotation
5
+ * survivable. Rotating credentials is slow and irreversible-ish, so "rotate
6
+ * everything, just in case" is advice nobody follows twice. Knowing that three
7
+ * specific keys appeared in a transcript turns a day of work into ten minutes.
8
+ *
9
+ * Agent sessions are the leak nobody watches. A token pasted into a chat is
10
+ * written to a transcript file and stays there, in plaintext, indefinitely —
11
+ * long after the conversation is forgotten. Shell history is the same story with
12
+ * a longer tradition.
13
+ *
14
+ * Two kinds of finding, and the difference matters:
15
+ *
16
+ * - EXACT a value from your own store appears verbatim in a file. Certain.
17
+ * - SHAPED something that looks like a credential by its prefix. Probable,
18
+ * and biased: a GitHub token announces itself with `ghp_`, while a
19
+ * Cloudflare token is forty characters of nothing in particular and
20
+ * cannot be recognised at all. Finding none proves nothing.
21
+ */
22
+ import { readdirSync, statSync, readFileSync, existsSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+
26
+ /** Credentials that announce themselves. Deliberately not exhaustive. */
27
+ export const SHAPES = [
28
+ { name: "GitHub token", rx: /gh[pousr]_[A-Za-z0-9]{20,}/g },
29
+ { name: "GitHub fine-grained PAT", rx: /github_pat_[A-Za-z0-9_]{20,}/g },
30
+ { name: "AWS access key id", rx: /AKIA[0-9A-Z]{16}/g },
31
+ { name: "Slack token", rx: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
32
+ { name: "Google API key", rx: /AIza[0-9A-Za-z_\-]{30,}/g },
33
+ { name: "OpenAI key", rx: /sk-[A-Za-z0-9_\-]{20,}/g },
34
+ { name: "Anthropic key", rx: /sk-ant-[A-Za-z0-9_\-]{20,}/g },
35
+ { name: "private key block", rx: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
36
+ ];
37
+
38
+ /** Where credentials go to be forgotten about. */
39
+ export function defaultTargets(home = homedir()) {
40
+ return [
41
+ { label: "Claude transcripts", path: join(home, ".claude", "projects") },
42
+ { label: "Codex sessions", path: join(home, ".codex", "sessions") },
43
+ { label: "Codex history", path: join(home, ".codex", "history.jsonl") },
44
+ { label: "zsh history", path: join(home, ".zsh_history") },
45
+ { label: "bash history", path: join(home, ".bash_history") },
46
+ ].filter((t) => existsSync(t.path));
47
+ }
48
+
49
+ /** Every readable file under a path, capped so a huge tree cannot hang the scan. */
50
+ function* walk(path, budget = { files: 20000 }) {
51
+ let st;
52
+ try { st = statSync(path); } catch { return; }
53
+ if (st.isFile()) { yield path; return; }
54
+ if (!st.isDirectory()) return;
55
+ let entries;
56
+ try { entries = readdirSync(path); } catch { return; }
57
+ for (const e of entries) {
58
+ if (budget.files-- <= 0) return;
59
+ yield* walk(join(path, e), budget);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Scan for leaks.
65
+ *
66
+ * `known` maps name → current value. Those are matched verbatim, which is the
67
+ * only certain signal available; everything else is a shape and says so.
68
+ * Values are compared, never returned — a leak report that quotes the secret
69
+ * has simply moved the leak.
70
+ */
71
+ export function scan({ targets = defaultTargets(), known = {}, minLength = 16 } = {}) {
72
+ const exact = new Map(); // name -> { files:Set, hits:number }
73
+ const shaped = new Map(); // shapeName -> { distinct:Set, hits:number, files:Set }
74
+
75
+ const watch = Object.entries(known).filter(([, v]) => typeof v === "string" && v.length >= minLength);
76
+
77
+ for (const target of targets) {
78
+ for (const file of walk(target.path)) {
79
+ let text;
80
+ try { text = readFileSync(file, "utf-8"); } catch { continue; }
81
+
82
+ for (const [name, value] of watch) {
83
+ if (!text.includes(value)) continue;
84
+ const rec = exact.get(name) ?? { files: new Set(), hits: 0, labels: new Set() };
85
+ rec.hits += text.split(value).length - 1;
86
+ rec.files.add(file);
87
+ rec.labels.add(target.label);
88
+ exact.set(name, rec);
89
+ }
90
+
91
+ for (const shape of SHAPES) {
92
+ const found = text.match(shape.rx);
93
+ if (!found) continue;
94
+ const rec = shaped.get(shape.name) ?? { distinct: new Set(), hits: 0, files: new Set(), labels: new Set() };
95
+ rec.hits += found.length;
96
+ for (const f of found) rec.distinct.add(f);
97
+ rec.files.add(file);
98
+ rec.labels.add(target.label);
99
+ shaped.set(shape.name, rec);
100
+ }
101
+ }
102
+ }
103
+ return { exact, shaped, targets };
104
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Storage: SOPS + age.
3
+ *
4
+ * Chosen for what it is NOT. A secrets server — Infisical, OpenBao — is a
5
+ * database, a cache, a patch cadence and a thing that can be down at 3am when an
6
+ * agent needs a key. This is two static binaries and an encrypted file. It runs
7
+ * the same on a laptop and in a container, which matters because the container
8
+ * is where the agents live now, and it means "self-hosted" is literally true:
9
+ * nothing leaves the machine and there is no host to compromise.
10
+ *
11
+ * The file is safe to commit. That is the point — an encrypted store in a
12
+ * private repo gets versioning and offsite backup for free, and the history
13
+ * shows when each key last changed, which is the only rotation record anyone
14
+ * actually keeps.
15
+ *
16
+ * The cost, stated plainly: the age private key is the one credential that
17
+ * matters. Everything else is recoverable; that is not. It never goes in the
18
+ * store, it never goes in the repo.
19
+ */
20
+ import { execFileSync, spawnSync } from "node:child_process";
21
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { homedir } from "node:os";
24
+
25
+ /** Where the encrypted store and the key live. Override for tests. */
26
+ export function combHome() {
27
+ return process.env.COMB_HOME ?? join(homedir(), ".comb");
28
+ }
29
+ export const storePath = (home = combHome()) => join(home, "secrets.yaml");
30
+ export const keyPath = (home = combHome()) => join(home, "age.key");
31
+
32
+ function ageRecipient(home = combHome()) {
33
+ const key = readFileSync(keyPath(home), "utf-8");
34
+ const m = key.match(/public key: (age1[a-z0-9]+)/);
35
+ if (!m) throw new Error("could not read the age public key — is ~/.comb/age.key intact?");
36
+ return m[1];
37
+ }
38
+
39
+ /** True once there is a key and a store to talk to. */
40
+ export function initialised(home = combHome()) {
41
+ return existsSync(keyPath(home)) && existsSync(storePath(home));
42
+ }
43
+
44
+ export function init(home = combHome()) {
45
+ mkdirSync(home, { recursive: true, mode: 0o700 });
46
+ if (!existsSync(keyPath(home))) {
47
+ const gen = spawnSync("age-keygen", { encoding: "utf-8" });
48
+ if (gen.status !== 0) throw new Error("age-keygen failed — is `age` installed?");
49
+ writeFileSync(keyPath(home), gen.stdout, { mode: 0o600 });
50
+ chmodSync(keyPath(home), 0o600);
51
+ }
52
+ if (!existsSync(storePath(home))) {
53
+ // An empty store still has to be encrypted, or the first `set` has nothing
54
+ // to merge into and sops has no recipient to learn from.
55
+ writeFileSync(storePath(home), "{}\n");
56
+ const r = spawnSync("sops", ["--encrypt", "--age", ageRecipient(home), "--in-place", storePath(home)], {
57
+ encoding: "utf-8",
58
+ env: { ...process.env },
59
+ });
60
+ if (r.status !== 0) throw new Error(`sops could not create the store: ${r.stderr?.trim()}`);
61
+ }
62
+ return { home, recipient: ageRecipient(home) };
63
+ }
64
+
65
+ function sopsEnv(home) {
66
+ return { ...process.env, SOPS_AGE_KEY_FILE: keyPath(home) };
67
+ }
68
+
69
+ /** The whole store, decrypted in memory. Never written anywhere. */
70
+ export function readAll(home = combHome()) {
71
+ if (!initialised(home)) throw new Error("no store yet — run `comb init`");
72
+ const r = spawnSync("sops", ["--decrypt", "--output-type", "json", storePath(home)], {
73
+ encoding: "utf-8",
74
+ env: sopsEnv(home),
75
+ });
76
+ if (r.status !== 0) throw new Error(`could not decrypt the store: ${r.stderr?.trim()}`);
77
+ return JSON.parse(r.stdout || "{}");
78
+ }
79
+
80
+ /**
81
+ * Write one entry.
82
+ *
83
+ * `sops --set` rather than decrypt-edit-reencrypt: the plaintext store never
84
+ * exists as a whole, not even for an instant, and not in a temp file.
85
+ */
86
+ export function writeSecret(name, value, meta = {}, home = combHome()) {
87
+ const entry = { value, updated: new Date().toISOString(), ...meta };
88
+ // Subcommand form (`sops set <file> <index> <value>`), not the legacy --set
89
+ // flag: `unset` only exists as a subcommand, and having the pair disagree is
90
+ // how you end up with a delete that silently is not one.
91
+ const r = spawnSync("sops", ["set", storePath(home), `["${name}"]`, JSON.stringify(entry)], {
92
+ encoding: "utf-8",
93
+ env: sopsEnv(home),
94
+ });
95
+ if (r.status !== 0) throw new Error(`could not write ${name}: ${r.stderr?.trim()}`);
96
+ }
97
+
98
+ export function deleteSecret(name, home = combHome()) {
99
+ const r = spawnSync("sops", ["unset", storePath(home), `["${name}"]`], {
100
+ encoding: "utf-8",
101
+ env: sopsEnv(home),
102
+ });
103
+ if (r.status !== 0) throw new Error(`could not remove ${name}: ${r.stderr?.trim()}`);
104
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * comb — keep your keys out of your conversations.
4
+ *
5
+ * The problem this exists for: you paste a Cloudflare token to an agent so it
6
+ * can do a job, and that token is now in a transcript file forever. Do it enough
7
+ * times and you have no idea which credentials are exposed or which to rotate.
8
+ *
9
+ * So: secrets are referred to by NAME, never by value. `comb run` hands the
10
+ * value to a child process's environment and nowhere else — not to a shell, not
11
+ * to history, not into the conversation. `comb audit` tells you which ones
12
+ * escaped anyway, because some already have.
13
+ *
14
+ * Rotation stays manual on purpose. Rotating a credential automatically needs a
15
+ * credential that can rotate credentials — a CF token with Edit-API-Tokens, a
16
+ * GitHub PAT with admin scope. That meta-credential is strictly more dangerous
17
+ * than the ones it replaces, lives in the same store, and nothing can rotate it.
18
+ * `comb rotate` therefore does the part a tool should: tells you where to go,
19
+ * takes the new value without echoing it, and records when it changed.
20
+ */
21
+ import { spawnSync } from "node:child_process";
22
+ import { createInterface } from "node:readline";
23
+ import * as store from "./backends/sops.mjs";
24
+ import * as realms from "./realms.mjs";
25
+ import { scan, defaultTargets } from "./audit.mjs";
26
+
27
+ const B = (s) => `\x1b[1m${s}\x1b[0m`;
28
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
29
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
30
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
31
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
32
+ const say = (s) => process.stdout.write(s + "\n");
33
+
34
+ /**
35
+ * Read a secret from the terminal without echoing it.
36
+ *
37
+ * Not an argument, deliberately. A value on the command line is in your shell
38
+ * history, in `ps` output while it runs, and in this conversation if an agent
39
+ * typed the command — which is the exact leak comb exists to stop.
40
+ */
41
+ async function promptSecret(label) {
42
+ if (!process.stdin.isTTY) {
43
+ // Piped input is fine and is how scripts and agents should feed a value.
44
+ return (await new Promise((r) => {
45
+ let d = ""; process.stdin.on("data", (c) => (d += c)); process.stdin.on("end", () => r(d));
46
+ })).trim();
47
+ }
48
+ process.stdout.write(`${label}: `);
49
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
50
+ // Silence the echo: the value should not be on screen, over a shoulder or in
51
+ // a screen recording.
52
+ const onData = () => process.stdout.write("\x1b[2K\r" + label + ": ");
53
+ process.stdin.on("data", onData);
54
+ const value = await new Promise((r) => rl.question("", r));
55
+ process.stdin.off("data", onData);
56
+ rl.close();
57
+ process.stdout.write("\n");
58
+ return value.trim();
59
+ }
60
+
61
+ function help() {
62
+ say([
63
+ `${B("comb")} — keep your keys out of your conversations`,
64
+ "",
65
+ ` ${B("comb init")} create the age key and the encrypted store`,
66
+ ` ${B("comb set")} <NAME> [--url <where>] take a value without echoing it, and store it`,
67
+ ` ${B("comb ls")} names, age and notes — never values`,
68
+ ` ${B("comb run")} --with A,B -- <cmd> run a command with those secrets in its env`,
69
+ ` ${B("comb rotate")} <NAME> where to go, then take the new value`,
70
+ ` ${B("comb audit")} which secrets leaked into transcripts and history`,
71
+ ` ${B("comb realm add")} <name> <age1…> let a realm's key read the store (re-encrypts)`,
72
+ ` ${B("comb realm rm")} <name> revoke a realm (re-encrypts without it)`,
73
+ ` ${B("comb realm ls")} realms that can read the store`,
74
+ ` ${B("comb pubkey")} print THIS machine's age public key`,
75
+ ` ${B("comb rm")} <NAME> forget one`,
76
+ "",
77
+ dim(" Values are never printed, never passed as arguments, never logged."),
78
+ dim(" `comb get NAME --reveal` exists for when you genuinely need to read one."),
79
+ ].join("\n"));
80
+ }
81
+
82
+ const [cmd, ...rest] = process.argv.slice(2);
83
+ const flags = {}; const pos = [];
84
+ for (let i = 0; i < rest.length; i++) {
85
+ const a = rest[i];
86
+ if (a === "--") { flags["--"] = rest.slice(i + 1); break; }
87
+ if (a.startsWith("--")) {
88
+ const k = a.slice(2), n = rest[i + 1];
89
+ if (n === undefined || n.startsWith("--")) flags[k] = true; else { flags[k] = n; i++; }
90
+ } else pos.push(a);
91
+ }
92
+
93
+ try {
94
+ switch (cmd) {
95
+ case "init": {
96
+ const { home, recipient } = store.init();
97
+ say(`${green("✓")} store ready at ${B(home)}`);
98
+ say(dim(` public key ${recipient}`));
99
+ say(dim(` private key ${home}/age.key — back this up somewhere safe; nothing else can recover the store`));
100
+ break;
101
+ }
102
+
103
+ case "set": case "add": {
104
+ const name = pos[0];
105
+ if (!name) throw new Error("which one? `comb set CF_API_TOKEN`");
106
+ const value = await promptSecret(`value for ${name}`);
107
+ if (!value) throw new Error("nothing entered — not stored");
108
+ store.writeSecret(name, value, typeof flags.url === "string" ? { url: flags.url } : {});
109
+ say(`${green("✓")} ${name} stored ${dim(`(${value.length} characters — not shown, not logged)`)}`);
110
+ break;
111
+ }
112
+
113
+ case "rotate": {
114
+ const name = pos[0];
115
+ if (!name) throw new Error("which one? `comb rotate CF_API_TOKEN`");
116
+ const all = store.readAll();
117
+ const cur = all[name];
118
+ if (!cur) throw new Error(`no secret called ${name} — see \`comb ls\``);
119
+ say(`${B(name)} last changed ${cur.updated ? cur.updated.slice(0, 10) : "unknown"}`);
120
+ if (cur.url) say(`rotate it here: ${B(cur.url)}`);
121
+ say(dim("revoke the old one at the provider AFTER the new one is verified working"));
122
+ const value = await promptSecret(`new value for ${name}`);
123
+ if (!value) throw new Error("nothing entered — the old value is untouched");
124
+ store.writeSecret(name, value, cur.url ? { url: cur.url } : {});
125
+ say(`${green("✓")} ${name} updated. Old value is gone from the store.`);
126
+ break;
127
+ }
128
+
129
+ case "ls": {
130
+ const all = store.readAll();
131
+ const names = Object.keys(all).sort();
132
+ if (names.length === 0) { say(dim(" nothing stored yet — `comb set NAME`")); break; }
133
+ say(B(" name updated note"));
134
+ for (const n of names) {
135
+ const e = all[n] ?? {};
136
+ say(` ${n.padEnd(29)} ${(e.updated ?? "").slice(0, 10).padEnd(12)} ${e.url ?? ""}`);
137
+ }
138
+ break;
139
+ }
140
+
141
+ case "get": {
142
+ const name = pos[0];
143
+ if (!name) throw new Error("which one?");
144
+ if (flags.reveal !== true) throw new Error("that prints a secret to your terminal — pass --reveal if you mean it");
145
+ const all = store.readAll();
146
+ if (!(name in all)) throw new Error(`no secret called ${name}`);
147
+ process.stdout.write(all[name].value + "\n");
148
+ break;
149
+ }
150
+
151
+ case "run": {
152
+ const want = typeof flags.with === "string" ? flags.with.split(",").map((s) => s.trim()).filter(Boolean) : [];
153
+ const command = flags["--"] ?? [];
154
+ if (command.length === 0) throw new Error("what should I run? `comb run --with CF_API_TOKEN -- curl ...`");
155
+ const all = store.readAll();
156
+ const env = { ...process.env };
157
+ for (const n of want) {
158
+ if (!(n in all)) throw new Error(`no secret called ${n} — see \`comb ls\``);
159
+ env[n] = all[n].value;
160
+ }
161
+ const r = spawnSync(command[0], command.slice(1), { stdio: "inherit", env });
162
+ process.exit(r.status ?? 1);
163
+ }
164
+
165
+ case "rm": {
166
+ const name = pos[0];
167
+ if (!name) throw new Error("which one?");
168
+ store.deleteSecret(name);
169
+ say(`${green("✓")} ${name} removed from the store ${dim("(this does NOT revoke it at the provider)")}`);
170
+ break;
171
+ }
172
+
173
+ case "pubkey": {
174
+ say(realms.localRecipient());
175
+ break;
176
+ }
177
+
178
+ case "realm": {
179
+ const sub = pos[0];
180
+ if (sub === "add") {
181
+ const name = pos[1];
182
+ // key may be an arg, or piped (so it need not sit in shell history)
183
+ let key = pos[2];
184
+ if (!key && !process.stdin.isTTY) {
185
+ key = await new Promise((r) => { let d=""; process.stdin.on("data",c=>d+=c); process.stdin.on("end",()=>r(d.trim())); });
186
+ }
187
+ if (!name || !key) throw new Error("usage: comb realm add <name> <age1…> (key may be piped)");
188
+ const n = realms.addRealm(name, key);
189
+ say(`${green("✓")} realm ${B(name)} can now read the store ${dim(`(re-encrypted to ${n} recipients)`)}`);
190
+ } else if (sub === "rm" || sub === "remove") {
191
+ const name = pos[1];
192
+ if (!name) throw new Error("usage: comb realm rm <name>");
193
+ const n = realms.removeRealm(name);
194
+ say(`${green("✓")} realm ${B(name)} revoked ${dim(`(store re-encrypted to ${n} recipients; it can no longer decrypt, old copies included)`)}`);
195
+ } else if (sub === "ls" || sub === undefined) {
196
+ const list = realms.listRealmRecipients();
197
+ say(`${B("comb realms")} ${dim("(can read the store, besides this machine)")}`);
198
+ if (!list.length) say(dim(" none — `comb realm add <name> <age1…>`"));
199
+ for (const r of list) say(` ${B(r.name)} ${dim(r.key.slice(0, 24) + "…")}`);
200
+ } else {
201
+ throw new Error(`unknown: comb realm ${sub}`);
202
+ }
203
+ break;
204
+ }
205
+
206
+ case "audit": {
207
+ const known = store.initialised()
208
+ ? Object.fromEntries(Object.entries(store.readAll()).map(([k, v]) => [k, v?.value]))
209
+ : {};
210
+ const targets = defaultTargets();
211
+ say(`${B("comb audit")} ${dim(`scanning ${targets.length} place(s) credentials go to be forgotten`)}`);
212
+ for (const t of targets) say(dim(` · ${t.label}`));
213
+ say("");
214
+
215
+ const { exact, shaped } = scan({ targets, known });
216
+
217
+ if (exact.size === 0) say(green(" no stored secret was found verbatim in any of them"));
218
+ for (const [name, rec] of exact) {
219
+ say(red(` LEAKED ${name}`) + dim(` — ${rec.hits} occurrence(s) across ${rec.files.size} file(s): ${[...rec.labels].join(", ")}`));
220
+ }
221
+ say("");
222
+ if (shaped.size === 0) {
223
+ say(dim(" nothing credential-shaped found either"));
224
+ } else {
225
+ say(B(" credential-shaped strings (not necessarily yours):"));
226
+ for (const [name, rec] of shaped) {
227
+ say(yellow(` ${name}`) + dim(` — ${rec.distinct.size} distinct, ${rec.hits} occurrence(s), ${rec.files.size} file(s): ${[...rec.labels].join(", ")}`));
228
+ }
229
+ }
230
+ say("");
231
+ say(dim(" A Cloudflare token is 40 characters of nothing in particular and cannot be"));
232
+ say(dim(" matched by shape. Finding none above does not mean none are there."));
233
+ break;
234
+ }
235
+
236
+ case undefined: case "help": case "--help": case "-h": help(); break;
237
+ case "version": case "--version": case "-v": say("comb 0.1.0"); break;
238
+ default: say(red(`unknown command: ${cmd}`)); help(); process.exit(2);
239
+ }
240
+ } catch (e) {
241
+ say(red(`✗ ${e.message}`));
242
+ process.exit(1);
243
+ }
package/src/realms.mjs ADDED
@@ -0,0 +1,81 @@
1
+ // comb across machines: give a realm agent the credentials it needs, without
2
+ // the secrets ever leaving your control.
3
+ //
4
+ // A secret encrypted with age can be encrypted to SEVERAL recipients at once —
5
+ // each with its own keypair, any one of which can decrypt. So each realm gets
6
+ // its own age key, the store is encrypted to all of them, and the SAME encrypted
7
+ // file can be copied to every realm (over bifrost's sync, or a private repo)
8
+ // leaking nothing: it is ciphertext, useless without a key.
9
+ //
10
+ // Revoking a realm is re-encrypting the store WITHOUT its key — no secret has to
11
+ // be rotated, because that realm can no longer decrypt anything, old copies
12
+ // included (its key never changes what the current ciphertext is encrypted to).
13
+ import { execFileSync, spawnSync } from "node:child_process";
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { combHome, storePath, keyPath } from "./backends/sops.mjs";
17
+
18
+ export const recipientsDir = (home = combHome()) => join(home, "recipients");
19
+
20
+ /** A realm's recipient file: just its age public key. */
21
+ const recipientFile = (name, home = combHome()) => join(recipientsDir(home), name);
22
+
23
+ /** The local store's own public key — always a recipient. */
24
+ export function localRecipient(home = combHome()) {
25
+ const m = readFileSync(keyPath(home), "utf-8").match(/public key: (age1[a-z0-9]+)/);
26
+ if (!m) throw new Error("no local age key — run `comb init`");
27
+ return m[1];
28
+ }
29
+
30
+ /** Names of every realm registered as a recipient. */
31
+ export function listRealmRecipients(home = combHome()) {
32
+ const d = recipientsDir(home);
33
+ if (!existsSync(d)) return [];
34
+ return readdirSync(d)
35
+ .filter((n) => /^[A-Za-z0-9_-]+$/.test(n))
36
+ .map((name) => ({ name, key: readFileSync(join(d, name), "utf-8").trim() }));
37
+ }
38
+
39
+ /** A public key is well-formed if it's a single age1… recipient. */
40
+ export function isValidAgePubkey(key) {
41
+ return /^age1[a-z0-9]{20,}$/.test((key || "").trim());
42
+ }
43
+
44
+ /**
45
+ * Re-key the store to exactly the current recipient set (local + all realms).
46
+ *
47
+ * Runs after any add or remove. `sops updatekeys`-style via rotate add/rm would
48
+ * need diffing; instead we decrypt with our key and re-encrypt to the full set,
49
+ * which is simplest and leaves the store readable by precisely those who should.
50
+ */
51
+ export function rekeyStore(home = combHome()) {
52
+ const recipients = [localRecipient(home), ...listRealmRecipients(home).map((r) => r.key)];
53
+ const env = { ...process.env, SOPS_AGE_KEY_FILE: keyPath(home) };
54
+ // Decrypt to JSON in memory, then re-encrypt to the new recipient set.
55
+ const dec = spawnSync("sops", ["--decrypt", "--output-type", "json", storePath(home)], { encoding: "utf-8", env });
56
+ if (dec.status !== 0) throw new Error(`could not decrypt to re-key: ${dec.stderr?.trim()}`);
57
+ const enc = spawnSync("sops",
58
+ ["--encrypt", "--age", recipients.join(","), "--input-type", "json", "--output-type", "yaml", "/dev/stdin"],
59
+ { input: dec.stdout, encoding: "utf-8", env });
60
+ if (enc.status !== 0) throw new Error(`could not re-encrypt: ${enc.stderr?.trim()}`);
61
+ writeFileSync(storePath(home), enc.stdout);
62
+ return recipients.length;
63
+ }
64
+
65
+ /** Register a realm's public key and re-key the store so it can read secrets. */
66
+ export function addRealm(name, pubkey, home = combHome()) {
67
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`invalid realm name: ${JSON.stringify(name)}`);
68
+ const key = (pubkey || "").trim();
69
+ if (!isValidAgePubkey(key)) throw new Error("that is not an age public key (expected age1…)");
70
+ mkdirSync(recipientsDir(home), { recursive: true });
71
+ writeFileSync(recipientFile(name, home), key + "\n", { mode: 0o644 });
72
+ return rekeyStore(home);
73
+ }
74
+
75
+ /** Remove a realm and re-key WITHOUT it — revocation. */
76
+ export function removeRealm(name, home = combHome()) {
77
+ const f = recipientFile(name, home);
78
+ if (!existsSync(f)) throw new Error(`no realm recipient called ${name}`);
79
+ rmSync(f);
80
+ return rekeyStore(home);
81
+ }