@sharedenv/cli 1.1.0 → 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 +711 -213
- package/lib/api.js +20 -0
- package/lib/auth.js +12 -1
- 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,121 +161,100 @@ 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 };
|
|
144
188
|
}
|
|
145
189
|
|
|
146
|
-
// ──
|
|
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).
|
|
147
193
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (!identifier) {
|
|
156
|
-
throw new UsageError(
|
|
157
|
-
'no identifier: run in a terminal, or pass --identifier (or set SHAREDENV_IDENTIFIER)'
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
const password = await masterPassword();
|
|
161
|
-
// Same server: sign in as the same device, so the server replaces the session it had
|
|
162
|
-
// for this machine instead of keeping a second one alive.
|
|
163
|
-
const cookies = previous?.baseUrl === baseUrl ? previous.cookies : {};
|
|
164
|
-
const client = createClient({ baseUrl, cookies, userAgent: USER_AGENT });
|
|
165
|
-
const otp = async () => process.env.SHAREDENV_OTP || (await ask('Two-factor code: '));
|
|
166
|
-
await login(client, { identifier, password, otp });
|
|
167
|
-
const account = await unlockAccount(client, password); // the vault opens: only then is anything kept
|
|
168
|
-
store.writeSession({ baseUrl, cookies: client.cookies() });
|
|
169
|
-
note(`Signed in to ${baseUrl}.`);
|
|
170
|
-
await watchOwnKey(client, account);
|
|
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');
|
|
171
201
|
}
|
|
172
202
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
} else {
|
|
184
|
-
process.stdout.write(text);
|
|
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;
|
|
185
213
|
}
|
|
186
214
|
}
|
|
187
215
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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') {
|
|
197
229
|
note(
|
|
198
|
-
`
|
|
199
|
-
|
|
230
|
+
` WARNING: this machine knew ANOTHER key for ${who} ` +
|
|
231
|
+
`(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
|
|
200
232
|
);
|
|
233
|
+
} else if (status === 'new') {
|
|
234
|
+
note(' First time this machine sees this key: compare it with them.');
|
|
201
235
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function cmdList() {
|
|
213
|
-
const client = connect();
|
|
214
|
-
const nameOf = (row) => (row.safe?.name ?? row.name ?? '').toLowerCase();
|
|
215
|
-
const byName = (a, b) => nameOf(a).localeCompare(nameOf(b));
|
|
216
|
-
const safes = (await listAll(client, '/safes', 'safes')).sort(byName);
|
|
217
|
-
const { direct, team } = await listPendingShares(client);
|
|
218
|
-
const lines = [safes.length ? 'Vaults:' : 'No vaults yet.'];
|
|
219
|
-
for (const s of safes) {
|
|
220
|
-
const via = Array.isArray(s.via) && !s.via.includes('direct') ? ' (via team)' : '';
|
|
221
|
-
lines.push(` ${s.name} ${s._id} ${LEVEL[s.level] || s.level}${via}`);
|
|
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;
|
|
222
244
|
}
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
|
|
234
|
-
` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
|
|
235
|
-
(p.provisioned ? '' : ' [you do not hold this team key yet]')
|
|
236
|
-
);
|
|
237
|
-
}
|
|
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;
|
|
238
255
|
}
|
|
239
|
-
|
|
256
|
+
pinKey(pins, subject, publicKey);
|
|
257
|
+
return true;
|
|
240
258
|
}
|
|
241
259
|
|
|
242
260
|
// A team share sealed to the team's PREVIOUS key moves to its current key on accept.
|
|
@@ -274,80 +292,61 @@ async function confirmTeamKey(pins, { share, teamPublicKey }, values) {
|
|
|
274
292
|
}
|
|
275
293
|
|
|
276
294
|
// The human step of `accept`: who sealed the share, with what key, as this machine
|
|
277
|
-
// knows it — then yes or no.
|
|
278
|
-
// without an explicit yes (or --accept-key-change).
|
|
295
|
+
// knows it — then yes or no.
|
|
279
296
|
function confirmShare(values) {
|
|
280
297
|
return async (opened) => {
|
|
281
|
-
const { share, senderKey
|
|
298
|
+
const { share, senderKey } = opened;
|
|
282
299
|
const pins = store.readPins();
|
|
283
|
-
const subject = `user:${share.from?._id}`;
|
|
284
|
-
const status = pinStatus(pins, subject, senderKey);
|
|
285
300
|
note(`"${share.safe?.name}" from ${senderLabel(share)}`);
|
|
286
|
-
|
|
287
|
-
`
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
);
|
|
294
|
-
} else if (status === 'new') {
|
|
295
|
-
note(' First time this machine sees this key: compare it with them.');
|
|
296
|
-
}
|
|
297
|
-
const verdict = senderVerdict(status, fingerprint, {
|
|
298
|
-
expected: values.fingerprint,
|
|
299
|
-
acceptKeyChange: values['accept-key-change'],
|
|
300
|
-
yes: values.yes,
|
|
301
|
-
});
|
|
302
|
-
if (verdict === 'refuse') {
|
|
303
|
-
note(' That is not the --fingerprint you gave.');
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
if (
|
|
307
|
-
verdict === 'ask' &&
|
|
308
|
-
!(await confirm(
|
|
309
|
-
status === 'changed'
|
|
310
|
-
? 'Accept their NEW key? Confirm it with them first. [y/N] '
|
|
311
|
-
: 'Accept this share? [y/N] ',
|
|
312
|
-
status === 'changed'
|
|
313
|
-
? 'pass --fingerprint <fingerprint>, checked with them'
|
|
314
|
-
: 'pass --yes, or --fingerprint <fingerprint> checked with them'
|
|
315
|
-
))
|
|
316
|
-
) {
|
|
317
|
-
return false;
|
|
318
|
-
}
|
|
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;
|
|
319
308
|
if (opened.teamKeyVouched === false && !(await confirmTeamKey(pins, opened, values))) {
|
|
320
309
|
return false;
|
|
321
310
|
}
|
|
322
|
-
store.writePins(
|
|
311
|
+
store.writePins(pins);
|
|
323
312
|
return true;
|
|
324
313
|
};
|
|
325
314
|
}
|
|
326
315
|
|
|
327
|
-
|
|
328
|
-
const { client, privateKey } = await unlock();
|
|
329
|
-
const safeId = await resolvePendingShare(client, ref, values.team);
|
|
330
|
-
const confirm = confirmShare(values);
|
|
331
|
-
const log = { store };
|
|
332
|
-
const result = values.team
|
|
333
|
-
? await acceptTeamShare(client, privateKey, values.team, safeId, { confirm, log })
|
|
334
|
-
: await acceptShare(client, privateKey, safeId, { confirm, log });
|
|
335
|
-
note(result.accepted ? 'Accepted.' : 'Left pending.');
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async function cmdDecline({ values, positionals: [ref] }) {
|
|
339
|
-
const client = connect();
|
|
340
|
-
const safeId = await resolvePendingShare(client, ref, values.team);
|
|
341
|
-
if (values.team) await declineTeamShare(client, values.team, safeId);
|
|
342
|
-
else await declineShare(client, safeId);
|
|
343
|
-
note('Declined.');
|
|
344
|
-
}
|
|
316
|
+
// ── Commands: session ───────────────────────────────────────────────────────────────
|
|
345
317
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
318
|
+
async function cmdLogin({ values }) {
|
|
319
|
+
const previous = store.readSession();
|
|
320
|
+
const baseUrl = apiBase(
|
|
321
|
+
values.api || process.env.SHAREDENV_API || previous?.baseUrl || DEFAULT_API
|
|
322
|
+
);
|
|
323
|
+
const identifier =
|
|
324
|
+
values.identifier || process.env.SHAREDENV_IDENTIFIER || (await ask('Email or username: '));
|
|
325
|
+
if (!identifier) {
|
|
326
|
+
throw new UsageError(
|
|
327
|
+
'no identifier: run in a terminal, or pass --identifier (or set SHAREDENV_IDENTIFIER)'
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const password = await masterPassword();
|
|
331
|
+
// Same server: sign in as the same device, so the server replaces the session it had
|
|
332
|
+
// for this machine instead of keeping a second one alive.
|
|
333
|
+
const cookies = previous?.baseUrl === baseUrl ? previous.cookies : {};
|
|
334
|
+
const client = createClient({ baseUrl, cookies, userAgent: USER_AGENT });
|
|
335
|
+
const otp = async () => process.env.SHAREDENV_OTP || (await ask('Two-factor code: '));
|
|
336
|
+
await login(client, { identifier, password, otp });
|
|
337
|
+
// The vault opens: only then is anything kept. The server just accepted this password,
|
|
338
|
+
// so a vault it does not open was sealed under the one before a reset.
|
|
339
|
+
const account = await unlockAccount(client, password).catch((err) => {
|
|
340
|
+
if (err.code !== 'VAULT_SEALED') throw err;
|
|
341
|
+
throw new Error(
|
|
342
|
+
'your password was reset, and the vault is still sealed under the old one. Sign in ' +
|
|
343
|
+
'once on the web app and enter your recovery code: the vault then opens with the new ' +
|
|
344
|
+
'password, here too'
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
store.writeSession({ baseUrl, cookies: client.cookies() });
|
|
348
|
+
note(`Signed in to ${baseUrl}.`);
|
|
349
|
+
await watchKeys(client, account);
|
|
351
350
|
}
|
|
352
351
|
|
|
353
352
|
async function cmdLogout() {
|
|
@@ -375,8 +374,377 @@ async function cmdLogout() {
|
|
|
375
374
|
store.clearSession();
|
|
376
375
|
}
|
|
377
376
|
|
|
377
|
+
// ── Commands: vaults ────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
// Names only: no password needed (vault and key names are stored in clear).
|
|
380
|
+
async function cmdVaults() {
|
|
381
|
+
const client = connect();
|
|
382
|
+
const vaults = (await listVaults(client)).sort(byName);
|
|
383
|
+
const me = await whoAmI(client);
|
|
384
|
+
const { direct, team } = await listPendingShares(client);
|
|
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)' : ''}`);
|
|
389
|
+
}
|
|
390
|
+
const pendingName = (p) => ({ name: p.safe?.name });
|
|
391
|
+
if (direct.length || team.length) {
|
|
392
|
+
lines.push('', 'Waiting for your answer (sharedenv accept|decline <vault>):');
|
|
393
|
+
for (const p of direct.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
|
|
394
|
+
lines.push(
|
|
395
|
+
` ${p.safe?.name} ${p.safeId} ${levelName(p.level)} from ${senderLabel(p)}` +
|
|
396
|
+
` key ${p.fingerprint || 'none on file'}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
for (const p of team.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
|
|
400
|
+
lines.push(
|
|
401
|
+
` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
|
|
402
|
+
` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
|
|
403
|
+
(p.provisioned ? '' : ' [you do not hold this team key yet]')
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
say(lines.join('\n'));
|
|
408
|
+
}
|
|
409
|
+
|
|
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) {
|
|
488
|
+
note(
|
|
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.'
|
|
491
|
+
);
|
|
492
|
+
}
|
|
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;
|
|
524
|
+
}
|
|
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;
|
|
533
|
+
}
|
|
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;
|
|
538
|
+
}
|
|
539
|
+
|
|
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) {
|
|
577
|
+
note(
|
|
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).'
|
|
580
|
+
);
|
|
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
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function cmdAccept({ values, positionals: [ref] }) {
|
|
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 });
|
|
672
|
+
note(result.accepted ? 'Accepted.' : 'Left pending.');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function cmdDecline({ values, positionals: [ref] }) {
|
|
676
|
+
const client = connect();
|
|
677
|
+
const { safeId, teamId } = await resolvePendingShare(client, ref, values.team);
|
|
678
|
+
if (teamId) await declineTeamShare(client, teamId, safeId);
|
|
679
|
+
else await declineShare(client, safeId);
|
|
680
|
+
note('Declined.');
|
|
681
|
+
}
|
|
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
|
+
|
|
709
|
+
// Derived from YOUR private key, never the server's copy: the value a teammate checks a
|
|
710
|
+
// key against must not be one the server chose.
|
|
711
|
+
async function cmdFingerprint() {
|
|
712
|
+
const { privateKey } = await unlock(connect());
|
|
713
|
+
say(fingerprintOf(publicKeyOf(privateKey)));
|
|
714
|
+
}
|
|
715
|
+
|
|
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.');
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
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
|
+
);
|
|
731
|
+
}
|
|
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
|
+
|
|
378
745
|
const COMMANDS = {
|
|
379
746
|
login: {
|
|
747
|
+
group: 'Session',
|
|
380
748
|
usage: 'login [--api <url>] [--identifier <email or username>]',
|
|
381
749
|
summary: 'Sign in (keeps the session cookies only)',
|
|
382
750
|
details:
|
|
@@ -384,93 +752,224 @@ const COMMANDS = {
|
|
|
384
752
|
'(or SHAREDENV_IDENTIFIER), SHAREDENV_PASSWORD, and SHAREDENV_OTP if the account\n' +
|
|
385
753
|
'has a second factor.',
|
|
386
754
|
options: { api: { type: 'string' }, identifier: { type: 'string' } },
|
|
387
|
-
positionals: 0,
|
|
755
|
+
positionals: [0, 0],
|
|
388
756
|
run: cmdLogin,
|
|
389
757
|
},
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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',
|
|
393
770
|
details:
|
|
394
|
-
'
|
|
395
|
-
'
|
|
396
|
-
options: {
|
|
397
|
-
positionals:
|
|
398
|
-
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,
|
|
399
838
|
},
|
|
400
839
|
run: {
|
|
401
|
-
|
|
402
|
-
|
|
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',
|
|
403
843
|
details:
|
|
844
|
+
`${VAULT_DETAIL}\n` +
|
|
404
845
|
'The secrets reach the command in memory, never on disk. Held back unless asked:\n' +
|
|
405
846
|
'variables that make the command run code or redirect its traffic (LD_*, PATH,\n' +
|
|
406
847
|
'NODE_OPTIONS, *_PROXY...: --allow-unsafe-env), and variables your environment\n' +
|
|
407
848
|
"already sets (--override-env). The exit code is the command's.",
|
|
408
|
-
options: {
|
|
409
|
-
|
|
849
|
+
options: {
|
|
850
|
+
...VAULT_OPTION,
|
|
851
|
+
'allow-unsafe-env': { type: 'boolean' },
|
|
852
|
+
'override-env': { type: 'boolean' },
|
|
853
|
+
},
|
|
854
|
+
positionals: [0, 0],
|
|
410
855
|
takesCommand: true,
|
|
411
856
|
run: cmdRun,
|
|
412
857
|
},
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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,
|
|
419
897
|
},
|
|
420
898
|
accept: {
|
|
899
|
+
group: 'Sharing',
|
|
421
900
|
usage:
|
|
422
|
-
'accept <vault> [--team <
|
|
901
|
+
'accept <vault> [--team <team>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
|
|
423
902
|
' [--team-fingerprint <fp>]',
|
|
424
903
|
summary: 'Accept a share after checking who sealed it',
|
|
425
904
|
details:
|
|
426
905
|
"Shows the sender and their key's fingerprint, and asks. --yes: no question for a key\n" +
|
|
427
906
|
'that is new or already known here. --fingerprint: accept only if the key is this\n' +
|
|
428
907
|
'one. --accept-key-change: take a key that differs from the one known here. --team:\n' +
|
|
429
|
-
'a share to a team you lead; if its key must be confirmed, only a human
|
|
430
|
-
'--team-fingerprint can.',
|
|
431
|
-
options: {
|
|
432
|
-
|
|
433
|
-
yes: { type: 'boolean', short: 'y' },
|
|
434
|
-
fingerprint: { type: 'string' },
|
|
435
|
-
'accept-key-change': { type: 'boolean' },
|
|
436
|
-
'team-fingerprint': { type: 'string' },
|
|
437
|
-
},
|
|
438
|
-
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],
|
|
439
912
|
run: cmdAccept,
|
|
440
913
|
},
|
|
441
914
|
decline: {
|
|
442
|
-
|
|
915
|
+
group: 'Sharing',
|
|
916
|
+
usage: 'decline <vault> [--team <team>]',
|
|
443
917
|
summary: 'Decline a share',
|
|
444
918
|
options: { team: { type: 'string' } },
|
|
445
|
-
positionals: 1,
|
|
919
|
+
positionals: [1, 1],
|
|
446
920
|
run: cmdDecline,
|
|
447
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
|
+
},
|
|
448
931
|
fingerprint: {
|
|
932
|
+
group: 'Trust',
|
|
449
933
|
usage: 'fingerprint',
|
|
450
934
|
summary: 'Your public-key fingerprint, to read to a teammate',
|
|
451
935
|
options: {},
|
|
452
|
-
positionals: 0,
|
|
936
|
+
positionals: [0, 0],
|
|
453
937
|
run: cmdFingerprint,
|
|
454
938
|
},
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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,
|
|
461
951
|
},
|
|
462
952
|
};
|
|
463
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
|
+
|
|
464
963
|
const HELP = `sharedenv ${VERSION} — zero-knowledge secrets, decrypted locally
|
|
465
964
|
|
|
466
965
|
Usage: sharedenv <command> [options]
|
|
467
966
|
|
|
468
967
|
Commands:
|
|
469
|
-
${Object.entries(COMMANDS)
|
|
470
|
-
.map(([name, c]) => ` ${name.padEnd(12)} ${c.summary}`)
|
|
471
|
-
.join('\n')}
|
|
472
968
|
|
|
473
|
-
|
|
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.
|
|
474
973
|
|
|
475
974
|
Environment:
|
|
476
975
|
SHAREDENV_API API URL (default ${DEFAULT_API})
|
|
@@ -492,7 +991,7 @@ async function main(argv) {
|
|
|
492
991
|
if (!name || name === 'help' || name === '-h' || name === '--help') {
|
|
493
992
|
return say(name === 'help' && rest[0] ? commandHelp(rest[0]) : HELP);
|
|
494
993
|
}
|
|
495
|
-
if (name === 'version' || name === '--version'
|
|
994
|
+
if (name === 'version' || name === '--version') return say(VERSION);
|
|
496
995
|
|
|
497
996
|
const command = COMMANDS[name];
|
|
498
997
|
if (!command) throw new UsageError(`unknown command "${name}" — see \`sharedenv help\``);
|
|
@@ -507,10 +1006,9 @@ async function main(argv) {
|
|
|
507
1006
|
throw new UsageError(`${err.message.split('. ')[0]} — see \`sharedenv help ${name}\``);
|
|
508
1007
|
}
|
|
509
1008
|
const shell = command.takesCommand ? rest.slice(sep + 1) : [];
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
) {
|
|
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))) {
|
|
514
1012
|
throw new UsageError(`usage: sharedenv ${command.usage}`);
|
|
515
1013
|
}
|
|
516
1014
|
try {
|