@sagentix-inc/evidence-collector 0.3.1 → 0.3.3

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.
@@ -0,0 +1,303 @@
1
+ // The collector's vault (apps/sos-app/docs/COLLECTOR_VAULT.md): the connectors' secrets and settings. A person
2
+ // types them in sos-app; the browser seals each one to this collector's own X25519 key, so sos-app stores and relays
3
+ // only what it cannot open; the collector opens it here, and each run reads the vault into its own environment, so the
4
+ // sources do not change.
5
+ //
6
+ // One file beside the config, sagentix-collector.vault (mode 0600): each value sealed on its own with AES-256-GCM, its
7
+ // name as additional data, so a value cannot be moved under another name. The names and dates are readable without
8
+ // the key (the check-in reports them to sos-app); the values are not. The file is only as safe as its 256-bit key, so the key lives
9
+ // in the strongest place the computer offers, in this order:
10
+ //
11
+ // systemd $CREDENTIALS_DIRECTORY/sagentix-vault-key: a systemd credential (LoadCredentialEncrypted=, sealed to
12
+ // the TPM where there is one), never on disk in clear
13
+ // docker /run/secrets/sagentix-vault-key: a Docker secret
14
+ // keychain macOS: the Keychain item sagentix-collector-vault / <key id>, through `security` (base64 on stdin)
15
+ // dpapi Windows: sagentix-collector.vault.key.dpapi, the key protected by DPAPI for the collector's account
16
+ // (the protection Windows Credential Manager uses), through PowerShell (base64 on stdin)
17
+ // file anywhere else: sagentix-collector.vault.key, mode 0600; sos-app says the protection is the
18
+ // file's permission only, and how to seal it
19
+ //
20
+ // A value is never logged and never sent to sos-app in clear.
21
+ import { spawnSync } from "node:child_process";
22
+ import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
23
+ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { writePrivate } from "./channel.js";
26
+ export const VAULT_FILE = "sagentix-collector.vault";
27
+ export const KEY_FILE = "sagentix-collector.vault.key";
28
+ export const DPAPI_FILE = "sagentix-collector.vault.key.dpapi";
29
+ export const CREDENTIAL_NAME = "sagentix-vault-key";
30
+ export const DOCKER_SECRET = `/run/secrets/${CREDENTIAL_NAME}`;
31
+ export const KEYCHAIN_SERVICE = "sagentix-collector-vault";
32
+ /** A name the vault takes: an environment variable's. */
33
+ export const NAME = /^[A-Z][A-Z0-9_]{0,99}$/;
34
+ export const MAX_VALUE = 64 * 1024;
35
+ export const KINDS = ["secret", "file", "setting"];
36
+ export const SEAL_FORMAT = "sagentix-seal/1";
37
+ /** The bytes both sides bind in: the format, the collector's key id, the name and the kind. A value sealed for one
38
+ * name, kind or collector does not open as another. */
39
+ export const sealInfo = (keyId, name, kind, bind = {}) => Buffer.from(`${SEAL_FORMAT}\n${keyId}\n${name}\n${kind}\n${canonicalBind(bind)}`);
40
+ /** The settings a value is bound to, in one spelling both sides compute: names sorted, JSON. */
41
+ export const canonicalBind = (b) => JSON.stringify(Object.keys(b).sort().map((k) => [k, b[k]]));
42
+ export const realExec = (cmd, args, input) => {
43
+ const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout: 30_000, windowsHide: true });
44
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
45
+ };
46
+ const key32 = (b) => (b && b.length === 32 ? b : null);
47
+ const fromB64 = (s) => key32(Buffer.from(s.trim(), "base64"));
48
+ function fileHolder(path, protection, readOnly) {
49
+ return {
50
+ protection,
51
+ read: () => {
52
+ if (!existsSync(path))
53
+ return null;
54
+ const raw = readFileSync(path);
55
+ return raw.length === 32 ? raw : fromB64(raw.toString("utf8"));
56
+ },
57
+ create: () => {
58
+ if (readOnly)
59
+ throw new Error(`the vault key ${path} is provided by ${protection} and is missing or unreadable`);
60
+ const k = randomBytes(32);
61
+ writePrivate(path, k.toString("base64") + "\n");
62
+ return k;
63
+ },
64
+ };
65
+ }
66
+ /** `keychain`: a keychain file other than the login one (the tests use a throwaway keychain). */
67
+ function keychainHolder(account, exec, keychain) {
68
+ // The account goes into a `security -i` command line: a key id only (letters, digits, -).
69
+ if (!/^[A-Za-z0-9-]{1,80}$/.test(account))
70
+ throw new Error("the key id is not one the Keychain item may be named after");
71
+ const kc = keychain ? [keychain] : [];
72
+ return {
73
+ protection: "keychain",
74
+ read: () => {
75
+ const r = exec("/usr/bin/security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", ...kc], "");
76
+ return r.status === 0 ? fromB64(r.stdout) : null;
77
+ },
78
+ create: () => {
79
+ const k = randomBytes(32);
80
+ // `security -i` reads its commands on standard input, so the key never appears in a process listing.
81
+ const r = exec("/usr/bin/security", ["-i"], `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${account} -l "Sagentix collector vault" -w ${k.toString("base64")}${keychain ? ` "${keychain}"` : ""}\n`);
82
+ if (r.status !== 0)
83
+ throw new Error(`the Keychain refused the vault key (${r.stderr.trim() || r.status})`);
84
+ return k;
85
+ },
86
+ };
87
+ }
88
+ const DPAPI_PROTECT = "$in=[Console]::In.ReadToEnd().Trim();Add-Type -AssemblyName System.Security;"
89
+ + "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Convert]::FromBase64String($in),$null,'CurrentUser'))";
90
+ const DPAPI_UNPROTECT = "$in=[Console]::In.ReadToEnd().Trim();Add-Type -AssemblyName System.Security;"
91
+ + "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($in),$null,'CurrentUser'))";
92
+ const PS = ["-NoProfile", "-NonInteractive", "-Command"];
93
+ function dpapiHolder(path, exec) {
94
+ return {
95
+ protection: "dpapi",
96
+ read: () => {
97
+ if (!existsSync(path))
98
+ return null;
99
+ const r = exec("powershell.exe", [...PS, DPAPI_UNPROTECT], readFileSync(path, "utf8"));
100
+ return r.status === 0 ? fromB64(r.stdout) : null;
101
+ },
102
+ create: () => {
103
+ const k = randomBytes(32);
104
+ const r = exec("powershell.exe", [...PS, DPAPI_PROTECT], k.toString("base64"));
105
+ if (r.status !== 0 || !r.stdout.trim())
106
+ throw new Error(`DPAPI refused the vault key (${r.stderr.trim() || r.status})`);
107
+ writePrivate(path, r.stdout.trim() + "\n");
108
+ return k;
109
+ },
110
+ };
111
+ }
112
+ /** The strongest key holder this computer offers (see the top of this file). */
113
+ export function keyHolderFor(dir, keyId, o) {
114
+ const exec = o.exec ?? realExec, exists = o.exists ?? existsSync;
115
+ const credDir = o.env.CREDENTIALS_DIRECTORY;
116
+ if (credDir && exists(join(credDir, CREDENTIAL_NAME)))
117
+ return fileHolder(join(credDir, CREDENTIAL_NAME), "systemd", true);
118
+ if (exists(DOCKER_SECRET))
119
+ return fileHolder(DOCKER_SECRET, "docker", true);
120
+ if (o.runner !== "docker" && o.platform === "darwin")
121
+ return keychainHolder(keyId, exec, o.keychain);
122
+ if (o.runner !== "docker" && o.platform === "win32")
123
+ return dpapiHolder(join(dir, DPAPI_FILE), exec);
124
+ return fileHolder(join(dir, KEY_FILE), "file", false);
125
+ }
126
+ export class Vault {
127
+ path;
128
+ holder;
129
+ key = null;
130
+ constructor(dir, holder) { this.path = join(dir, VAULT_FILE); this.holder = holder; }
131
+ get protection() { return this.holder.protection; }
132
+ doc() {
133
+ if (!existsSync(this.path))
134
+ return { format: "sagentix-vault/1", entries: {} };
135
+ const d = JSON.parse(readFileSync(this.path, "utf8"));
136
+ if (d?.format !== "sagentix-vault/1" || typeof d.entries !== "object")
137
+ throw new Error(`${this.path} is not a collector vault`);
138
+ return d;
139
+ }
140
+ write(d) {
141
+ // A new file renamed over the old one, so a crash mid-write never leaves half a vault.
142
+ const tmp = `${this.path}.${randomBytes(4).toString("hex")}.tmp`;
143
+ writePrivate(tmp, JSON.stringify(d, null, 1) + "\n");
144
+ renameSync(tmp, this.path);
145
+ }
146
+ /** The key, made on the first write when there is none. Reading an empty vault needs no key. */
147
+ keyFor(create) {
148
+ if (this.key)
149
+ return this.key;
150
+ this.key = this.holder.read();
151
+ if (!this.key && create)
152
+ this.key = this.holder.create();
153
+ return this.key;
154
+ }
155
+ /** Names, dates and whether each is a secret: no value, no key needed. */
156
+ list() {
157
+ return Object.entries(this.doc().entries).map(([name, e]) => ({ name, setAt: e.setAt, kind: e.kind, ...(e.bind ? { bind: e.bind } : {}) })).sort((a, b) => a.name.localeCompare(b.name));
158
+ }
159
+ set(name, value, o) {
160
+ if (!KINDS.includes(o.kind))
161
+ throw new Error(`${o.kind} is not a kind of vault entry`);
162
+ if (!NAME.test(name))
163
+ throw new Error(`${name} is not a name the vault takes (capital letters, digits and _)`);
164
+ if (typeof value !== "string" || value.length === 0)
165
+ throw new Error("the value is empty");
166
+ if (Buffer.byteLength(value) > MAX_VALUE)
167
+ throw new Error(`the value is larger than ${MAX_VALUE / 1024} KiB`);
168
+ const key = this.keyFor(true);
169
+ const iv = randomBytes(12), c = createCipheriv("aes-256-gcm", key, iv);
170
+ c.setAAD(Buffer.from(`sagentix-vault/1\n${name}`));
171
+ const data = Buffer.concat([c.update(value, "utf8"), c.final()]);
172
+ const d = this.doc();
173
+ d.entries[name] = { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64"), setAt: o.now.toISOString(), kind: o.kind, ...(o.bind && o.kind !== "setting" ? { bind: o.bind } : {}) };
174
+ this.write(d);
175
+ }
176
+ remove(name) {
177
+ const d = this.doc();
178
+ if (!(name in d.entries))
179
+ return false;
180
+ delete d.entries[name];
181
+ this.write(d);
182
+ return true;
183
+ }
184
+ /** One value, or undefined when it is not there. Throws when the key is missing or the value was tampered with. */
185
+ get(name) {
186
+ const e = this.doc().entries[name];
187
+ if (!e)
188
+ return undefined;
189
+ const key = this.keyFor(false);
190
+ if (!key)
191
+ throw new Error(`the vault key is missing (${this.holder.protection}); the vault cannot be read`);
192
+ const d = createDecipheriv("aes-256-gcm", key, Buffer.from(e.iv, "base64"));
193
+ d.setAAD(Buffer.from(`sagentix-vault/1\n${name}`));
194
+ d.setAuthTag(Buffer.from(e.tag, "base64"));
195
+ try {
196
+ return Buffer.concat([d.update(Buffer.from(e.data, "base64")), d.final()]).toString("utf8");
197
+ }
198
+ catch {
199
+ throw new Error(`the vault value ${name} does not open with this key (changed, or sealed with another key)`);
200
+ }
201
+ }
202
+ sealWith(key, plain, aad) {
203
+ const iv = randomBytes(12), c = createCipheriv("aes-256-gcm", key, iv);
204
+ c.setAAD(Buffer.from(aad));
205
+ const data = Buffer.concat([c.update(plain), c.final()]);
206
+ return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64") };
207
+ }
208
+ /** This collector's sealing public key (32 bytes, base64url), made with the vault key on first use. */
209
+ sealPublicKey() {
210
+ const d = this.doc();
211
+ if (d.seal)
212
+ return d.seal.publicKey;
213
+ const key = this.keyFor(true);
214
+ const { privateKey, publicKey } = generateKeyPairSync("x25519");
215
+ const raw = publicKey.export({ format: "jwk" }).x;
216
+ d.seal = { ...this.sealWith(key, privateKey.export({ type: "pkcs8", format: "der" }), "sagentix-vault/1\nseal"), publicKey: raw };
217
+ this.write(d);
218
+ return raw;
219
+ }
220
+ /** Opens a value sealed in the browser for this collector (ECDH X25519, HKDF-SHA256, AES-256-GCM); throws when it
221
+ * was sealed for another collector, name or kind, or changed on the way. */
222
+ openSealed(keyId, v) {
223
+ const d = this.doc();
224
+ if (!d.seal)
225
+ throw new Error("this collector has no sealing key yet");
226
+ const key = this.keyFor(false);
227
+ if (!key)
228
+ throw new Error(`the vault key is missing (${this.holder.protection})`);
229
+ const dk = createDecipheriv("aes-256-gcm", key, Buffer.from(d.seal.iv, "base64"));
230
+ dk.setAAD(Buffer.from("sagentix-vault/1\nseal"));
231
+ dk.setAuthTag(Buffer.from(d.seal.tag, "base64"));
232
+ const priv = createPrivateKey({ key: Buffer.concat([dk.update(Buffer.from(d.seal.data, "base64")), dk.final()]), format: "der", type: "pkcs8" });
233
+ const epk = Buffer.from(v.epk, "base64url");
234
+ if (epk.length !== 32)
235
+ throw new Error("the sealed value's ephemeral key is not 32 bytes");
236
+ const shared = diffieHellman({ privateKey: priv, publicKey: createPublicKey({ key: { kty: "OKP", crv: "X25519", x: v.epk }, format: "jwk" }) });
237
+ const info = sealInfo(keyId, v.name, v.kind, v.bind ?? {});
238
+ const aes = Buffer.from(hkdfSync("sha256", shared, Buffer.concat([epk, Buffer.from(d.seal.publicKey, "base64url")]), info, 32));
239
+ const all = Buffer.from(v.data, "base64"), iv = Buffer.from(v.iv, "base64");
240
+ if (iv.length !== 12 || all.length < 17)
241
+ throw new Error("the sealed value is malformed");
242
+ const dc = createDecipheriv("aes-256-gcm", aes, iv);
243
+ dc.setAAD(info);
244
+ dc.setAuthTag(all.subarray(all.length - 16));
245
+ try {
246
+ return Buffer.concat([dc.update(all.subarray(0, all.length - 16)), dc.final()]).toString("utf8");
247
+ }
248
+ catch {
249
+ throw new Error(`the sealed value for ${v.name} does not open with this collector's key`);
250
+ }
251
+ }
252
+ /** Every entry with its value, for a run's environment. */
253
+ all() {
254
+ return this.list().map(({ name, kind, bind }) => ({ name, kind, value: this.get(name), ...(bind ? { bind } : {}) }));
255
+ }
256
+ }
257
+ /**
258
+ * A run's environment: the process's own, plus each vault entry it does not already set (a variable set on purpose
259
+ * wins). `settingsOf(name)`: the settings of the connector a secret belongs to (the config's credentials). A `file` entry is written to a private temporary folder beside the config, its path put in the variable, and
260
+ * the folder removed by `cleanup` when the run ends. No vault file: the environment as it is.
261
+ */
262
+ export function runEnv(dir, holder, base, settingsOf = () => []) {
263
+ const v = new Vault(dir, holder);
264
+ if (!existsSync(v.path))
265
+ return { env: base, used: [], secrets: [], warnings: [], cleanup: () => { } };
266
+ const env = { ...base }, used = [], secrets = [], warnings = [];
267
+ const all = v.all();
268
+ for (const e of all)
269
+ if (e.kind === "setting" && !env[e.name]) {
270
+ env[e.name] = e.value;
271
+ used.push(e.name);
272
+ }
273
+ let tmp;
274
+ for (const e of all) {
275
+ if (e.kind === "setting" || env[e.name])
276
+ continue;
277
+ // A secret is used only while its connector's settings are the ones it was set with: all of them, so a setting
278
+ // added since (an optional URL) counts as a change too.
279
+ if (e.bind) {
280
+ const now = {};
281
+ for (const k of settingsOf(e.name))
282
+ if (env[k])
283
+ now[k] = env[k];
284
+ if (canonicalBind(now) !== canonicalBind(e.bind)) {
285
+ const changed = [...new Set([...Object.keys(now), ...Object.keys(e.bind)])].filter((k) => now[k] !== e.bind[k]);
286
+ warnings.push(`${e.name} is not used: ${changed.join(", ")} changed since it was set; set ${e.name} again`);
287
+ continue;
288
+ }
289
+ }
290
+ if (e.kind === "file") {
291
+ tmp ??= mkdtempSync(join(dir, ".sagentix-run-"));
292
+ const p = join(tmp, e.name.toLowerCase());
293
+ writePrivate(p, e.value);
294
+ env[e.name] = p;
295
+ }
296
+ else
297
+ env[e.name] = e.value;
298
+ used.push(e.name);
299
+ secrets.push(e.value, ...e.value.split(/\r?\n/).filter((l) => l.length >= 16));
300
+ }
301
+ return { env, used, secrets, warnings, cleanup: () => { if (tmp)
302
+ rmSync(tmp, { recursive: true, force: true }); } };
303
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Customer-side evidence collector: reads systems read-only, runs checks, signs the results and pushes them outbound to sos-app.",
5
5
  "type": "module",
6
6
  "engines": {