@sharedenv/cli 1.1.1 → 2.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/README.md +121 -56
- package/bin/sharedenv.js +708 -219
- package/lib/api.js +20 -0
- package/lib/auth.js +6 -0
- package/lib/crypto/kt.js +127 -2
- package/lib/crypto/safeKey.js +13 -0
- package/lib/crypto.js +3 -2
- package/lib/link.js +46 -0
- package/lib/safes.js +188 -42
- package/lib/shares.js +42 -15
- package/lib/sharing.js +112 -0
- package/lib/transparency.js +116 -32
- package/package.json +1 -1
package/bin/sharedenv.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// sharedenv —
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// sharedenv — your zero-knowledge vaults in the terminal, decrypted locally. The master
|
|
3
|
+
// password and the private key never leave this process and never touch disk; only the
|
|
4
|
+
// session cookies and the public keys you vouched for are kept (~/.sharedenv).
|
|
5
5
|
//
|
|
6
6
|
// This file is the terminal: arguments, questions, output, exit codes. What happens to
|
|
7
7
|
// keys and secrets lives in ../lib.
|
|
@@ -12,21 +12,54 @@ import { Writable } from 'stream';
|
|
|
12
12
|
import { parseArgs } from 'util';
|
|
13
13
|
import { fingerprintOf } from '../lib/crypto.js';
|
|
14
14
|
import { apiBase, createClient, expectOk, listAll } from '../lib/api.js';
|
|
15
|
-
import { login, publicKeyOf, unlockAccount } from '../lib/auth.js';
|
|
16
|
-
import {
|
|
15
|
+
import { login, publicKeyOf, unlockAccount, whoAmI } from '../lib/auth.js';
|
|
16
|
+
import {
|
|
17
|
+
acknowledgeAlerts,
|
|
18
|
+
heldKeys,
|
|
19
|
+
keptAlerts,
|
|
20
|
+
syncLog,
|
|
21
|
+
watchWarnings,
|
|
22
|
+
} from '../lib/transparency.js';
|
|
17
23
|
import { childEnv, splitUnsafeEnv } from '../lib/env.js';
|
|
18
24
|
import { FORMATS } from '../lib/format.js';
|
|
25
|
+
import { LINK_FILE, findLink, writeLink } from '../lib/link.js';
|
|
19
26
|
import { runChild } from '../lib/run.js';
|
|
20
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
LEVELS,
|
|
29
|
+
SECRET_NAME,
|
|
30
|
+
accessOf,
|
|
31
|
+
createVault,
|
|
32
|
+
describeVault,
|
|
33
|
+
levelName,
|
|
34
|
+
listSecretRows,
|
|
35
|
+
listVaults,
|
|
36
|
+
openVault,
|
|
37
|
+
pullSecrets,
|
|
38
|
+
readSecret,
|
|
39
|
+
readVault,
|
|
40
|
+
resolveSafe,
|
|
41
|
+
setSecrets,
|
|
42
|
+
unsetSecrets,
|
|
43
|
+
} from '../lib/safes.js';
|
|
21
44
|
import {
|
|
22
45
|
acceptShare,
|
|
23
46
|
acceptTeamShare,
|
|
24
47
|
declineShare,
|
|
25
48
|
declineTeamShare,
|
|
26
49
|
listPendingShares,
|
|
50
|
+
personLabel,
|
|
27
51
|
resolvePendingShare,
|
|
28
52
|
senderLabel,
|
|
29
53
|
} from '../lib/shares.js';
|
|
54
|
+
import {
|
|
55
|
+
assertPersonLogged,
|
|
56
|
+
listTeams,
|
|
57
|
+
lookupPerson,
|
|
58
|
+
resolveTeam,
|
|
59
|
+
shareWithPerson,
|
|
60
|
+
shareWithTeam,
|
|
61
|
+
teamLabel,
|
|
62
|
+
} from '../lib/sharing.js';
|
|
30
63
|
import { createStore, writePrivate } from '../lib/store.js';
|
|
31
64
|
import { pinKey, pinStatus, senderVerdict, teamKeyVerdict } from '../lib/trust.js';
|
|
32
65
|
|
|
@@ -35,13 +68,12 @@ const { version: VERSION } = JSON.parse(
|
|
|
35
68
|
);
|
|
36
69
|
const DEFAULT_API = 'https://api.sharedenv.com';
|
|
37
70
|
const USER_AGENT = `sharedenv-cli/${VERSION}`;
|
|
38
|
-
const LEVEL = { 10: 'read', 50: 'write', 90: 'admin' };
|
|
39
71
|
|
|
40
72
|
const store = createStore();
|
|
41
73
|
|
|
42
74
|
// ── Terminal ────────────────────────────────────────────────────────────────────────
|
|
43
75
|
// Questions and notices go to stderr: stdout carries data only, so
|
|
44
|
-
// `sharedenv pull
|
|
76
|
+
// `sharedenv pull > .env` writes the secrets and nothing else.
|
|
45
77
|
|
|
46
78
|
const say = (text) => process.stdout.write(`${text}\n`);
|
|
47
79
|
const note = (text) => process.stderr.write(`${text}\n`);
|
|
@@ -95,6 +127,13 @@ async function masterPassword() {
|
|
|
95
127
|
return password;
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
// Everything piped in, as text.
|
|
131
|
+
async function readStdin() {
|
|
132
|
+
const chunks = [];
|
|
133
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
134
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
// ── Session ─────────────────────────────────────────────────────────────────────────
|
|
99
138
|
|
|
100
139
|
let connected = null; // { client, session } of this run, to save renewed cookies
|
|
@@ -122,28 +161,159 @@ function saveSession() {
|
|
|
122
161
|
connected.session.cookies = cookies;
|
|
123
162
|
}
|
|
124
163
|
|
|
125
|
-
// Each time the vault opens:
|
|
126
|
-
//
|
|
127
|
-
|
|
164
|
+
// Each time the vault opens: are the keys the public log holds for us - our own, and
|
|
165
|
+
// those of the teams whose key we hold - still the ones we hold? A warning, never a
|
|
166
|
+
// refusal: reading your own secrets seals nothing to anyone. What it found is kept
|
|
167
|
+
// (~/.sharedenv/log.json) and said again at every unlock until `sharedenv log --ack`,
|
|
168
|
+
// even when the log cannot be read this time.
|
|
169
|
+
// → the watch, for every later read of the log in this run (a read that watches less
|
|
170
|
+
// would let entries go by unchecked).
|
|
171
|
+
async function watchKeys(client, account) {
|
|
172
|
+
const { watch, labels } = await heldKeys(client, account);
|
|
173
|
+
let report;
|
|
128
174
|
try {
|
|
129
|
-
|
|
130
|
-
store,
|
|
131
|
-
watch: { [`user:${userId}`]: publicKeyOf(privateKey) },
|
|
132
|
-
});
|
|
133
|
-
for (const line of watchWarnings(report)) note(line);
|
|
175
|
+
report = await syncLog(client, { store, watch });
|
|
134
176
|
} catch (err) {
|
|
135
177
|
note(`WARNING: ${err.message}.`);
|
|
178
|
+
report = { alerts: keptAlerts(store) };
|
|
136
179
|
}
|
|
180
|
+
for (const line of watchWarnings(report, labels)) note(line);
|
|
181
|
+
return watch;
|
|
137
182
|
}
|
|
138
183
|
|
|
139
|
-
async function unlock() {
|
|
140
|
-
const client = connect();
|
|
184
|
+
async function unlock(client) {
|
|
141
185
|
const account = await unlockAccount(client, await masterPassword());
|
|
142
|
-
await
|
|
143
|
-
return {
|
|
186
|
+
const watch = await watchKeys(client, account);
|
|
187
|
+
return { privateKey: account.privateKey, watch };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Which vault ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
// `-v <name or id>`, else the vault this project is linked to (`sharedenv link`). A name
|
|
192
|
+
// several of your vaults answer to is refused with the candidates (lib/safes.js).
|
|
193
|
+
|
|
194
|
+
const VAULT_OPTION = { vault: { type: 'string', short: 'v' } };
|
|
195
|
+
|
|
196
|
+
async function targetVault(client, values) {
|
|
197
|
+
if (values.vault) return resolveSafe(client, values.vault);
|
|
198
|
+
const link = findLink();
|
|
199
|
+
if (link) return link.vault;
|
|
200
|
+
throw new UsageError('which vault? pass -v <vault>, or run `sharedenv link` in this project');
|
|
144
201
|
}
|
|
145
202
|
|
|
146
|
-
//
|
|
203
|
+
// Said by every command that works on a vault: a link you did not write yourself (a
|
|
204
|
+
// cloned repository) may name another vault than the one you expect.
|
|
205
|
+
const using = (safe) => note(`sharedenv: vault "${safe.name}" (${safe._id})`);
|
|
206
|
+
|
|
207
|
+
// The link of this directory, for display only: a broken one is not an error here.
|
|
208
|
+
function linkedHere() {
|
|
209
|
+
try {
|
|
210
|
+
return findLink()?.vault ?? null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const byName = (a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
|
|
217
|
+
|
|
218
|
+
// ── Trust ───────────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
// Is `publicKey` the key of `who`, as this machine knows them? A key seen before passes;
|
|
221
|
+
// a new one is shown and asked about; a CHANGED one is never taken without its own yes
|
|
222
|
+
// (or --accept-key-change). --fingerprint settles it for a script. A yes pins the key in
|
|
223
|
+
// `pins` — the caller saves them once everything is agreed.
|
|
224
|
+
async function vouchFor(pins, { subject, publicKey, who }, values, { question, changed }) {
|
|
225
|
+
const status = pinStatus(pins, subject, publicKey);
|
|
226
|
+
const fingerprint = fingerprintOf(publicKey);
|
|
227
|
+
note(` key fingerprint ${fingerprint}${status === 'match' ? ' (known on this machine)' : ''}`);
|
|
228
|
+
if (status === 'changed') {
|
|
229
|
+
note(
|
|
230
|
+
` WARNING: this machine knew ANOTHER key for ${who} ` +
|
|
231
|
+
`(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
|
|
232
|
+
);
|
|
233
|
+
} else if (status === 'new') {
|
|
234
|
+
note(' First time this machine sees this key: compare it with them.');
|
|
235
|
+
}
|
|
236
|
+
const verdict = senderVerdict(status, fingerprint, {
|
|
237
|
+
expected: values.fingerprint,
|
|
238
|
+
acceptKeyChange: values['accept-key-change'],
|
|
239
|
+
yes: values.yes,
|
|
240
|
+
});
|
|
241
|
+
if (verdict === 'refuse') {
|
|
242
|
+
note(' That is not the --fingerprint you gave.');
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
if (
|
|
246
|
+
verdict === 'ask' &&
|
|
247
|
+
!(await confirm(
|
|
248
|
+
status === 'changed' ? `${changed} Confirm it with them first. [y/N] ` : `${question} [y/N] `,
|
|
249
|
+
status === 'changed'
|
|
250
|
+
? 'pass --fingerprint <fingerprint>, checked with them'
|
|
251
|
+
: 'pass --yes, or --fingerprint <fingerprint> checked with them'
|
|
252
|
+
))
|
|
253
|
+
) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
pinKey(pins, subject, publicKey);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A team share sealed to the team's PREVIOUS key moves to its current key on accept.
|
|
261
|
+
// Unless we sealed that key ourselves, it must be the one this machine knows for the
|
|
262
|
+
// team, or its fingerprint is confirmed — by a human, or by --team-fingerprint.
|
|
263
|
+
async function confirmTeamKey(pins, { share, teamPublicKey }, values) {
|
|
264
|
+
const subject = `team:${share.teamId}`;
|
|
265
|
+
const status = pinStatus(pins, subject, teamPublicKey);
|
|
266
|
+
const fingerprint = fingerprintOf(teamPublicKey);
|
|
267
|
+
const verdict = teamKeyVerdict(status, fingerprint, { expected: values['team-fingerprint'] });
|
|
268
|
+
if (verdict !== 'accept') {
|
|
269
|
+
note(
|
|
270
|
+
` The share moves to the current key of team "${share.team?.name || share.teamId}", ` +
|
|
271
|
+
`fingerprint ${fingerprint}` +
|
|
272
|
+
(status === 'changed'
|
|
273
|
+
? `; this machine knew ANOTHER key for it (${fingerprintOf(pins[subject])}).`
|
|
274
|
+
: ', never seen on this machine.')
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (verdict === 'refuse') {
|
|
278
|
+
note(' That is not the --team-fingerprint you gave.');
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
verdict === 'ask' &&
|
|
283
|
+
!(await confirm(
|
|
284
|
+
'Trust this team key? Confirm it with a lead of the team first. [y/N] ',
|
|
285
|
+
'pass --team-fingerprint <fingerprint>, checked with a lead of the team'
|
|
286
|
+
))
|
|
287
|
+
) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
pinKey(pins, subject, teamPublicKey);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The human step of `accept`: who sealed the share, with what key, as this machine
|
|
295
|
+
// knows it — then yes or no.
|
|
296
|
+
function confirmShare(values) {
|
|
297
|
+
return async (opened) => {
|
|
298
|
+
const { share, senderKey } = opened;
|
|
299
|
+
const pins = store.readPins();
|
|
300
|
+
note(`"${share.safe?.name}" from ${senderLabel(share)}`);
|
|
301
|
+
const sender = {
|
|
302
|
+
subject: `user:${share.from?._id}`,
|
|
303
|
+
publicKey: senderKey,
|
|
304
|
+
who: senderLabel(share),
|
|
305
|
+
};
|
|
306
|
+
const asked = { question: 'Accept this share?', changed: 'Accept their NEW key?' };
|
|
307
|
+
if (!(await vouchFor(pins, sender, values, asked))) return false;
|
|
308
|
+
if (opened.teamKeyVouched === false && !(await confirmTeamKey(pins, opened, values))) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
store.writePins(pins);
|
|
312
|
+
return true;
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Commands: session ───────────────────────────────────────────────────────────────
|
|
147
317
|
|
|
148
318
|
async function cmdLogin({ values }) {
|
|
149
319
|
const previous = store.readSession();
|
|
@@ -176,68 +346,57 @@ async function cmdLogin({ values }) {
|
|
|
176
346
|
});
|
|
177
347
|
store.writeSession({ baseUrl, cookies: client.cookies() });
|
|
178
348
|
note(`Signed in to ${baseUrl}.`);
|
|
179
|
-
await
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function cmdPull({ values, positionals: [ref] }) {
|
|
183
|
-
const render = FORMATS[values.format];
|
|
184
|
-
if (!render) throw new UsageError(`unknown format "${values.format}": dotenv or json`);
|
|
185
|
-
const { client, privateKey } = await unlock();
|
|
186
|
-
const secrets = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
|
|
187
|
-
const body = render(secrets);
|
|
188
|
-
const text = body ? `${body}\n` : '';
|
|
189
|
-
if (values.out) {
|
|
190
|
-
writePrivate(values.out, text);
|
|
191
|
-
note(`Wrote ${Object.keys(secrets).length} secrets to ${values.out}.`);
|
|
192
|
-
} else {
|
|
193
|
-
process.stdout.write(text);
|
|
194
|
-
}
|
|
349
|
+
await watchKeys(client, account);
|
|
195
350
|
}
|
|
196
351
|
|
|
197
|
-
async function
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
allowUnsafe: values['allow-unsafe-env'],
|
|
203
|
-
override: values['override-env'],
|
|
204
|
-
});
|
|
205
|
-
if (blocked.length) {
|
|
206
|
-
note(
|
|
207
|
-
`sharedenv: not injecting ${blocked.sort().join(', ')} — these variables can make the ` +
|
|
208
|
-
'command run code or redirect its traffic (--allow-unsafe-env to inject them anyway).'
|
|
209
|
-
);
|
|
352
|
+
async function cmdLogout() {
|
|
353
|
+
const session = store.readSession();
|
|
354
|
+
if (!session?.baseUrl) {
|
|
355
|
+
note('Not signed in.');
|
|
356
|
+
return;
|
|
210
357
|
}
|
|
211
|
-
|
|
358
|
+
try {
|
|
359
|
+
const client = createClient({
|
|
360
|
+
baseUrl: apiBase(session.baseUrl),
|
|
361
|
+
cookies: session.cookies,
|
|
362
|
+
userAgent: USER_AGENT,
|
|
363
|
+
});
|
|
364
|
+
const res = await client.call('POST', '/auth/sign-out');
|
|
365
|
+
if (res.status !== 401) expectOk(res, 'sign-out refused'); // 401: already over
|
|
366
|
+
note('Signed out.');
|
|
367
|
+
} catch (err) {
|
|
212
368
|
note(
|
|
213
|
-
`sharedenv: not
|
|
214
|
-
'
|
|
369
|
+
`sharedenv: the server did not close the session (${err.message}) — it stays valid ` +
|
|
370
|
+
'there until it expires. Forgotten on this machine.'
|
|
215
371
|
);
|
|
372
|
+
process.exitCode = 1;
|
|
216
373
|
}
|
|
217
|
-
|
|
218
|
-
process.exitCode = await runChild(cmd, args, childEnv(process.env, safe));
|
|
374
|
+
store.clearSession();
|
|
219
375
|
}
|
|
220
376
|
|
|
221
|
-
|
|
377
|
+
// ── Commands: vaults ────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
// Names only: no password needed (vault and key names are stored in clear).
|
|
380
|
+
async function cmdVaults() {
|
|
222
381
|
const client = connect();
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const safes = (await listAll(client, '/safes', 'safes')).sort(byName);
|
|
382
|
+
const vaults = (await listVaults(client)).sort(byName);
|
|
383
|
+
const me = await whoAmI(client);
|
|
226
384
|
const { direct, team } = await listPendingShares(client);
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
lines.push(` ${
|
|
385
|
+
const linked = linkedHere();
|
|
386
|
+
const lines = [vaults.length ? 'Vaults:' : 'No vaults yet: sharedenv create <name>.'];
|
|
387
|
+
for (const row of vaults) {
|
|
388
|
+
lines.push(` ${describeVault(row, me)}${String(row._id) === linked ? ' (linked here)' : ''}`);
|
|
231
389
|
}
|
|
390
|
+
const pendingName = (p) => ({ name: p.safe?.name });
|
|
232
391
|
if (direct.length || team.length) {
|
|
233
392
|
lines.push('', 'Waiting for your answer (sharedenv accept|decline <vault>):');
|
|
234
|
-
for (const p of direct.sort(byName)) {
|
|
393
|
+
for (const p of direct.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
|
|
235
394
|
lines.push(
|
|
236
|
-
` ${p.safe?.name} ${p.safeId} ${
|
|
395
|
+
` ${p.safe?.name} ${p.safeId} ${levelName(p.level)} from ${senderLabel(p)}` +
|
|
237
396
|
` key ${p.fingerprint || 'none on file'}`
|
|
238
397
|
);
|
|
239
398
|
}
|
|
240
|
-
for (const p of team.sort(byName)) {
|
|
399
|
+
for (const p of team.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
|
|
241
400
|
lines.push(
|
|
242
401
|
` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
|
|
243
402
|
` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
|
|
@@ -248,144 +407,344 @@ async function cmdList() {
|
|
|
248
407
|
say(lines.join('\n'));
|
|
249
408
|
}
|
|
250
409
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
410
|
+
async function cmdCreate({ positionals: [name] }) {
|
|
411
|
+
const client = connect();
|
|
412
|
+
const { privateKey } = await unlock(client);
|
|
413
|
+
const safe = await createVault(client, privateKey, name);
|
|
414
|
+
note(`Created vault "${safe.name}". Use it in a project: sharedenv link ${safe._id}`);
|
|
415
|
+
say(String(safe._id));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// No argument: the vaults are listed and you pick one.
|
|
419
|
+
async function cmdLink({ positionals: [ref] }) {
|
|
420
|
+
const client = connect();
|
|
421
|
+
const safeId = ref ? await resolveSafe(client, ref) : await pickVault(client);
|
|
422
|
+
const { safe } = await readVault(client, safeId); // you can open it, and its name
|
|
423
|
+
const file = writeLink(process.cwd(), safe._id);
|
|
424
|
+
note(
|
|
425
|
+
`Linked to vault "${safe.name}" (${safe._id}). ${file} holds its id and no secret: ` +
|
|
426
|
+
'commit it, and your team works on the same vault.'
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function pickVault(client) {
|
|
431
|
+
const vaults = (await listVaults(client)).sort(byName);
|
|
432
|
+
if (vaults.length === 0) throw new Error('no vault yet: sharedenv create <name>');
|
|
433
|
+
if (!process.stdin.isTTY) {
|
|
434
|
+
throw new UsageError('no terminal to choose in: name the vault, sharedenv link <vault>');
|
|
435
|
+
}
|
|
436
|
+
const me = await whoAmI(client);
|
|
437
|
+
vaults.forEach((row, i) => note(` ${i + 1}. ${describeVault(row, me)}`));
|
|
438
|
+
const n = Number(await ask(`Vault to link (1-${vaults.length}): `));
|
|
439
|
+
if (!Number.isInteger(n) || n < 1 || n > vaults.length) throw new UsageError('no vault chosen');
|
|
440
|
+
return String(vaults[n - 1]._id);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Names only, like `vaults`: no password needed.
|
|
444
|
+
async function cmdShow({ values }) {
|
|
445
|
+
const client = connect();
|
|
446
|
+
const safeId = await targetVault(client, values);
|
|
447
|
+
const vault = await readVault(client, safeId);
|
|
448
|
+
const { safe } = vault;
|
|
449
|
+
const me = await whoAmI(client);
|
|
450
|
+
const members = await listAll(client, `/members/${safeId}`, 'members');
|
|
451
|
+
const keys = (await listSecretRows(client, safeId)).map((r) => r.key).sort();
|
|
452
|
+
|
|
453
|
+
// Admins see every team the vault is shared with; others, the teams they reach it by.
|
|
454
|
+
const teams = vault.grants
|
|
455
|
+
? vault.grants.map((g) => ({
|
|
456
|
+
label: g.teamName || String(g.teamId),
|
|
457
|
+
level: g.level,
|
|
458
|
+
pending: g.status === 'pending',
|
|
459
|
+
}))
|
|
460
|
+
: (safe.teams || []).map((t) => ({
|
|
461
|
+
label: teamLabel(t),
|
|
462
|
+
level: (vault.teamAccess || []).find((a) => String(a.teamId) === String(t._id))?.level,
|
|
463
|
+
}));
|
|
464
|
+
|
|
465
|
+
const owner = me && String(safe.ownerId) === me ? 'you' : personLabel(safe.owner);
|
|
466
|
+
const waiting = ' (has not accepted yet)';
|
|
467
|
+
const lines = [
|
|
468
|
+
`${safe.name} ${safe._id}`,
|
|
469
|
+
`Owner: ${owner} Your access: ${levelName(accessOf(vault))}`,
|
|
470
|
+
'',
|
|
471
|
+
'Members:',
|
|
472
|
+
...members.map(
|
|
473
|
+
(m) =>
|
|
474
|
+
` ${String(m.userId) === me ? 'you' : personLabel(m.user)} ${levelName(m.level)}` +
|
|
475
|
+
(m.status === 'pending' ? waiting : '')
|
|
476
|
+
),
|
|
477
|
+
...(teams.length
|
|
478
|
+
? [
|
|
479
|
+
'Teams:',
|
|
480
|
+
...teams.map((t) => ` ${t.label} ${levelName(t.level)}${t.pending ? waiting : ''}`),
|
|
481
|
+
]
|
|
482
|
+
: []),
|
|
483
|
+
keys.length ? `Keys (${keys.length}):` : 'No keys yet: sharedenv set <KEY>=<value>.',
|
|
484
|
+
...keys.map((key) => ` ${key}`),
|
|
485
|
+
];
|
|
486
|
+
say(lines.join('\n'));
|
|
487
|
+
if (safe.pendingRotation) {
|
|
260
488
|
note(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
(status === 'changed'
|
|
264
|
-
? `; this machine knew ANOTHER key for it (${fingerprintOf(pins[subject])}).`
|
|
265
|
-
: ', never seen on this machine.')
|
|
489
|
+
'This vault must change its key (someone lost access to it): until an admin opens it ' +
|
|
490
|
+
'in the web app, nothing new can be written to it.'
|
|
266
491
|
);
|
|
267
492
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── Commands: keys ──────────────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
async function cmdGet({ values, positionals: [key] }) {
|
|
498
|
+
const client = connect();
|
|
499
|
+
const safeId = await targetVault(client, values);
|
|
500
|
+
const { privateKey } = await unlock(client);
|
|
501
|
+
const { safe, value } = await readSecret(client, privateKey, safeId, key);
|
|
502
|
+
using(safe);
|
|
503
|
+
// Nothing added to the value in a pipe or a file; a newline for the terminal's prompt.
|
|
504
|
+
process.stdout.write(process.stdout.isTTY ? `${value}\n` : value);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// KEY=value pairs, and at most one KEY alone, whose value is asked (hidden) or read
|
|
508
|
+
// from stdin — so that it lands neither in the shell's history nor in `ps`.
|
|
509
|
+
function parseAssignments(args) {
|
|
510
|
+
const entries = [];
|
|
511
|
+
let bare = null;
|
|
512
|
+
for (const arg of args) {
|
|
513
|
+
const eq = arg.indexOf('=');
|
|
514
|
+
const key = eq < 0 ? arg : arg.slice(0, eq);
|
|
515
|
+
if (!SECRET_NAME.test(key)) {
|
|
516
|
+
throw new UsageError(`"${key}" is not a key name: letters, digits, _ and - only`);
|
|
517
|
+
}
|
|
518
|
+
if (entries.some(([k]) => k === key) || bare === key) {
|
|
519
|
+
throw new UsageError(`"${key}" is given twice`);
|
|
520
|
+
}
|
|
521
|
+
if (eq >= 0) entries.push([key, arg.slice(eq + 1)]);
|
|
522
|
+
else if (bare) throw new UsageError('one key without a value at most: its value is asked');
|
|
523
|
+
else bare = key;
|
|
271
524
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
return
|
|
525
|
+
return { entries, bare };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function valueOf(key) {
|
|
529
|
+
if (process.stdin.isTTY) {
|
|
530
|
+
const value = await ask(`Value of ${key}: `, { secret: true });
|
|
531
|
+
if (value === null || value === '') throw new UsageError(`no value for ${key}`);
|
|
532
|
+
return value;
|
|
280
533
|
}
|
|
281
|
-
|
|
282
|
-
|
|
534
|
+
// Piped: what came in, without the newline `echo` or a file ends with.
|
|
535
|
+
const value = (await readStdin()).replace(/\r?\n$/, '');
|
|
536
|
+
if (value === '') throw new UsageError(`no value for ${key} on stdin (an empty one: ${key}=)`);
|
|
537
|
+
return value;
|
|
283
538
|
}
|
|
284
539
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
540
|
+
async function cmdSet({ values, positionals }) {
|
|
541
|
+
const { entries, bare } = parseAssignments(positionals);
|
|
542
|
+
const client = connect();
|
|
543
|
+
const safeId = await targetVault(client, values);
|
|
544
|
+
const { privateKey } = await unlock(client);
|
|
545
|
+
if (bare) entries.push([bare, await valueOf(bare)]);
|
|
546
|
+
const { safe, added, replaced } = await setSecrets(client, privateKey, safeId, entries);
|
|
547
|
+
using(safe);
|
|
548
|
+
note(
|
|
549
|
+
[
|
|
550
|
+
...(added.length ? [`added ${added.join(', ')}`] : []),
|
|
551
|
+
...(replaced.length ? [`replaced ${replaced.join(', ')}`] : []),
|
|
552
|
+
].join('; ')
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function cmdUnset({ values, positionals: keys }) {
|
|
557
|
+
const client = connect();
|
|
558
|
+
const safeId = await targetVault(client, values);
|
|
559
|
+
const { safe } = await readVault(client, safeId);
|
|
560
|
+
using(safe);
|
|
561
|
+
await unsetSecrets(client, safeId, keys);
|
|
562
|
+
note(`removed ${keys.join(', ')}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function cmdRun({ values, command: [cmd, ...args] }) {
|
|
566
|
+
const client = connect();
|
|
567
|
+
const safeId = await targetVault(client, values);
|
|
568
|
+
const { privateKey } = await unlock(client);
|
|
569
|
+
const { safe: vault, secrets } = await pullSecrets(client, privateKey, safeId);
|
|
570
|
+
using(vault);
|
|
571
|
+
const { safe, blocked, kept } = splitUnsafeEnv(secrets, {
|
|
572
|
+
base: process.env,
|
|
573
|
+
allowUnsafe: values['allow-unsafe-env'],
|
|
574
|
+
override: values['override-env'],
|
|
575
|
+
});
|
|
576
|
+
if (blocked.length) {
|
|
295
577
|
note(
|
|
296
|
-
`
|
|
578
|
+
`sharedenv: not injecting ${blocked.sort().join(', ')} — these variables can make the ` +
|
|
579
|
+
'command run code or redirect its traffic (--allow-unsafe-env to inject them anyway).'
|
|
297
580
|
);
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
581
|
+
}
|
|
582
|
+
if (kept.length) {
|
|
583
|
+
note(
|
|
584
|
+
`sharedenv: not overriding ${kept.sort().join(', ')} — your environment already sets ` +
|
|
585
|
+
'them (--override-env to let the vault replace them).'
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
saveSession(); // the command may run for days
|
|
589
|
+
process.exitCode = await runChild(cmd, args, childEnv(process.env, safe));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function cmdPull({ values }) {
|
|
593
|
+
const render = FORMATS[values.format];
|
|
594
|
+
if (!render) throw new UsageError(`unknown format "${values.format}": dotenv or json`);
|
|
595
|
+
const client = connect();
|
|
596
|
+
const safeId = await targetVault(client, values);
|
|
597
|
+
const { privateKey } = await unlock(client);
|
|
598
|
+
const { safe, secrets } = await pullSecrets(client, privateKey, safeId);
|
|
599
|
+
using(safe);
|
|
600
|
+
const body = render(secrets);
|
|
601
|
+
const text = body ? `${body}\n` : '';
|
|
602
|
+
if (values.out) {
|
|
603
|
+
writePrivate(values.out, text);
|
|
604
|
+
note(`Wrote ${Object.keys(secrets).length} secrets to ${values.out}.`);
|
|
605
|
+
} else {
|
|
606
|
+
process.stdout.write(text);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── Commands: sharing ───────────────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
function levelOf(values) {
|
|
613
|
+
const chosen = Object.keys(LEVELS).filter((name) => values[name]);
|
|
614
|
+
if (chosen.length > 1) throw new UsageError('one of --read, --write, --admin');
|
|
615
|
+
return LEVELS[chosen[0] || 'read'];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function cmdShare({ values, positionals: [email] }) {
|
|
619
|
+
if (Boolean(email) === Boolean(values.team)) {
|
|
620
|
+
throw new UsageError('share with a person (an email) or a team (--team <team>), one of them');
|
|
621
|
+
}
|
|
622
|
+
const level = levelOf(values);
|
|
623
|
+
const client = connect();
|
|
624
|
+
const safeId = await targetVault(client, values);
|
|
625
|
+
const { privateKey, watch } = await unlock(client);
|
|
626
|
+
const vault = await openVault(client, privateKey, safeId);
|
|
627
|
+
using(vault.safe);
|
|
628
|
+
|
|
629
|
+
if (values.team) {
|
|
630
|
+
const team = await resolveTeam(client, values.team);
|
|
631
|
+
const status = await shareWithTeam(client, privateKey, vault, team, level);
|
|
632
|
+
note(
|
|
633
|
+
`Shared with team ${teamLabel(team)} (${levelName(level)}).` +
|
|
634
|
+
(status === 'pending' ? ' It waits for a lead of the team to accept it.' : '')
|
|
635
|
+
);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const person = await lookupPerson(client, email);
|
|
640
|
+
const who = person.username ? `${person.username} (${email})` : email;
|
|
641
|
+
await assertPersonLogged(client, { store, watch }, person, who);
|
|
642
|
+
note(`Sharing "${vault.safe.name}" with ${who}`);
|
|
643
|
+
const pins = store.readPins();
|
|
644
|
+
const ok = await vouchFor(
|
|
645
|
+
pins,
|
|
646
|
+
{ subject: `user:${person.userId}`, publicKey: person.publicKey, who },
|
|
647
|
+
values,
|
|
648
|
+
{ question: 'Seal this vault to this key?', changed: 'Seal it to their NEW key?' }
|
|
649
|
+
);
|
|
650
|
+
if (!ok) {
|
|
651
|
+
note('Not shared.');
|
|
652
|
+
process.exitCode = 1;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
store.writePins(pins);
|
|
656
|
+
const status = await shareWithPerson(client, privateKey, vault, person, level);
|
|
657
|
+
note(
|
|
658
|
+
`Shared with ${who} (${levelName(level)}).` +
|
|
659
|
+
(status === 'pending' ? ' It waits for their answer: sharedenv accept, on their side.' : '')
|
|
660
|
+
);
|
|
334
661
|
}
|
|
335
662
|
|
|
336
663
|
async function cmdAccept({ values, positionals: [ref] }) {
|
|
337
|
-
const
|
|
338
|
-
const safeId = await resolvePendingShare(client, ref, values.team);
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
664
|
+
const client = connect();
|
|
665
|
+
const { safeId, teamId } = await resolvePendingShare(client, ref, values.team);
|
|
666
|
+
const { privateKey, watch } = await unlock(client);
|
|
667
|
+
const confirmIt = confirmShare(values);
|
|
668
|
+
const log = { store, watch };
|
|
669
|
+
const result = teamId
|
|
670
|
+
? await acceptTeamShare(client, privateKey, teamId, safeId, { confirm: confirmIt, log })
|
|
671
|
+
: await acceptShare(client, privateKey, safeId, { confirm: confirmIt, log });
|
|
344
672
|
note(result.accepted ? 'Accepted.' : 'Left pending.');
|
|
345
673
|
}
|
|
346
674
|
|
|
347
675
|
async function cmdDecline({ values, positionals: [ref] }) {
|
|
348
676
|
const client = connect();
|
|
349
|
-
const safeId = await resolvePendingShare(client, ref, values.team);
|
|
350
|
-
if (
|
|
677
|
+
const { safeId, teamId } = await resolvePendingShare(client, ref, values.team);
|
|
678
|
+
if (teamId) await declineTeamShare(client, teamId, safeId);
|
|
351
679
|
else await declineShare(client, safeId);
|
|
352
680
|
note('Declined.');
|
|
353
681
|
}
|
|
354
682
|
|
|
683
|
+
async function cmdTeams() {
|
|
684
|
+
const client = connect();
|
|
685
|
+
const teams = await listTeams(client);
|
|
686
|
+
if (teams.length === 0) {
|
|
687
|
+
say('No team yet: teams are made on the web app.');
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const lines = [];
|
|
691
|
+
const orgs = new Map();
|
|
692
|
+
for (const team of teams) {
|
|
693
|
+
const key = String(team.org._id);
|
|
694
|
+
if (!orgs.has(key)) orgs.set(key, { org: team.org, teams: [] });
|
|
695
|
+
orgs.get(key).teams.push(team);
|
|
696
|
+
}
|
|
697
|
+
for (const { org, teams: rows } of orgs.values()) {
|
|
698
|
+
lines.push(`${org.name} ${org._id}`);
|
|
699
|
+
for (const t of rows.sort(byName)) {
|
|
700
|
+
const count = t.memberCount === undefined ? '' : ` ${t.memberCount} member(s)`;
|
|
701
|
+
lines.push(` ${t.name} ${t._id}${count}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
say(lines.join('\n'));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ── Commands: trust ─────────────────────────────────────────────────────────────────
|
|
708
|
+
|
|
355
709
|
// Derived from YOUR private key, never the server's copy: the value a teammate checks a
|
|
356
710
|
// key against must not be one the server chose.
|
|
357
711
|
async function cmdFingerprint() {
|
|
358
|
-
const { privateKey } = await unlock();
|
|
712
|
+
const { privateKey } = await unlock(connect());
|
|
359
713
|
say(fingerprintOf(publicKeyOf(privateKey)));
|
|
360
714
|
}
|
|
361
715
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
716
|
+
// What the public key log check found on this machine and nobody acknowledged yet. Read
|
|
717
|
+
// from what is kept, without the network: the warnings are the data (stdout).
|
|
718
|
+
function cmdLog({ values }) {
|
|
719
|
+
const alerts = values.ack ? acknowledgeAlerts(store) : keptAlerts(store);
|
|
720
|
+
if (alerts.length === 0) {
|
|
721
|
+
note('No warning from the public key log on this machine.');
|
|
366
722
|
return;
|
|
367
723
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (res.status !== 401) expectOk(res, 'sign-out refused'); // 401: already over
|
|
376
|
-
note('Signed out.');
|
|
377
|
-
} catch (err) {
|
|
378
|
-
note(
|
|
379
|
-
`sharedenv: the server did not close the session (${err.message}) — it stays valid ` +
|
|
380
|
-
'there until it expires. Forgotten on this machine.'
|
|
381
|
-
);
|
|
382
|
-
process.exitCode = 1;
|
|
383
|
-
}
|
|
384
|
-
store.clearSession();
|
|
724
|
+
const lines = watchWarnings({ alerts });
|
|
725
|
+
say(lines.slice(0, -1).join('\n'));
|
|
726
|
+
note(
|
|
727
|
+
values.ack
|
|
728
|
+
? 'Acknowledged: a warning that is still true comes back at the next unlock.'
|
|
729
|
+
: lines[lines.length - 1]
|
|
730
|
+
);
|
|
385
731
|
}
|
|
386
732
|
|
|
733
|
+
// ── The catalogue ───────────────────────────────────────────────────────────────────
|
|
734
|
+
// `positionals`: [min, max]. `takesCommand`: what follows `--` is the command to run.
|
|
735
|
+
|
|
736
|
+
const TRUST_OPTIONS = {
|
|
737
|
+
yes: { type: 'boolean', short: 'y' },
|
|
738
|
+
fingerprint: { type: 'string' },
|
|
739
|
+
'accept-key-change': { type: 'boolean' },
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const VAULT_DETAIL =
|
|
743
|
+
'The vault: -v <name or id>, or the one this project is linked to (sharedenv link).';
|
|
744
|
+
|
|
387
745
|
const COMMANDS = {
|
|
388
746
|
login: {
|
|
747
|
+
group: 'Session',
|
|
389
748
|
usage: 'login [--api <url>] [--identifier <email or username>]',
|
|
390
749
|
summary: 'Sign in (keeps the session cookies only)',
|
|
391
750
|
details:
|
|
@@ -393,93 +752,224 @@ const COMMANDS = {
|
|
|
393
752
|
'(or SHAREDENV_IDENTIFIER), SHAREDENV_PASSWORD, and SHAREDENV_OTP if the account\n' +
|
|
394
753
|
'has a second factor.',
|
|
395
754
|
options: { api: { type: 'string' }, identifier: { type: 'string' } },
|
|
396
|
-
positionals: 0,
|
|
755
|
+
positionals: [0, 0],
|
|
397
756
|
run: cmdLogin,
|
|
398
757
|
},
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
758
|
+
logout: {
|
|
759
|
+
group: 'Session',
|
|
760
|
+
usage: 'logout',
|
|
761
|
+
summary: 'Close the session, on the server and here',
|
|
762
|
+
options: {},
|
|
763
|
+
positionals: [0, 0],
|
|
764
|
+
run: cmdLogout,
|
|
765
|
+
},
|
|
766
|
+
vaults: {
|
|
767
|
+
group: 'Vaults',
|
|
768
|
+
usage: 'vaults',
|
|
769
|
+
summary: 'Your vaults, and the shares waiting for your answer',
|
|
402
770
|
details:
|
|
403
|
-
'
|
|
404
|
-
'
|
|
405
|
-
options: {
|
|
406
|
-
positionals:
|
|
407
|
-
run:
|
|
771
|
+
'Each vault with its id, your access, its owner and the teams you reach it through:\n' +
|
|
772
|
+
'what tells two vaults of the same name apart. No password asked.',
|
|
773
|
+
options: {},
|
|
774
|
+
positionals: [0, 0],
|
|
775
|
+
run: cmdVaults,
|
|
776
|
+
},
|
|
777
|
+
create: {
|
|
778
|
+
group: 'Vaults',
|
|
779
|
+
usage: 'create <name>',
|
|
780
|
+
summary: 'Create a vault (prints its id)',
|
|
781
|
+
options: {},
|
|
782
|
+
positionals: [1, 1],
|
|
783
|
+
run: cmdCreate,
|
|
784
|
+
},
|
|
785
|
+
link: {
|
|
786
|
+
group: 'Vaults',
|
|
787
|
+
usage: 'link [<vault>]',
|
|
788
|
+
summary: `Link this directory to a vault (${LINK_FILE})`,
|
|
789
|
+
details:
|
|
790
|
+
`Writes ${LINK_FILE} here: the vault's id, no secret. Every command run in this\n` +
|
|
791
|
+
'directory or below then works on that vault without -v. Commit the file and your\n' +
|
|
792
|
+
'team works on the same vault. Without <vault>, you pick one from the list.',
|
|
793
|
+
options: {},
|
|
794
|
+
positionals: [0, 1],
|
|
795
|
+
run: cmdLink,
|
|
796
|
+
},
|
|
797
|
+
show: {
|
|
798
|
+
group: 'Vaults',
|
|
799
|
+
usage: 'show [-v <vault>]',
|
|
800
|
+
summary: 'The vault: owner, members, teams, and the names of its keys',
|
|
801
|
+
details: `${VAULT_DETAIL}\nNames only, never a value: no password asked.`,
|
|
802
|
+
options: VAULT_OPTION,
|
|
803
|
+
positionals: [0, 0],
|
|
804
|
+
run: cmdShow,
|
|
805
|
+
},
|
|
806
|
+
get: {
|
|
807
|
+
group: 'Keys',
|
|
808
|
+
usage: 'get <KEY> [-v <vault>]',
|
|
809
|
+
summary: 'Print the value of one key',
|
|
810
|
+
details:
|
|
811
|
+
`${VAULT_DETAIL}\n` +
|
|
812
|
+
'The value is printed as is: nothing added in a pipe or a file, a newline in a terminal.',
|
|
813
|
+
options: VAULT_OPTION,
|
|
814
|
+
positionals: [1, 1],
|
|
815
|
+
run: cmdGet,
|
|
816
|
+
},
|
|
817
|
+
set: {
|
|
818
|
+
group: 'Keys',
|
|
819
|
+
usage: 'set <KEY>=<value>... [<KEY>] [-v <vault>]',
|
|
820
|
+
summary: 'Add or replace keys',
|
|
821
|
+
details:
|
|
822
|
+
`${VAULT_DETAIL}\n` +
|
|
823
|
+
'A <KEY> given without a value has its value asked without echo, or read from stdin\n' +
|
|
824
|
+
'(`sharedenv set TLS_CERT < cert.pem`): it lands neither in your shell history nor in\n' +
|
|
825
|
+
'the process list. Values are encrypted here before they leave.',
|
|
826
|
+
options: VAULT_OPTION,
|
|
827
|
+
positionals: [1, Infinity],
|
|
828
|
+
run: cmdSet,
|
|
829
|
+
},
|
|
830
|
+
unset: {
|
|
831
|
+
group: 'Keys',
|
|
832
|
+
usage: 'unset <KEY>... [-v <vault>]',
|
|
833
|
+
summary: 'Remove keys',
|
|
834
|
+
details: `${VAULT_DETAIL}\nEvery key must exist, or none is removed. No password asked.`,
|
|
835
|
+
options: VAULT_OPTION,
|
|
836
|
+
positionals: [1, Infinity],
|
|
837
|
+
run: cmdUnset,
|
|
408
838
|
},
|
|
409
839
|
run: {
|
|
410
|
-
|
|
411
|
-
|
|
840
|
+
group: 'Keys',
|
|
841
|
+
usage: 'run [-v <vault>] [--allow-unsafe-env] [--override-env] -- <command> [args...]',
|
|
842
|
+
summary: 'Run a command with the keys in its environment',
|
|
412
843
|
details:
|
|
844
|
+
`${VAULT_DETAIL}\n` +
|
|
413
845
|
'The secrets reach the command in memory, never on disk. Held back unless asked:\n' +
|
|
414
846
|
'variables that make the command run code or redirect its traffic (LD_*, PATH,\n' +
|
|
415
847
|
'NODE_OPTIONS, *_PROXY...: --allow-unsafe-env), and variables your environment\n' +
|
|
416
848
|
"already sets (--override-env). The exit code is the command's.",
|
|
417
|
-
options: {
|
|
418
|
-
|
|
849
|
+
options: {
|
|
850
|
+
...VAULT_OPTION,
|
|
851
|
+
'allow-unsafe-env': { type: 'boolean' },
|
|
852
|
+
'override-env': { type: 'boolean' },
|
|
853
|
+
},
|
|
854
|
+
positionals: [0, 0],
|
|
419
855
|
takesCommand: true,
|
|
420
856
|
run: cmdRun,
|
|
421
857
|
},
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
858
|
+
pull: {
|
|
859
|
+
group: 'Keys',
|
|
860
|
+
usage: 'pull [-v <vault>] [--out <file>] [--format dotenv|json]',
|
|
861
|
+
summary: 'Print or write every key, as a .env or JSON',
|
|
862
|
+
details:
|
|
863
|
+
`${VAULT_DETAIL}\n` +
|
|
864
|
+
'--out writes the file readable by you only. A dotenv file is safe to source in a\n' +
|
|
865
|
+
'shell: values are quoted so that none is expanded.',
|
|
866
|
+
options: {
|
|
867
|
+
...VAULT_OPTION,
|
|
868
|
+
out: { type: 'string' },
|
|
869
|
+
format: { type: 'string', default: 'dotenv' },
|
|
870
|
+
},
|
|
871
|
+
positionals: [0, 0],
|
|
872
|
+
run: cmdPull,
|
|
873
|
+
},
|
|
874
|
+
share: {
|
|
875
|
+
group: 'Sharing',
|
|
876
|
+
usage:
|
|
877
|
+
'share <email> | --team <team> [--read|--write|--admin] [-v <vault>]\n' +
|
|
878
|
+
' [--yes] [--fingerprint <fp>] [--accept-key-change]',
|
|
879
|
+
summary: 'Share the vault with a person or one of your teams',
|
|
880
|
+
details:
|
|
881
|
+
`${VAULT_DETAIL}\n` +
|
|
882
|
+
'Read access unless --write or --admin. A person: their key fingerprint is shown and\n' +
|
|
883
|
+
'asked about, as for accept (compare it with them; --yes, --fingerprint and\n' +
|
|
884
|
+
'--accept-key-change answer for a script); the share then waits for their answer.\n' +
|
|
885
|
+
'--team: a team you are a member of (name or id, see `sharedenv teams`). Removing\n' +
|
|
886
|
+
"someone's access changes the vault's key: that is done in the web app.",
|
|
887
|
+
options: {
|
|
888
|
+
...VAULT_OPTION,
|
|
889
|
+
...TRUST_OPTIONS,
|
|
890
|
+
team: { type: 'string' },
|
|
891
|
+
read: { type: 'boolean' },
|
|
892
|
+
write: { type: 'boolean' },
|
|
893
|
+
admin: { type: 'boolean' },
|
|
894
|
+
},
|
|
895
|
+
positionals: [0, 1],
|
|
896
|
+
run: cmdShare,
|
|
428
897
|
},
|
|
429
898
|
accept: {
|
|
899
|
+
group: 'Sharing',
|
|
430
900
|
usage:
|
|
431
|
-
'accept <vault> [--team <
|
|
901
|
+
'accept <vault> [--team <team>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
|
|
432
902
|
' [--team-fingerprint <fp>]',
|
|
433
903
|
summary: 'Accept a share after checking who sealed it',
|
|
434
904
|
details:
|
|
435
905
|
"Shows the sender and their key's fingerprint, and asks. --yes: no question for a key\n" +
|
|
436
906
|
'that is new or already known here. --fingerprint: accept only if the key is this\n' +
|
|
437
907
|
'one. --accept-key-change: take a key that differs from the one known here. --team:\n' +
|
|
438
|
-
'a share to a team you lead; if its key must be confirmed, only a human
|
|
439
|
-
'--team-fingerprint can.',
|
|
440
|
-
options: {
|
|
441
|
-
|
|
442
|
-
yes: { type: 'boolean', short: 'y' },
|
|
443
|
-
fingerprint: { type: 'string' },
|
|
444
|
-
'accept-key-change': { type: 'boolean' },
|
|
445
|
-
'team-fingerprint': { type: 'string' },
|
|
446
|
-
},
|
|
447
|
-
positionals: 1,
|
|
908
|
+
'a share to a team you lead (name or id); if its key must be confirmed, only a human\n' +
|
|
909
|
+
'or --team-fingerprint can.',
|
|
910
|
+
options: { ...TRUST_OPTIONS, team: { type: 'string' }, 'team-fingerprint': { type: 'string' } },
|
|
911
|
+
positionals: [1, 1],
|
|
448
912
|
run: cmdAccept,
|
|
449
913
|
},
|
|
450
914
|
decline: {
|
|
451
|
-
|
|
915
|
+
group: 'Sharing',
|
|
916
|
+
usage: 'decline <vault> [--team <team>]',
|
|
452
917
|
summary: 'Decline a share',
|
|
453
918
|
options: { team: { type: 'string' } },
|
|
454
|
-
positionals: 1,
|
|
919
|
+
positionals: [1, 1],
|
|
455
920
|
run: cmdDecline,
|
|
456
921
|
},
|
|
922
|
+
teams: {
|
|
923
|
+
group: 'Sharing',
|
|
924
|
+
usage: 'teams',
|
|
925
|
+
summary: 'Your organizations and their teams',
|
|
926
|
+
details: 'Teams and organizations are managed in the web app.',
|
|
927
|
+
options: {},
|
|
928
|
+
positionals: [0, 0],
|
|
929
|
+
run: cmdTeams,
|
|
930
|
+
},
|
|
457
931
|
fingerprint: {
|
|
932
|
+
group: 'Trust',
|
|
458
933
|
usage: 'fingerprint',
|
|
459
934
|
summary: 'Your public-key fingerprint, to read to a teammate',
|
|
460
935
|
options: {},
|
|
461
|
-
positionals: 0,
|
|
936
|
+
positionals: [0, 0],
|
|
462
937
|
run: cmdFingerprint,
|
|
463
938
|
},
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
939
|
+
log: {
|
|
940
|
+
group: 'Trust',
|
|
941
|
+
usage: 'log [--ack]',
|
|
942
|
+
summary: 'Warnings from the public key log, kept until acknowledged',
|
|
943
|
+
details:
|
|
944
|
+
'Each unlock checks that the keys the public key log holds for you and for your teams\n' +
|
|
945
|
+
'are the ones you hold. What it finds is kept on this machine and repeated at every\n' +
|
|
946
|
+
'unlock. --ack: you have read them (and told SharedEnv); a warning that is still\n' +
|
|
947
|
+
'true comes back at the next unlock.',
|
|
948
|
+
options: { ack: { type: 'boolean' } },
|
|
949
|
+
positionals: [0, 0],
|
|
950
|
+
run: cmdLog,
|
|
470
951
|
},
|
|
471
952
|
};
|
|
472
953
|
|
|
954
|
+
function commandList() {
|
|
955
|
+
const groups = new Map();
|
|
956
|
+
for (const [name, c] of Object.entries(COMMANDS)) {
|
|
957
|
+
if (!groups.has(c.group)) groups.set(c.group, []);
|
|
958
|
+
groups.get(c.group).push(` ${name.padEnd(12)} ${c.summary}`);
|
|
959
|
+
}
|
|
960
|
+
return [...groups].map(([group, lines]) => `${group}:\n${lines.join('\n')}`).join('\n\n');
|
|
961
|
+
}
|
|
962
|
+
|
|
473
963
|
const HELP = `sharedenv ${VERSION} — zero-knowledge secrets, decrypted locally
|
|
474
964
|
|
|
475
965
|
Usage: sharedenv <command> [options]
|
|
476
966
|
|
|
477
967
|
Commands:
|
|
478
|
-
${Object.entries(COMMANDS)
|
|
479
|
-
.map(([name, c]) => ` ${name.padEnd(12)} ${c.summary}`)
|
|
480
|
-
.join('\n')}
|
|
481
968
|
|
|
482
|
-
|
|
969
|
+
${commandList()}
|
|
970
|
+
|
|
971
|
+
A command works on the vault named by -v <name or id>, or on the one the current
|
|
972
|
+
directory is linked to (sharedenv link). Run \`sharedenv help <command>\` for its options.
|
|
483
973
|
|
|
484
974
|
Environment:
|
|
485
975
|
SHAREDENV_API API URL (default ${DEFAULT_API})
|
|
@@ -501,7 +991,7 @@ async function main(argv) {
|
|
|
501
991
|
if (!name || name === 'help' || name === '-h' || name === '--help') {
|
|
502
992
|
return say(name === 'help' && rest[0] ? commandHelp(rest[0]) : HELP);
|
|
503
993
|
}
|
|
504
|
-
if (name === 'version' || name === '--version'
|
|
994
|
+
if (name === 'version' || name === '--version') return say(VERSION);
|
|
505
995
|
|
|
506
996
|
const command = COMMANDS[name];
|
|
507
997
|
if (!command) throw new UsageError(`unknown command "${name}" — see \`sharedenv help\``);
|
|
@@ -516,10 +1006,9 @@ async function main(argv) {
|
|
|
516
1006
|
throw new UsageError(`${err.message.split('. ')[0]} — see \`sharedenv help ${name}\``);
|
|
517
1007
|
}
|
|
518
1008
|
const shell = command.takesCommand ? rest.slice(sep + 1) : [];
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
) {
|
|
1009
|
+
const [min, max] = command.positionals;
|
|
1010
|
+
const count = parsed.positionals.length;
|
|
1011
|
+
if (count < min || count > max || (command.takesCommand && (sep < 0 || shell.length === 0))) {
|
|
523
1012
|
throw new UsageError(`usage: sharedenv ${command.usage}`);
|
|
524
1013
|
}
|
|
525
1014
|
try {
|