@josfox/jos 4.0.8 → 5.0.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/LICENSE +208 -21
- package/PROMISES.md +52 -0
- package/README.md +294 -146
- package/SECURITY-MCP.md +117 -0
- package/SECURITY-SECRETS.md +108 -0
- package/STYLE.md +23 -0
- package/bin/jos +139 -67
- package/bin/jos.cmd +5 -0
- package/examples/01-hello.jos +44 -0
- package/examples/02-criteria.jos +54 -0
- package/examples/03-flow-gate.jos +83 -0
- package/examples/04-use-template/README.md +22 -0
- package/examples/04-use-template/lib/greeting.jos +56 -0
- package/examples/04-use-template/use.jos +64 -0
- package/examples/05-sealed/.jos/manifest.jos +53 -0
- package/examples/05-sealed/README.md +25 -0
- package/examples/06-requires.jos +42 -0
- package/examples/07-sandbox.jos +39 -0
- package/examples/08-ids-ledger-meter.md +26 -0
- package/examples/10-ollama.jos +47 -0
- package/examples/11-openai.jos +47 -0
- package/examples/12-gemini.jos +47 -0
- package/examples/13-anthropic.jos +47 -0
- package/examples/14-serve.jos +88 -0
- package/examples/README.md +105 -0
- package/examples/hello.jos +47 -0
- package/package.json +31 -9
- package/promises.json +894 -0
- package/src/args.js +47 -0
- package/src/commands/_frozen/add.README.md +1 -0
- package/src/commands/_frozen/get.README.md +1 -0
- package/src/commands/_frozen/llm.README.md +1 -0
- package/src/commands/_frozen/prompts.README.md +1 -0
- package/src/commands/_frozen/repo.README.md +1 -0
- package/src/commands/_frozen/serve-explorer.README.md +1 -0
- package/src/commands/ids.js +101 -0
- package/src/commands/init.js +113 -147
- package/src/commands/ledger.js +94 -0
- package/src/commands/meter.js +96 -0
- package/src/commands/migrate.js +214 -0
- package/src/commands/overview.js +118 -0
- package/src/commands/run.js +164 -214
- package/src/commands/runs.js +102 -0
- package/src/commands/secrets.js +695 -115
- package/src/commands/start.js +210 -0
- package/src/commands/validate.js +153 -165
- package/src/commands/verify.js +56 -0
- package/src/commands/version.js +23 -0
- package/src/intention.js +114 -0
- package/src/kernel.js +69 -0
- package/src/ledgers.js +57 -0
- package/src/mcp-guide.js +138 -0
- package/src/mcp.js +734 -0
- package/src/serve.js +38 -770
- package/src/ui.js +98 -0
- package/src/commands/add.js +0 -145
- package/src/commands/get.js +0 -245
- package/src/commands/repo.js +0 -139
- package/src/index.js +0 -0
package/src/commands/secrets.js
CHANGED
|
@@ -1,137 +1,717 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JOS SECRETS
|
|
2
|
+
* JOS SECRETS - local credential storage in ~/.jos/secrets/ (threat model and format: SECURITY-SECRETS.md).
|
|
3
|
+
*
|
|
4
|
+
* Format v2: AES-256-GCM per entry, 12-byte random nonce per write, the entry name as AAD, 16-byte tag; the
|
|
5
|
+
* GCM key is derived from the master key with HKDF-SHA256, so the legacy CBC key and the GCM key are never the
|
|
6
|
+
* same bytes. Entries written by 4.x and 5.0.0-rc.1/rc.2 (AES-256-CBC, "<iv>:<ct>") are still read, and are
|
|
7
|
+
* rewritten only by `jos secrets migrate` (backup 0600 first, every value verified after the write).
|
|
8
|
+
*
|
|
9
|
+
* Values never reach argv, stdout, files other than the vault, or error messages:
|
|
10
|
+
* set NAME --stdin value from stdin (hidden prompt on a terminal); a value argument is refused
|
|
11
|
+
* get NAME existence + keyed fingerprint (file backend: hmac:, 8 hex); --reveal only to a terminal, confirmed
|
|
12
|
+
* exec --only A,B -- cmd the values go only into the child's environment
|
|
13
|
+
* The directory must be 0700 and everything in it 0600 (0700 for directories), owned by the user, no symlinks;
|
|
14
|
+
* otherwise nothing is read until `jos secrets fix-perms`. JOS_SECRETS_BACKEND=keychain uses the macOS keychain.
|
|
15
|
+
*
|
|
16
|
+
* Every process-dependent input (streams, environment, platform, uid, clock, the security(1) path, the stdio of
|
|
17
|
+
* an exec child, read-back after a write) goes through `io`, so the tests reach every branch in-process.
|
|
18
|
+
*
|
|
19
|
+
* Exit codes: 0 ok, 1 error, not found or refused, 64 usage; exec returns the child's code (126/127 if it
|
|
20
|
+
* cannot be started, 128+n if it dies by signal n).
|
|
3
21
|
*/
|
|
4
|
-
|
|
22
|
+
'use strict';
|
|
5
23
|
const fs = require('fs');
|
|
24
|
+
const os = require('os');
|
|
6
25
|
const path = require('path');
|
|
7
26
|
const crypto = require('crypto');
|
|
27
|
+
const { spawn } = require('child_process');
|
|
28
|
+
const { StringDecoder } = require('string_decoder');
|
|
29
|
+
const ui = require('../ui');
|
|
30
|
+
const { RULE } = ui;
|
|
8
31
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
32
|
+
const HEX = /^[0-9a-f]*$/i;
|
|
33
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
34
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
35
|
+
const FS_CODE = /^E[A-Z]+$/;
|
|
36
|
+
const ALG = 'aes-256-gcm';
|
|
37
|
+
const HKDF_INFO = 'jos-secrets/v2 aes-256-gcm';
|
|
38
|
+
const HKDF_FP_INFO = 'jos-secrets/v2 fingerprint hmac-sha256'; // its own label: the fingerprint key is never the GCM key
|
|
39
|
+
const MAX_VALUE = 1024 * 1024; // bytes (stdin) or characters (terminal) at most
|
|
40
|
+
const MAX_NAME = 256;
|
|
41
|
+
const KEYCHAIN_SERVICE = 'jos';
|
|
42
|
+
const KEYCHAIN_MAX = 128; // security(1) reads a prompted password with getpass(3): 128 characters at most
|
|
43
|
+
const ACTIONS = ['set', 'get', 'list', 'delete', 'exec', 'migrate', 'fix-perms'];
|
|
16
44
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
45
|
+
class VaultError extends Error {}
|
|
46
|
+
class UsageError extends Error {}
|
|
47
|
+
|
|
48
|
+
function defaultIO() {
|
|
49
|
+
return {
|
|
50
|
+
stdin: process.stdin,
|
|
51
|
+
stdout: process.stdout,
|
|
52
|
+
stderr: process.stderr,
|
|
53
|
+
env: process.env,
|
|
54
|
+
platform: process.platform,
|
|
55
|
+
/* node:coverage ignore next */
|
|
56
|
+
uid: typeof process.getuid === 'function' ? process.getuid() : -1, // Windows has no uid (and no mode check)
|
|
57
|
+
now: () => new Date(),
|
|
58
|
+
security: '/usr/bin/security',
|
|
59
|
+
childStdio: 'inherit',
|
|
60
|
+
readBack: (file) => fs.readFileSync(file, 'utf8'),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------------------------------------ files
|
|
65
|
+
|
|
66
|
+
function writeAtomic(file, data) {
|
|
67
|
+
// Unique temp name created exclusively (never follows a planted file), mode 0600, then renamed over.
|
|
68
|
+
const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
|
69
|
+
const buf = Buffer.from(data, 'utf8');
|
|
70
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
71
|
+
try {
|
|
72
|
+
for (let off = 0; off < buf.length;) off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
73
|
+
fs.fchmodSync(fd, 0o600);
|
|
74
|
+
fs.fsyncSync(fd);
|
|
75
|
+
} finally { fs.closeSync(fd); }
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function modeText(m) { return '0' + (m & 0o777).toString(8).padStart(3, '0'); }
|
|
80
|
+
|
|
81
|
+
// What is wrong with the permissions of the secrets directory and of every entry in it. A missing directory
|
|
82
|
+
// is not a problem (nothing is stored yet); a non-directory is an fs error (ENOTDIR).
|
|
83
|
+
function permissionProblems(dir, io) {
|
|
84
|
+
const problems = [];
|
|
85
|
+
let st;
|
|
86
|
+
try { st = fs.lstatSync(dir); } catch (e) { if (e.code === 'ENOENT') return problems; throw e; }
|
|
87
|
+
if (st.isSymbolicLink()) return [{ path: dir, why: 'is a symbolic link', fixable: false }];
|
|
88
|
+
if (!st.isDirectory()) throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
|
|
89
|
+
if (io.platform === 'win32') return problems; // no POSIX modes: the directory ACL is the user's
|
|
90
|
+
const look = (p, s, want) => {
|
|
91
|
+
if (s.uid !== io.uid) problems.push({ path: p, why: 'is owned by uid ' + s.uid + ', not by you (' + io.uid + ')', fixable: false });
|
|
92
|
+
if (s.mode & 0o077) problems.push({ path: p, why: 'has mode ' + modeText(s.mode) + ', expected ' + modeText(want), fixable: true, want });
|
|
93
|
+
};
|
|
94
|
+
look(dir, st, 0o700);
|
|
95
|
+
for (const name of fs.readdirSync(dir)) {
|
|
96
|
+
const p = path.join(dir, name);
|
|
97
|
+
const s = fs.lstatSync(p);
|
|
98
|
+
if (s.isSymbolicLink()) problems.push({ path: p, why: 'is a symbolic link', fixable: false });
|
|
99
|
+
else look(p, s, s.isDirectory() ? 0o700 : 0o600);
|
|
21
100
|
}
|
|
101
|
+
return problems;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readMasterKey(dir, create, io) {
|
|
105
|
+
const masterPath = path.join(dir, '.master');
|
|
22
106
|
if (!fs.existsSync(masterPath)) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
${C.white}Examples:${C.reset}
|
|
50
|
-
jos secrets set API_KEY sk-abc123
|
|
51
|
-
jos secrets get API_KEY
|
|
52
|
-
jos secrets list
|
|
53
|
-
jos secrets delete API_KEY
|
|
54
|
-
|
|
55
|
-
${C.white}Security:${C.reset}
|
|
56
|
-
• Master key: ~/.jos/secrets/.master (0600)
|
|
57
|
-
• Vault: ~/.jos/secrets/vault.json
|
|
58
|
-
• Encryption: AES-256-CBC with random IV per secret
|
|
59
|
-
`);
|
|
107
|
+
if (!create) return null;
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
109
|
+
writeAtomic(masterPath, crypto.randomBytes(32).toString('hex'));
|
|
110
|
+
const e = ui.printer(io.stderr);
|
|
111
|
+
e.ok('New master key generated at ' + masterPath);
|
|
112
|
+
e.hint('0600; keep it: without it the entries of the vault cannot be decrypted');
|
|
113
|
+
}
|
|
114
|
+
const text = fs.readFileSync(masterPath, 'utf8').trim();
|
|
115
|
+
if (text.length !== 64 || !HEX.test(text)) {
|
|
116
|
+
throw new VaultError('master key is corrupt: ' + masterPath + ' must hold 64 hex characters');
|
|
117
|
+
}
|
|
118
|
+
return Buffer.from(text, 'hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ------------------------------------------------------------------------------------------------ vault
|
|
122
|
+
|
|
123
|
+
function entryFormat(v) { return typeof v === 'string' ? 1 : 2; }
|
|
124
|
+
|
|
125
|
+
function isObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
126
|
+
|
|
127
|
+
function checkEntry(k, v, vaultPath) {
|
|
128
|
+
if (typeof v === 'string') {
|
|
129
|
+
const parts = v.split(':');
|
|
130
|
+
if (parts.length !== 2 || parts[0].length !== 32 || !HEX.test(parts[0]) || parts[1].length === 0 || parts[1].length % 32 !== 0 || !HEX.test(parts[1])) {
|
|
131
|
+
throw new VaultError('vault is corrupt: entry ' + JSON.stringify(k) + ' in ' + vaultPath + ' is not "<iv hex>:<ciphertext hex>"');
|
|
132
|
+
}
|
|
60
133
|
return;
|
|
61
134
|
}
|
|
135
|
+
if (isObject(v) && typeof v.v === 'number' && v.v > 2) {
|
|
136
|
+
throw new VaultError('vault entry ' + JSON.stringify(k) + ' uses format v' + v.v + ', which this version of jos does not read');
|
|
137
|
+
}
|
|
138
|
+
const ok = isObject(v) && v.v === 2 && v.alg === ALG &&
|
|
139
|
+
typeof v.iv === 'string' && v.iv.length === 24 && HEX.test(v.iv) &&
|
|
140
|
+
typeof v.tag === 'string' && v.tag.length === 32 && HEX.test(v.tag) &&
|
|
141
|
+
typeof v.ct === 'string' && v.ct.length % 2 === 0 && HEX.test(v.ct) &&
|
|
142
|
+
Object.keys(v).every((f) => ['v', 'alg', 'iv', 'tag', 'ct'].includes(f));
|
|
143
|
+
if (!ok) {
|
|
144
|
+
throw new VaultError('vault is corrupt: entry ' + JSON.stringify(k) + ' in ' + vaultPath + ' is neither "<iv hex>:<ciphertext hex>" (v1) nor {"v":2,"alg":"aes-256-gcm","iv","tag","ct"}');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseVault(text, vaultPath) {
|
|
149
|
+
let parsed;
|
|
150
|
+
try { parsed = JSON.parse(text); } catch (e) {
|
|
151
|
+
throw new VaultError('vault is corrupt: ' + vaultPath + ' is not valid JSON');
|
|
152
|
+
}
|
|
153
|
+
if (!isObject(parsed)) throw new VaultError('vault is corrupt: ' + vaultPath + ' must hold a JSON object');
|
|
154
|
+
const vault = Object.create(null); // "__proto__" is an ordinary name
|
|
155
|
+
for (const k of Object.keys(parsed)) { checkEntry(k, parsed[k], vaultPath); vault[k] = parsed[k]; }
|
|
156
|
+
return vault;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function serializeVault(vault) {
|
|
160
|
+
const out = Object.create(null); // a plain {} would treat "__proto__" as the prototype and drop the entry
|
|
161
|
+
for (const k of Object.keys(vault)) out[k] = vault[k];
|
|
162
|
+
return JSON.stringify(out, null, 2) + '\n';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function gcmKey(master) { return Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), HKDF_INFO, 32)); }
|
|
62
166
|
|
|
63
|
-
|
|
64
|
-
const
|
|
167
|
+
function encryptV2(master, name, value) {
|
|
168
|
+
const key = gcmKey(master);
|
|
169
|
+
const iv = crypto.randomBytes(12);
|
|
170
|
+
const c = crypto.createCipheriv(ALG, key, iv, { authTagLength: 16 });
|
|
171
|
+
c.setAAD(Buffer.from(name, 'utf8'));
|
|
172
|
+
const ct = Buffer.concat([c.update(value), c.final()]);
|
|
173
|
+
key.fill(0);
|
|
174
|
+
return { v: 2, alg: ALG, iv: iv.toString('hex'), tag: c.getAuthTag().toString('hex'), ct: ct.toString('hex') };
|
|
175
|
+
}
|
|
65
176
|
|
|
177
|
+
function decryptEntry(master, name, entry) {
|
|
178
|
+
const v1 = typeof entry === 'string';
|
|
179
|
+
const key = v1 ? Buffer.from(master) : gcmKey(master);
|
|
180
|
+
let value;
|
|
66
181
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
182
|
+
let d;
|
|
183
|
+
if (v1) {
|
|
184
|
+
d = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(entry.slice(0, 32), 'hex'));
|
|
185
|
+
value = Buffer.concat([d.update(Buffer.from(entry.slice(33), 'hex')), d.final()]);
|
|
186
|
+
} else {
|
|
187
|
+
d = crypto.createDecipheriv(ALG, key, Buffer.from(entry.iv, 'hex'), { authTagLength: 16 });
|
|
188
|
+
d.setAAD(Buffer.from(name, 'utf8'));
|
|
189
|
+
d.setAuthTag(Buffer.from(entry.tag, 'hex'));
|
|
190
|
+
value = Buffer.concat([d.update(Buffer.from(entry.ct, 'hex')), d.final()]);
|
|
71
191
|
}
|
|
192
|
+
} catch (e) {
|
|
193
|
+
throw new VaultError(v1
|
|
194
|
+
? 'secret ' + JSON.stringify(name) + ' cannot be decrypted with the current master key'
|
|
195
|
+
: 'secret ' + JSON.stringify(name) + ' failed authentication: the vault entry was altered, renamed, or the master key is not the one that wrote it');
|
|
196
|
+
} finally { key.fill(0); }
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
72
199
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
encrypted += cipher.final('hex');
|
|
83
|
-
vault[key] = iv.toString('hex') + ':' + encrypted;
|
|
84
|
-
fs.writeFileSync(vaultPath, JSON.stringify(vault, null, 2), { mode: 0o600 });
|
|
85
|
-
console.log(`${C.green}🔒 Secret '${key}' saved securely.${C.reset}`);
|
|
86
|
-
break;
|
|
87
|
-
|
|
88
|
-
case 'get':
|
|
89
|
-
if (!key) {
|
|
90
|
-
console.log(`${C.red}✖ Usage: jos secrets get <key>${C.reset}`);
|
|
91
|
-
process.exit(1);
|
|
92
|
-
}
|
|
93
|
-
if (!vault[key]) {
|
|
94
|
-
console.log(`${C.yellow}⚠ Secret '${key}' not found${C.reset}`);
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
const [ivHex, encData] = vault[key].split(':');
|
|
98
|
-
const decipher = crypto.createDecipheriv('aes-256-cbc', masterKey, Buffer.from(ivHex, 'hex'));
|
|
99
|
-
let decrypted = decipher.update(encData, 'hex', 'utf8');
|
|
100
|
-
decrypted += decipher.final('utf8');
|
|
101
|
-
console.log(decrypted);
|
|
102
|
-
break;
|
|
103
|
-
|
|
104
|
-
case 'list':
|
|
105
|
-
const keys = Object.keys(vault);
|
|
106
|
-
if (keys.length === 0) {
|
|
107
|
-
console.log(`${C.dim}No secrets stored${C.reset}`);
|
|
108
|
-
} else {
|
|
109
|
-
console.log(`${C.cyan}🔐 Stored secrets (${keys.length}):${C.reset}`);
|
|
110
|
-
keys.forEach(k => console.log(` ${C.white}•${C.reset} ${k}`));
|
|
111
|
-
}
|
|
112
|
-
break;
|
|
200
|
+
// Keyed fingerprint: HMAC-SHA256 under a key derived (HKDF) from the master key with its own label, first 8 hex.
|
|
201
|
+
// Stable for one value in one vault, different between vaults, and not computable (so a guess not testable)
|
|
202
|
+
// without the master key. A bare sha256 of the value would let anyone who saw the output test guesses.
|
|
203
|
+
function fingerprint(master, value) {
|
|
204
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), HKDF_FP_INFO, 32));
|
|
205
|
+
const mac = crypto.createHmac('sha256', key).update(value).digest('hex').slice(0, 8);
|
|
206
|
+
key.fill(0);
|
|
207
|
+
return 'hmac:' + mac;
|
|
208
|
+
}
|
|
113
209
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
210
|
+
function sameBytes(a, b) { return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
|
211
|
+
|
|
212
|
+
// ------------------------------------------------------------------------------------------------ keychain (macOS)
|
|
213
|
+
|
|
214
|
+
function run(io, argv, input, detached) {
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
// detached: a new session without a controlling terminal, so security(1) reads the prompted password
|
|
217
|
+
// from this stdin pipe instead of /dev/tty.
|
|
218
|
+
const ch = spawn(io.security, argv, { stdio: ['pipe', 'pipe', 'ignore'], detached, env: io.env });
|
|
219
|
+
const out = [];
|
|
220
|
+
ch.stdout.on('data', (d) => out.push(d));
|
|
221
|
+
ch.on('error', () => resolve({ code: -1, out: Buffer.alloc(0) }));
|
|
222
|
+
ch.on('close', (code) => resolve({ code, out: Buffer.concat(out) }));
|
|
223
|
+
// EPIPE if the child exits before reading (its exit code tells); not reproducible in a test: the input is
|
|
224
|
+
// at most 2 x 129 bytes and fits the pipe buffer before the child can exit.
|
|
225
|
+
/* node:coverage ignore next */
|
|
226
|
+
ch.stdin.on('error', () => {});
|
|
227
|
+
ch.stdin.end(input);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function keychain(io) {
|
|
232
|
+
const kc = {
|
|
233
|
+
async get(name) {
|
|
234
|
+
const r = await run(io, ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', name, '-w'], '', false);
|
|
235
|
+
if (r.code === 44) return null;
|
|
236
|
+
if (r.code !== 0) throw new VaultError('keychain read of ' + JSON.stringify(name) + ' failed (security exit ' + r.code + ')');
|
|
237
|
+
const b = r.out;
|
|
238
|
+
return b.length && b[b.length - 1] === 0x0a ? b.subarray(0, b.length - 1) : b;
|
|
239
|
+
},
|
|
240
|
+
async set(name, value) {
|
|
241
|
+
const line = Buffer.concat([value, Buffer.from('\n')]);
|
|
242
|
+
const input = Buffer.concat([line, line]); // the password, then its confirmation
|
|
243
|
+
line.fill(0);
|
|
244
|
+
const r = await run(io, ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', name, '-w'], input, true);
|
|
245
|
+
input.fill(0);
|
|
246
|
+
if (r.code !== 0) throw new VaultError('keychain write of ' + JSON.stringify(name) + ' failed (security exit ' + r.code + ')');
|
|
247
|
+
const back = await kc.get(name);
|
|
248
|
+
const same = back !== null && sameBytes(back, value);
|
|
249
|
+
if (back) back.fill(0);
|
|
250
|
+
if (!same) {
|
|
251
|
+
await kc.del(name);
|
|
252
|
+
throw new VaultError('keychain write of ' + JSON.stringify(name) + ' could not be verified; the item was removed');
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async del(name) {
|
|
256
|
+
const r = await run(io, ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', name], '', false);
|
|
257
|
+
if (r.code === 44) return false;
|
|
258
|
+
if (r.code !== 0) throw new VaultError('keychain delete of ' + JSON.stringify(name) + ' failed (security exit ' + r.code + ')');
|
|
259
|
+
return true;
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
return kc;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The keychain cannot be listed without dumping it, so the names (never the values) are kept in keychain.json.
|
|
266
|
+
function readIndex(indexPath) {
|
|
267
|
+
if (!fs.existsSync(indexPath)) return [];
|
|
268
|
+
let p = null;
|
|
269
|
+
try { p = JSON.parse(fs.readFileSync(indexPath, 'utf8')); } catch (e) { /* reported below */ }
|
|
270
|
+
if (!isObject(p) || p.v !== 1 || !Array.isArray(p.names) || !p.names.every((n) => typeof n === 'string')) {
|
|
271
|
+
throw new VaultError('keychain index is corrupt: ' + indexPath + ' must hold {"v":1,"names":[...]}');
|
|
272
|
+
}
|
|
273
|
+
return p.names;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function writeIndex(dir, indexPath, names) {
|
|
277
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
278
|
+
writeAtomic(indexPath, JSON.stringify({ v: 1, names: Array.from(new Set(names)).sort() }, null, 2) + '\n');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ------------------------------------------------------------------------------------------------ input
|
|
282
|
+
|
|
283
|
+
function readPiped(stdin, max) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
const chunks = [];
|
|
286
|
+
let n = 0;
|
|
287
|
+
const finish = (e) => {
|
|
288
|
+
stdin.removeListener('data', onData);
|
|
289
|
+
stdin.removeListener('end', onEnd);
|
|
290
|
+
stdin.removeListener('error', finish);
|
|
291
|
+
stdin.pause();
|
|
292
|
+
const buf = Buffer.concat(chunks);
|
|
293
|
+
for (const c of chunks) c.fill(0);
|
|
294
|
+
if (e) { buf.fill(0); reject(e); } else resolve(buf);
|
|
295
|
+
};
|
|
296
|
+
const onData = (d) => { chunks.push(d); n += d.length; if (n > max) finish(new UsageError('the value is longer than ' + max + ' bytes')); };
|
|
297
|
+
const onEnd = () => finish();
|
|
298
|
+
stdin.on('data', onData);
|
|
299
|
+
stdin.on('end', onEnd);
|
|
300
|
+
stdin.on('error', finish);
|
|
301
|
+
stdin.resume();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// One line from a terminal. hidden: raw mode (no echo), backspace edits, Enter or Ctrl-D ends, Ctrl-C cancels.
|
|
306
|
+
function readTerminalLine(io, prompt, hidden) {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const stdin = io.stdin;
|
|
309
|
+
const dec = new StringDecoder('utf8');
|
|
310
|
+
let chars = [];
|
|
311
|
+
const finish = (e) => {
|
|
312
|
+
stdin.removeListener('data', onData);
|
|
313
|
+
if (hidden) { stdin.setRawMode(false); ui.printer(io.stderr).line(); }
|
|
314
|
+
stdin.pause();
|
|
315
|
+
const text = chars.join('');
|
|
316
|
+
chars = [];
|
|
317
|
+
if (e) reject(e); else resolve(text);
|
|
318
|
+
};
|
|
319
|
+
const onData = (d) => {
|
|
320
|
+
for (const ch of dec.write(d)) {
|
|
321
|
+
if (ch === '\r' || ch === '\n' || ch === '\u0004') return finish();
|
|
322
|
+
if (ch === '\u0003') return finish(Object.assign(new UsageError('cancelled'), { exitCode: 130 }));
|
|
323
|
+
if (hidden && (ch === '\u007f' || ch === '\b')) { chars.pop(); continue; }
|
|
324
|
+
chars.push(ch);
|
|
325
|
+
if (chars.length > MAX_VALUE) return finish(new UsageError('the value is longer than ' + MAX_VALUE + ' characters'));
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
ui.printer(io.stderr).text(prompt);
|
|
329
|
+
if (hidden) stdin.setRawMode(true);
|
|
330
|
+
stdin.on('data', onData);
|
|
331
|
+
stdin.resume();
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function readSecretValue(io, name) {
|
|
336
|
+
let buf;
|
|
337
|
+
if (io.stdin.isTTY) {
|
|
338
|
+
buf = Buffer.from(await readTerminalLine(io, 'Value for ' + JSON.stringify(name) + ' (input hidden, Enter to finish): ', true), 'utf8');
|
|
339
|
+
} else {
|
|
340
|
+
const raw = await readPiped(io.stdin, MAX_VALUE);
|
|
341
|
+
let end = raw.length; // one final line break comes from `echo` or a heredoc; it is not part of the value
|
|
342
|
+
if (end && raw[end - 1] === 0x0a) { end--; if (end && raw[end - 1] === 0x0d) end--; }
|
|
343
|
+
buf = Buffer.from(raw.subarray(0, end));
|
|
344
|
+
raw.fill(0);
|
|
345
|
+
}
|
|
346
|
+
if (buf.length === 0) throw new UsageError('empty value: nothing was read from stdin');
|
|
347
|
+
return buf;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ------------------------------------------------------------------------------------------------ command line
|
|
351
|
+
|
|
352
|
+
const HELP = (C) => `
|
|
353
|
+
${C.purple}${RULE}${C.reset}
|
|
354
|
+
${C.bold}JOS SECRETS${C.reset} // local credential storage (AES-256-GCM, values never on the command line)
|
|
355
|
+
${C.purple}${RULE}${C.reset}
|
|
127
356
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
357
|
+
${C.bold}Usage:${C.reset} jos secrets <action> [name] [options]
|
|
358
|
+
|
|
359
|
+
${C.bold}Actions:${C.reset}
|
|
360
|
+
set <name> --stdin Store a secret read from stdin (hidden prompt on a terminal); the name is
|
|
361
|
+
an environment variable name: letters, digits, "_", not starting with a digit
|
|
362
|
+
get <name> Say whether it exists and print its keyed fingerprint (hmac, 8 hex; file backend)
|
|
363
|
+
get <name> --reveal Print the value, only to a terminal and after typing the name
|
|
364
|
+
exec --only A,B -- <cmd> ... Run <cmd> with the secrets A and B added to its environment only
|
|
365
|
+
list List the names (not the values); marks entries in the old format
|
|
366
|
+
delete <name> Remove a secret
|
|
367
|
+
migrate Rewrite old-format entries (AES-256-CBC) as v2, with a 0600 backup
|
|
368
|
+
fix-perms Set the secrets directory to 0700 and its files to 0600
|
|
369
|
+
|
|
370
|
+
${C.bold}Examples:${C.reset}
|
|
371
|
+
printf %s "$TOKEN" | jos secrets set OPENAI_API_KEY --stdin
|
|
372
|
+
jos secrets exec --only OPENAI_API_KEY -- node agent.js
|
|
373
|
+
|
|
374
|
+
${C.bold}Files:${C.reset}
|
|
375
|
+
~/.jos/secrets/.master master key, 64 hex characters (0600)
|
|
376
|
+
~/.jos/secrets/vault.json {"<name>": {"v":2,"alg":"aes-256-gcm","iv","tag","ct"}} (0600)
|
|
377
|
+
|
|
378
|
+
${C.bold}Environment:${C.reset} JOS_SECRETS_BACKEND=keychain stores values in the macOS login keychain (service "jos")
|
|
379
|
+
|
|
380
|
+
${C.bold}Exit codes:${C.reset} 0 ok, 1 error, not found or refused, 64 usage; exec returns the code of <cmd>
|
|
381
|
+
`;
|
|
382
|
+
|
|
383
|
+
function parseArgs(args) {
|
|
384
|
+
const dd = args.indexOf('--');
|
|
385
|
+
const head = dd === -1 ? args : args.slice(0, dd);
|
|
386
|
+
const o = { action: head[0], positional: [], stdin: false, reveal: false, only: null, unknown: [], command: dd === -1 ? null : args.slice(dd + 1) };
|
|
387
|
+
o.help = head.includes('--help') || head.includes('-h');
|
|
388
|
+
for (let i = 1; i < head.length; i++) {
|
|
389
|
+
const a = head[i];
|
|
390
|
+
if (a === '--stdin') o.stdin = true;
|
|
391
|
+
else if (a === '--reveal') o.reveal = true;
|
|
392
|
+
else if (a === '--only') o.only = i + 1 < head.length ? head[++i] : '';
|
|
393
|
+
else if (a.startsWith('--only=')) o.only = a.slice(7);
|
|
394
|
+
else if (a.startsWith('-') && a !== '--help' && a !== '-h') o.unknown.push(a);
|
|
395
|
+
else if (!a.startsWith('-')) o.positional.push(a);
|
|
396
|
+
}
|
|
397
|
+
return o;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function checkName(name) {
|
|
401
|
+
if (name === undefined || name.length === 0) throw new UsageError('missing secret name');
|
|
402
|
+
if (name.length > MAX_NAME) throw new UsageError('secret names are at most ' + MAX_NAME + ' characters');
|
|
403
|
+
if (CONTROL.test(name)) throw new UsageError('secret names cannot contain control characters');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// One rule for the names `set` accepts and `exec --only` accepts: they are environment variable names.
|
|
407
|
+
// `get`, `delete` and `list` keep the looser checkName, so a vault written by an older version (names such as
|
|
408
|
+
// "my-key") can still be listed, read and cleaned up.
|
|
409
|
+
const NAME_RULE = 'letters, digits and "_", not starting with a digit ([A-Za-z_][A-Za-z0-9_]*), because exec hands the name to the command as an environment variable';
|
|
410
|
+
function checkEnvName(name, where) {
|
|
411
|
+
if (!ENV_NAME.test(name)) throw new UsageError(where + ': ' + JSON.stringify(name) + ' is not a valid secret name: use ' + NAME_RULE);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const SET_ARG_REFUSED =
|
|
415
|
+
'a value on the command line is visible in the process list and in your shell history, so it is not accepted.\n' +
|
|
416
|
+
' Pipe it: printf %s "$VALUE" | jos secrets set NAME --stdin\n' +
|
|
417
|
+
' or type it at the hidden prompt: jos secrets set NAME --stdin';
|
|
418
|
+
|
|
419
|
+
// Usage errors, found before anything is read.
|
|
420
|
+
function checkUsage(o, io) {
|
|
421
|
+
const action = o.action;
|
|
422
|
+
if (!ACTIONS.includes(action)) throw new UsageError('unknown action ' + JSON.stringify(action) + ' (' + ACTIONS.join(', ') + ')');
|
|
423
|
+
if (o.unknown.length) throw new UsageError('unknown option ' + JSON.stringify(o.unknown[0]) + ' for ' + action);
|
|
424
|
+
const backend = io.env.JOS_SECRETS_BACKEND || 'file';
|
|
425
|
+
if (backend !== 'file' && backend !== 'keychain') throw new UsageError('JOS_SECRETS_BACKEND must be "file" or "keychain"');
|
|
426
|
+
if (backend === 'keychain' && io.platform !== 'darwin') throw new UsageError('JOS_SECRETS_BACKEND=keychain is available on macOS only');
|
|
427
|
+
if (o.command !== null && action !== 'exec') throw new UsageError('"--" is only used by exec');
|
|
428
|
+
if (o.stdin && action !== 'set') throw new UsageError('--stdin is only used by set');
|
|
429
|
+
if (o.reveal && action !== 'get') throw new UsageError('--reveal is only used by get');
|
|
430
|
+
if (o.only !== null && action !== 'exec') throw new UsageError('--only is only used by exec');
|
|
431
|
+
if (action === 'set') {
|
|
432
|
+
if (o.positional.length > 1) throw new UsageError(SET_ARG_REFUSED);
|
|
433
|
+
checkName(o.positional[0]);
|
|
434
|
+
checkEnvName(o.positional[0], 'set');
|
|
435
|
+
if (!o.stdin) throw new UsageError('set needs --stdin; ' + SET_ARG_REFUSED);
|
|
436
|
+
} else if (action === 'get' || action === 'delete') {
|
|
437
|
+
if (o.positional.length !== 1) throw new UsageError('usage: jos secrets ' + action + ' <name>');
|
|
438
|
+
checkName(o.positional[0]);
|
|
439
|
+
} else if (action === 'exec') {
|
|
440
|
+
if (o.positional.length) throw new UsageError('usage: jos secrets exec --only A,B -- <command> [args]');
|
|
441
|
+
if (!o.only) throw new UsageError('exec needs --only A,B: the secrets the command receives are always named');
|
|
442
|
+
if (!o.command || o.command.length === 0) throw new UsageError('exec needs "-- <command> [args]"');
|
|
443
|
+
for (const n of o.only.split(',')) checkEnvName(n, 'exec --only');
|
|
444
|
+
} else if (o.positional.length) {
|
|
445
|
+
throw new UsageError('usage: jos secrets ' + action);
|
|
446
|
+
}
|
|
447
|
+
return backend;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
exports.execute = async (args, home, ioOverride) => {
|
|
451
|
+
const io = Object.assign(defaultIO(), ioOverride);
|
|
452
|
+
const out = ui.printer(io.stdout);
|
|
453
|
+
const err = ui.printer(io.stderr);
|
|
454
|
+
const ctx = {
|
|
455
|
+
io, C: out.C, out, err,
|
|
456
|
+
secretsDir: path.join(home, 'secrets'),
|
|
457
|
+
};
|
|
458
|
+
ctx.vaultPath = path.join(ctx.secretsDir, 'vault.json');
|
|
459
|
+
ctx.indexPath = path.join(ctx.secretsDir, 'keychain.json');
|
|
460
|
+
const o = parseArgs(args);
|
|
461
|
+
if (!o.action || o.help) { out.text(HELP(out.C)); return 0; }
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const backend = checkUsage(o, io);
|
|
465
|
+
if (o.action === 'fix-perms') return fixPerms(ctx);
|
|
466
|
+
const problems = permissionProblems(ctx.secretsDir, io);
|
|
467
|
+
if (problems.length) {
|
|
468
|
+
for (const p of problems) err.warn(`${p.path} ${p.why}`);
|
|
469
|
+
err.fail('jos secrets: refusing to read the secrets while their permissions are open: run `jos secrets fix-perms`');
|
|
470
|
+
return 1;
|
|
132
471
|
}
|
|
472
|
+
return backend === 'keychain' ? await withKeychain(o, ctx) : await withFile(o, ctx);
|
|
133
473
|
} catch (e) {
|
|
134
|
-
|
|
135
|
-
|
|
474
|
+
if (e instanceof UsageError || e instanceof VaultError) {
|
|
475
|
+
err.fail('jos secrets: ' + e.message);
|
|
476
|
+
return e instanceof VaultError ? 1 : (e.exitCode || 64);
|
|
477
|
+
}
|
|
478
|
+
if (typeof e.code === 'string' && FS_CODE.test(e.code)) {
|
|
479
|
+
// file system errors (ENOTDIR, EACCES, ...): report the path, not a stack
|
|
480
|
+
err.fail(`jos secrets: cannot use ${ctx.secretsDir}: ${e.code}`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
// A host bug. No message of a crypto or fs error carries a value, but the message is replaced anyway
|
|
484
|
+
// (unless JOS_DEBUG is set) so that no future error path can leak one through bin/jos.
|
|
485
|
+
if (io.env.JOS_DEBUG) throw e;
|
|
486
|
+
throw new Error('secrets: unexpected error (set JOS_DEBUG=1 for details)');
|
|
136
487
|
}
|
|
137
488
|
};
|
|
489
|
+
|
|
490
|
+
async function withFile(o, ctx) {
|
|
491
|
+
const { io, C, out, err, secretsDir, vaultPath } = ctx;
|
|
492
|
+
const text = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, 'utf8') : null;
|
|
493
|
+
const vault = text === null ? Object.create(null) : parseVault(text, vaultPath);
|
|
494
|
+
const names = Object.keys(vault);
|
|
495
|
+
const masterPath = path.join(secretsDir, '.master');
|
|
496
|
+
// A master key is created only by the first `set` of an empty vault: never next to existing entries.
|
|
497
|
+
const master = readMasterKey(secretsDir, o.action === 'set' && names.length === 0, io);
|
|
498
|
+
const needMaster = () => {
|
|
499
|
+
if (!master) throw new VaultError('master key is missing: ' + masterPath + ' (the entries of ' + vaultPath + ' cannot be decrypted without it)');
|
|
500
|
+
return master;
|
|
501
|
+
};
|
|
502
|
+
const name = o.positional[0];
|
|
503
|
+
const notFound = () => { err.warn(`Secret '${name}' not found`); return 1; };
|
|
504
|
+
|
|
505
|
+
switch (o.action) {
|
|
506
|
+
case 'set': {
|
|
507
|
+
const m = needMaster();
|
|
508
|
+
const value = await readSecretValue(io, name);
|
|
509
|
+
vault[name] = encryptV2(m, name, value);
|
|
510
|
+
const fp = fingerprint(m, value);
|
|
511
|
+
value.fill(0);
|
|
512
|
+
writeAtomic(vaultPath, serializeVault(vault));
|
|
513
|
+
err.ok(`Secret '${name}' saved`, `(aes-256-gcm, ${fp})`);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
case 'get': {
|
|
517
|
+
if (!(name in vault)) return notFound();
|
|
518
|
+
if (o.reveal) return await reveal(ctx, name, () => decryptEntry(needMaster(), name, vault[name]));
|
|
519
|
+
const m = needMaster();
|
|
520
|
+
const value = decryptEntry(m, name, vault[name]);
|
|
521
|
+
const fp = fingerprint(m, value);
|
|
522
|
+
value.fill(0);
|
|
523
|
+
out.line(`${name} exists · ${fp} · ` + (entryFormat(vault[name]) === 1 ? 'v1 aes-256-cbc (old format: run jos secrets migrate)' : 'v2 aes-256-gcm'));
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
case 'list': {
|
|
527
|
+
if (names.length === 0) { out.line(`${C.dim}No secrets stored${C.reset}`); return 0; }
|
|
528
|
+
const old = names.filter((k) => entryFormat(vault[k]) === 1);
|
|
529
|
+
out.line(`${C.cyan}Stored secrets (${names.length}):${C.reset}`);
|
|
530
|
+
for (const k of names) out.item(k + (entryFormat(vault[k]) === 1 ? ` ${C.yellow}[v1 aes-256-cbc, old format]${C.reset}` : ''));
|
|
531
|
+
if (old.length) out.warn(`${old.length} of ${names.length} in the old format (unauthenticated AES-256-CBC): run \`jos secrets migrate\``);
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
case 'delete': {
|
|
535
|
+
if (!(name in vault)) return notFound();
|
|
536
|
+
delete vault[name];
|
|
537
|
+
writeAtomic(vaultPath, serializeVault(vault));
|
|
538
|
+
err.ok(`Secret '${name}' deleted`);
|
|
539
|
+
return 0;
|
|
540
|
+
}
|
|
541
|
+
case 'exec': {
|
|
542
|
+
const want = Array.from(new Set(o.only.split(',')));
|
|
543
|
+
const missing = want.find((n) => !(n in vault));
|
|
544
|
+
if (missing !== undefined) { err.warn(`Secret '${missing}' not found; the command was not started`); return 1; }
|
|
545
|
+
const add = Object.create(null);
|
|
546
|
+
for (const n of want) add[n] = envValue(n, decryptEntry(needMaster(), n, vault[n]));
|
|
547
|
+
return await execChild(ctx, o.command, add);
|
|
548
|
+
}
|
|
549
|
+
default: // migrate (checkUsage admits nothing else here)
|
|
550
|
+
return migrate(ctx, vault, text, names.length ? needMaster() : null);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function envValue(name, buf) {
|
|
555
|
+
const nul = buf.includes(0);
|
|
556
|
+
const s = buf.toString('utf8');
|
|
557
|
+
buf.fill(0);
|
|
558
|
+
if (nul) throw new VaultError('secret ' + JSON.stringify(name) + ' contains a NUL byte and cannot be passed in an environment variable');
|
|
559
|
+
return s;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function execChild(ctx, command, add) {
|
|
563
|
+
const { io, err } = ctx;
|
|
564
|
+
return new Promise((resolve) => {
|
|
565
|
+
const env = Object.assign(Object.create(null), io.env, add); // null prototype: "__proto__" is a name too
|
|
566
|
+
const cannot = (code) => err.fail(`jos secrets exec: cannot start ${JSON.stringify(command[0])}: ${code}`);
|
|
567
|
+
let child;
|
|
568
|
+
try {
|
|
569
|
+
child = spawn(command[0], command.slice(1), { stdio: io.childStdio, env, shell: false });
|
|
570
|
+
} catch (e) { // invalid arguments (a NUL byte in the command, ...)
|
|
571
|
+
cannot(e.code);
|
|
572
|
+
resolve(126);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const forward = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
576
|
+
const relay = (s) => { child.kill(s); };
|
|
577
|
+
for (const s of forward) process.on(s, relay);
|
|
578
|
+
const done = (code) => { for (const s of forward) process.removeListener(s, relay); resolve(code); };
|
|
579
|
+
child.on('error', (e) => { cannot(e.code); done(e.code === 'ENOENT' ? 127 : 126); });
|
|
580
|
+
child.on('exit', (code, signal) => done(signal ? 128 + os.constants.signals[signal] : code));
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function reveal(ctx, name, decrypt) {
|
|
585
|
+
const { io, err } = ctx;
|
|
586
|
+
if (!io.stdout.isTTY || !io.stdin.isTTY) {
|
|
587
|
+
err.fail('jos secrets: --reveal prints only to a terminal (stdin and stdout must both be one).');
|
|
588
|
+
err.hint('To hand the value to a program: jos secrets exec --only ' + (ENV_NAME.test(name) ? name : 'NAME') + ' -- <command>');
|
|
589
|
+
return 1;
|
|
590
|
+
}
|
|
591
|
+
const typed = await readTerminalLine(io, 'Type the name ' + JSON.stringify(name) + ' to print its value on this terminal: ', false);
|
|
592
|
+
if (typed !== name) { err.warn('Not confirmed; nothing printed'); return 1; }
|
|
593
|
+
const value = await decrypt();
|
|
594
|
+
ui.data(io.stdout, Buffer.concat([value, Buffer.from('\n')])); // the value itself: data, not a message
|
|
595
|
+
value.fill(0);
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function fixPerms(ctx) {
|
|
600
|
+
const { io, out, err, secretsDir } = ctx;
|
|
601
|
+
const problems = permissionProblems(secretsDir, io);
|
|
602
|
+
if (problems.length === 0) { out.ok('jos secrets: permissions are already 0700/0600; nothing to fix'); return 0; }
|
|
603
|
+
let bad = 0;
|
|
604
|
+
for (const p of problems) {
|
|
605
|
+
if (!p.fixable) { bad++; err.fail(`${p.path} ${p.why}; fix it by hand`); continue; }
|
|
606
|
+
fs.chmodSync(p.path, p.want);
|
|
607
|
+
out.ok(`${p.path}: ${modeText(p.want)}`);
|
|
608
|
+
}
|
|
609
|
+
return bad ? 1 : 0;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function migrate(ctx, vault, text, master) {
|
|
613
|
+
const { io, out, vaultPath } = ctx;
|
|
614
|
+
const old = Object.keys(vault).filter((k) => entryFormat(vault[k]) === 1);
|
|
615
|
+
if (old.length === 0) { out.ok('jos secrets: nothing to migrate (every entry is already v2)'); return 0; }
|
|
616
|
+
|
|
617
|
+
const plain = Object.create(null);
|
|
618
|
+
try {
|
|
619
|
+
// 1. decrypt every old entry first: if one fails, nothing is written
|
|
620
|
+
for (const k of old) plain[k] = decryptEntry(master, k, vault[k]);
|
|
621
|
+
|
|
622
|
+
// 2. a copy of the exact bytes, created exclusively with mode 0600, read back and compared
|
|
623
|
+
const stamp = io.now().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
624
|
+
let backup = vaultPath + '.bak-' + stamp + '-v1';
|
|
625
|
+
for (let i = 1; fs.existsSync(backup); i++) backup = vaultPath + '.bak-' + stamp + '-v1-' + i;
|
|
626
|
+
const fd = fs.openSync(backup, 'wx', 0o600);
|
|
627
|
+
try { fs.writeSync(fd, Buffer.from(text, 'utf8')); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
628
|
+
if (io.readBack(backup) !== text) throw new VaultError('backup ' + backup + ' could not be verified; the vault was not changed');
|
|
629
|
+
|
|
630
|
+
// 3. write the v2 vault, read it back from disk, decrypt every migrated value and compare; any
|
|
631
|
+
// difference restores the original bytes
|
|
632
|
+
const next = Object.create(null);
|
|
633
|
+
for (const k of Object.keys(vault)) next[k] = entryFormat(vault[k]) === 1 ? encryptV2(master, k, plain[k]) : vault[k];
|
|
634
|
+
writeAtomic(vaultPath, serializeVault(next));
|
|
635
|
+
let ok;
|
|
636
|
+
try {
|
|
637
|
+
const disk = parseVault(io.readBack(vaultPath), vaultPath);
|
|
638
|
+
ok = Object.keys(disk).length === Object.keys(vault).length && old.every((k) => {
|
|
639
|
+
if (entryFormat(disk[k]) !== 2) return false;
|
|
640
|
+
const got = decryptEntry(master, k, disk[k]);
|
|
641
|
+
const same = sameBytes(got, plain[k]);
|
|
642
|
+
got.fill(0);
|
|
643
|
+
return same;
|
|
644
|
+
});
|
|
645
|
+
} catch (e) { ok = false; }
|
|
646
|
+
if (!ok) {
|
|
647
|
+
writeAtomic(vaultPath, text);
|
|
648
|
+
throw new VaultError('migration could not be verified on disk; the original vault was restored (backup: ' + backup + ')');
|
|
649
|
+
}
|
|
650
|
+
out.ok(`migrated ${old.length} entr${old.length === 1 ? 'y' : 'ies'} to v2 (aes-256-gcm); every value verified`);
|
|
651
|
+
out.hint('backup of the old vault: ' + backup + ' (0600)');
|
|
652
|
+
out.hint('it holds the old unauthenticated ciphertexts under the same master key: delete it once you have checked the vault');
|
|
653
|
+
return 0;
|
|
654
|
+
} finally {
|
|
655
|
+
for (const k of Object.keys(plain)) plain[k].fill(0);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function withKeychain(o, ctx) {
|
|
660
|
+
const { io, C, out, err, secretsDir, indexPath } = ctx;
|
|
661
|
+
const kc = keychain(io);
|
|
662
|
+
const names = readIndex(indexPath);
|
|
663
|
+
const name = o.positional[0];
|
|
664
|
+
const notFound = (n, tail) => { err.warn(`Secret '${n}' not found${tail}`); return 1; };
|
|
665
|
+
const fetch = async (n) => {
|
|
666
|
+
const v = await kc.get(n);
|
|
667
|
+
if (v === null) throw new VaultError('secret ' + JSON.stringify(n) + ' is in ' + indexPath + ' but not in the keychain');
|
|
668
|
+
return v;
|
|
669
|
+
};
|
|
670
|
+
switch (o.action) {
|
|
671
|
+
case 'set': {
|
|
672
|
+
const value = await readSecretValue(io, name);
|
|
673
|
+
try {
|
|
674
|
+
if (value.length > KEYCHAIN_MAX || !value.every((b) => b >= 0x20 && b <= 0x7e)) {
|
|
675
|
+
throw new UsageError('the keychain backend stores printable ASCII values of at most ' + KEYCHAIN_MAX + ' characters; use the file backend for this one');
|
|
676
|
+
}
|
|
677
|
+
await kc.set(name, value);
|
|
678
|
+
writeIndex(secretsDir, indexPath, names.concat([name]));
|
|
679
|
+
err.ok(`Secret '${name}' saved`, `(macOS keychain, service "${KEYCHAIN_SERVICE}")`);
|
|
680
|
+
return 0;
|
|
681
|
+
} finally { value.fill(0); }
|
|
682
|
+
}
|
|
683
|
+
case 'get': {
|
|
684
|
+
if (!names.includes(name)) return notFound(name, '');
|
|
685
|
+
const v = await fetch(name);
|
|
686
|
+
if (o.reveal) {
|
|
687
|
+
try { return await reveal(ctx, name, () => Buffer.from(v)); } finally { v.fill(0); }
|
|
688
|
+
}
|
|
689
|
+
v.fill(0); // read to prove the item exists; no fingerprint here (no master key to key it: SECURITY-SECRETS.md)
|
|
690
|
+
out.line(`${name} exists · macOS keychain`);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
case 'list': {
|
|
694
|
+
if (names.length === 0) { out.line(`${C.dim}No secrets stored${C.reset}`); return 0; }
|
|
695
|
+
out.line(`${C.cyan}Stored secrets (${names.length}, macOS keychain):${C.reset}`);
|
|
696
|
+
for (const k of names) out.item(k);
|
|
697
|
+
return 0;
|
|
698
|
+
}
|
|
699
|
+
case 'delete': {
|
|
700
|
+
const was = await kc.del(name);
|
|
701
|
+
if (!was && !names.includes(name)) return notFound(name, '');
|
|
702
|
+
writeIndex(secretsDir, indexPath, names.filter((n) => n !== name));
|
|
703
|
+
err.ok(`Secret '${name}' deleted`);
|
|
704
|
+
return 0;
|
|
705
|
+
}
|
|
706
|
+
case 'exec': {
|
|
707
|
+
const want = Array.from(new Set(o.only.split(',')));
|
|
708
|
+
const missing = want.find((n) => !names.includes(n));
|
|
709
|
+
if (missing !== undefined) return notFound(missing, '; the command was not started');
|
|
710
|
+
const add = Object.create(null);
|
|
711
|
+
for (const n of want) add[n] = envValue(n, await fetch(n));
|
|
712
|
+
return await execChild(ctx, o.command, add);
|
|
713
|
+
}
|
|
714
|
+
default: // migrate
|
|
715
|
+
throw new UsageError('migrate rewrites the file vault; run it without JOS_SECRETS_BACKEND=keychain');
|
|
716
|
+
}
|
|
717
|
+
}
|