@sharedenv/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +207 -2
- package/bin/sharedenv.js +508 -0
- package/lib/api.js +130 -0
- package/lib/auth.js +70 -0
- package/lib/crypto/aes.js +35 -0
- package/lib/crypto/fingerprint.js +15 -0
- package/lib/crypto/kdf.js +158 -0
- package/lib/crypto/keypass.js +129 -0
- package/lib/crypto/secretBlob.js +49 -0
- package/lib/crypto/wallet.js +63 -0
- package/lib/crypto.js +10 -0
- package/lib/env.js +96 -0
- package/lib/format.js +42 -0
- package/lib/run.js +41 -0
- package/lib/safes.js +106 -0
- package/lib/shares.js +169 -0
- package/lib/store.js +66 -0
- package/lib/trust.js +45 -0
- package/package.json +37 -4
package/bin/sharedenv.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sharedenv — pull and run with your zero-knowledge secrets, decrypted locally. The
|
|
3
|
+
// master password and the private key never leave this process and never touch disk;
|
|
4
|
+
// only the session cookies and the public keys you vouched for are kept (~/.sharedenv).
|
|
5
|
+
//
|
|
6
|
+
// This file is the terminal: arguments, questions, output, exit codes. What happens to
|
|
7
|
+
// keys and secrets lives in ../lib.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import readline from 'readline';
|
|
11
|
+
import { Writable } from 'stream';
|
|
12
|
+
import { parseArgs } from 'util';
|
|
13
|
+
import { fingerprintOf } from '../lib/crypto.js';
|
|
14
|
+
import { apiBase, createClient, expectOk, listAll } from '../lib/api.js';
|
|
15
|
+
import { login, publicKeyOf, unlockPrivateKey } from '../lib/auth.js';
|
|
16
|
+
import { childEnv, splitUnsafeEnv } from '../lib/env.js';
|
|
17
|
+
import { FORMATS } from '../lib/format.js';
|
|
18
|
+
import { runChild } from '../lib/run.js';
|
|
19
|
+
import { pullSecrets, resolveSafe } from '../lib/safes.js';
|
|
20
|
+
import {
|
|
21
|
+
acceptShare,
|
|
22
|
+
acceptTeamShare,
|
|
23
|
+
declineShare,
|
|
24
|
+
declineTeamShare,
|
|
25
|
+
listPendingShares,
|
|
26
|
+
resolvePendingShare,
|
|
27
|
+
senderLabel,
|
|
28
|
+
} from '../lib/shares.js';
|
|
29
|
+
import { createStore, writePrivate } from '../lib/store.js';
|
|
30
|
+
import { pinKey, pinStatus, senderVerdict, teamKeyVerdict } from '../lib/trust.js';
|
|
31
|
+
|
|
32
|
+
const { version: VERSION } = JSON.parse(
|
|
33
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8')
|
|
34
|
+
);
|
|
35
|
+
const DEFAULT_API = 'https://api.sharedenv.com';
|
|
36
|
+
const USER_AGENT = `sharedenv-cli/${VERSION}`;
|
|
37
|
+
const LEVEL = { 10: 'read', 50: 'write', 90: 'admin' };
|
|
38
|
+
|
|
39
|
+
const store = createStore();
|
|
40
|
+
|
|
41
|
+
// ── Terminal ────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Questions and notices go to stderr: stdout carries data only, so
|
|
43
|
+
// `sharedenv pull prod > .env` writes the secrets and nothing else.
|
|
44
|
+
|
|
45
|
+
const say = (text) => process.stdout.write(`${text}\n`);
|
|
46
|
+
const note = (text) => process.stderr.write(`${text}\n`);
|
|
47
|
+
|
|
48
|
+
class UsageError extends Error {
|
|
49
|
+
exitCode = 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// → the answer, or null when there is no terminal to ask or it was closed (Ctrl-D).
|
|
53
|
+
function ask(question, { secret = false } = {}) {
|
|
54
|
+
if (!process.stdin.isTTY) return Promise.resolve(null);
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
// readline echoes what is typed to its output: for a secret the output is a sink, so
|
|
57
|
+
// nothing reaches the terminal. A secret is not trimmed (a password may end in a space).
|
|
58
|
+
const output = secret
|
|
59
|
+
? new Writable({ write: (chunk, encoding, done) => done() })
|
|
60
|
+
: process.stderr;
|
|
61
|
+
const rl = readline.createInterface({ input: process.stdin, output, terminal: true });
|
|
62
|
+
let settled = false;
|
|
63
|
+
const finish = (fn, value) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
rl.close();
|
|
67
|
+
if (secret) process.stderr.write('\n');
|
|
68
|
+
fn(value);
|
|
69
|
+
};
|
|
70
|
+
rl.on('SIGINT', () => finish(reject, Object.assign(new Error('cancelled'), { exitCode: 130 })));
|
|
71
|
+
rl.on('close', () => finish(resolve, null));
|
|
72
|
+
if (secret) process.stderr.write(question);
|
|
73
|
+
rl.question(secret ? '' : question, (answer) =>
|
|
74
|
+
finish(resolve, secret ? answer : answer.trim())
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A yes/no question. Without a terminal it is a no, and `hint` says how a script answers.
|
|
80
|
+
async function confirm(question, hint) {
|
|
81
|
+
if (!process.stdin.isTTY) {
|
|
82
|
+
note(` No terminal to ask: ${hint}.`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return /^y(es)?$/i.test((await ask(question)) || '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function masterPassword() {
|
|
89
|
+
const password =
|
|
90
|
+
process.env.SHAREDENV_PASSWORD ?? (await ask('Master password: ', { secret: true }));
|
|
91
|
+
if (!password) {
|
|
92
|
+
throw new UsageError('no master password: run in a terminal, or set SHAREDENV_PASSWORD');
|
|
93
|
+
}
|
|
94
|
+
return password;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Session ─────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
let connected = null; // { client, session } of this run, to save renewed cookies
|
|
100
|
+
|
|
101
|
+
function connect() {
|
|
102
|
+
const session = store.readSession();
|
|
103
|
+
if (!session?.baseUrl) throw new Error('not signed in — run `sharedenv login`');
|
|
104
|
+
const baseUrl = apiBase(session.baseUrl);
|
|
105
|
+
// The cookies belong to the server they came from: never send them to another one.
|
|
106
|
+
if (process.env.SHAREDENV_API && apiBase(process.env.SHAREDENV_API) !== baseUrl) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`this session is for ${baseUrl}, SHAREDENV_API names another server: run \`sharedenv login\``
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const client = createClient({ baseUrl, cookies: session.cookies, userAgent: USER_AGENT });
|
|
112
|
+
connected = { client, session };
|
|
113
|
+
return client;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveSession() {
|
|
117
|
+
if (!connected) return;
|
|
118
|
+
const cookies = connected.client.cookies();
|
|
119
|
+
if (JSON.stringify(cookies) === JSON.stringify(connected.session.cookies)) return;
|
|
120
|
+
store.writeSession({ baseUrl: connected.client.baseUrl, cookies });
|
|
121
|
+
connected.session.cookies = cookies;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function unlock() {
|
|
125
|
+
const client = connect();
|
|
126
|
+
const privateKey = await unlockPrivateKey(client, await masterPassword());
|
|
127
|
+
return { client, privateKey };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Commands ────────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
async function cmdLogin({ values }) {
|
|
133
|
+
const previous = store.readSession();
|
|
134
|
+
const baseUrl = apiBase(
|
|
135
|
+
values.api || process.env.SHAREDENV_API || previous?.baseUrl || DEFAULT_API
|
|
136
|
+
);
|
|
137
|
+
const identifier =
|
|
138
|
+
values.identifier || process.env.SHAREDENV_IDENTIFIER || (await ask('Email or username: '));
|
|
139
|
+
if (!identifier) {
|
|
140
|
+
throw new UsageError(
|
|
141
|
+
'no identifier: run in a terminal, or pass --identifier (or set SHAREDENV_IDENTIFIER)'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const password = await masterPassword();
|
|
145
|
+
// Same server: sign in as the same device, so the server replaces the session it had
|
|
146
|
+
// for this machine instead of keeping a second one alive.
|
|
147
|
+
const cookies = previous?.baseUrl === baseUrl ? previous.cookies : {};
|
|
148
|
+
const client = createClient({ baseUrl, cookies, userAgent: USER_AGENT });
|
|
149
|
+
const otp = async () => process.env.SHAREDENV_OTP || (await ask('Two-factor code: '));
|
|
150
|
+
await login(client, { identifier, password, otp });
|
|
151
|
+
await unlockPrivateKey(client, password); // the vault opens: only then is anything kept
|
|
152
|
+
store.writeSession({ baseUrl, cookies: client.cookies() });
|
|
153
|
+
note(`Signed in to ${baseUrl}.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function cmdPull({ values, positionals: [ref] }) {
|
|
157
|
+
const render = FORMATS[values.format];
|
|
158
|
+
if (!render) throw new UsageError(`unknown format "${values.format}": dotenv or json`);
|
|
159
|
+
const { client, privateKey } = await unlock();
|
|
160
|
+
const secrets = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
|
|
161
|
+
const body = render(secrets);
|
|
162
|
+
const text = body ? `${body}\n` : '';
|
|
163
|
+
if (values.out) {
|
|
164
|
+
writePrivate(values.out, text);
|
|
165
|
+
note(`Wrote ${Object.keys(secrets).length} secrets to ${values.out}.`);
|
|
166
|
+
} else {
|
|
167
|
+
process.stdout.write(text);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdRun({ values, positionals: [ref], command: [cmd, ...args] }) {
|
|
172
|
+
const { client, privateKey } = await unlock();
|
|
173
|
+
const pulled = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
|
|
174
|
+
const { safe, blocked, kept } = splitUnsafeEnv(pulled, {
|
|
175
|
+
base: process.env,
|
|
176
|
+
allowUnsafe: values['allow-unsafe-env'],
|
|
177
|
+
override: values['override-env'],
|
|
178
|
+
});
|
|
179
|
+
if (blocked.length) {
|
|
180
|
+
note(
|
|
181
|
+
`sharedenv: not injecting ${blocked.sort().join(', ')} — these variables can make the ` +
|
|
182
|
+
'command run code or redirect its traffic (--allow-unsafe-env to inject them anyway).'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (kept.length) {
|
|
186
|
+
note(
|
|
187
|
+
`sharedenv: not overriding ${kept.sort().join(', ')} — your environment already sets ` +
|
|
188
|
+
'them (--override-env to let the vault replace them).'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
saveSession(); // the command may run for days
|
|
192
|
+
process.exitCode = await runChild(cmd, args, childEnv(process.env, safe));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function cmdList() {
|
|
196
|
+
const client = connect();
|
|
197
|
+
const nameOf = (row) => (row.safe?.name ?? row.name ?? '').toLowerCase();
|
|
198
|
+
const byName = (a, b) => nameOf(a).localeCompare(nameOf(b));
|
|
199
|
+
const safes = (await listAll(client, '/safes', 'safes')).sort(byName);
|
|
200
|
+
const { direct, team } = await listPendingShares(client);
|
|
201
|
+
const lines = [safes.length ? 'Vaults:' : 'No vaults yet.'];
|
|
202
|
+
for (const s of safes) {
|
|
203
|
+
const via = Array.isArray(s.via) && !s.via.includes('direct') ? ' (via team)' : '';
|
|
204
|
+
lines.push(` ${s.name} ${s._id} ${LEVEL[s.level] || s.level}${via}`);
|
|
205
|
+
}
|
|
206
|
+
if (direct.length || team.length) {
|
|
207
|
+
lines.push('', 'Waiting for your answer (sharedenv accept|decline <vault>):');
|
|
208
|
+
for (const p of direct.sort(byName)) {
|
|
209
|
+
lines.push(
|
|
210
|
+
` ${p.safe?.name} ${p.safeId} ${LEVEL[p.level] || p.level} from ${senderLabel(p)}` +
|
|
211
|
+
` key ${p.fingerprint || 'none on file'}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
for (const p of team.sort(byName)) {
|
|
215
|
+
lines.push(
|
|
216
|
+
` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
|
|
217
|
+
` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
|
|
218
|
+
(p.provisioned ? '' : ' [you do not hold this team key yet]')
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
say(lines.join('\n'));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A team share sealed to the team's PREVIOUS key moves to its current key on accept.
|
|
226
|
+
// Unless we sealed that key ourselves, it must be the one this machine knows for the
|
|
227
|
+
// team, or its fingerprint is confirmed — by a human, or by --team-fingerprint.
|
|
228
|
+
async function confirmTeamKey(pins, { share, teamPublicKey }, values) {
|
|
229
|
+
const subject = `team:${share.teamId}`;
|
|
230
|
+
const status = pinStatus(pins, subject, teamPublicKey);
|
|
231
|
+
const fingerprint = fingerprintOf(teamPublicKey);
|
|
232
|
+
const verdict = teamKeyVerdict(status, fingerprint, { expected: values['team-fingerprint'] });
|
|
233
|
+
if (verdict !== 'accept') {
|
|
234
|
+
note(
|
|
235
|
+
` The share moves to the current key of team "${share.team?.name || share.teamId}", ` +
|
|
236
|
+
`fingerprint ${fingerprint}` +
|
|
237
|
+
(status === 'changed'
|
|
238
|
+
? `; this machine knew ANOTHER key for it (${fingerprintOf(pins[subject])}).`
|
|
239
|
+
: ', never seen on this machine.')
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (verdict === 'refuse') {
|
|
243
|
+
note(' That is not the --team-fingerprint you gave.');
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
verdict === 'ask' &&
|
|
248
|
+
!(await confirm(
|
|
249
|
+
'Trust this team key? Confirm it with a lead of the team first. [y/N] ',
|
|
250
|
+
'pass --team-fingerprint <fingerprint>, checked with a lead of the team'
|
|
251
|
+
))
|
|
252
|
+
) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
pinKey(pins, subject, teamPublicKey);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The human step of `accept`: who sealed the share, with what key, as this machine
|
|
260
|
+
// knows it — then yes or no. A key that CHANGED since it was pinned is never taken
|
|
261
|
+
// without an explicit yes (or --accept-key-change).
|
|
262
|
+
function confirmShare(values) {
|
|
263
|
+
return async (opened) => {
|
|
264
|
+
const { share, senderKey, fingerprint } = opened;
|
|
265
|
+
const pins = store.readPins();
|
|
266
|
+
const subject = `user:${share.from?._id}`;
|
|
267
|
+
const status = pinStatus(pins, subject, senderKey);
|
|
268
|
+
note(`"${share.safe?.name}" from ${senderLabel(share)}`);
|
|
269
|
+
note(
|
|
270
|
+
` key fingerprint ${fingerprint}${status === 'match' ? ' (known on this machine)' : ''}`
|
|
271
|
+
);
|
|
272
|
+
if (status === 'changed') {
|
|
273
|
+
note(
|
|
274
|
+
` WARNING: this machine knew ANOTHER key for ${senderLabel(share)} ` +
|
|
275
|
+
`(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
|
|
276
|
+
);
|
|
277
|
+
} else if (status === 'new') {
|
|
278
|
+
note(' First time this machine sees this key: compare it with them.');
|
|
279
|
+
}
|
|
280
|
+
const verdict = senderVerdict(status, fingerprint, {
|
|
281
|
+
expected: values.fingerprint,
|
|
282
|
+
acceptKeyChange: values['accept-key-change'],
|
|
283
|
+
yes: values.yes,
|
|
284
|
+
});
|
|
285
|
+
if (verdict === 'refuse') {
|
|
286
|
+
note(' That is not the --fingerprint you gave.');
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
verdict === 'ask' &&
|
|
291
|
+
!(await confirm(
|
|
292
|
+
status === 'changed'
|
|
293
|
+
? 'Accept their NEW key? Confirm it with them first. [y/N] '
|
|
294
|
+
: 'Accept this share? [y/N] ',
|
|
295
|
+
status === 'changed'
|
|
296
|
+
? 'pass --fingerprint <fingerprint>, checked with them'
|
|
297
|
+
: 'pass --yes, or --fingerprint <fingerprint> checked with them'
|
|
298
|
+
))
|
|
299
|
+
) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (opened.teamKeyVouched === false && !(await confirmTeamKey(pins, opened, values))) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
store.writePins(pinKey(pins, subject, senderKey));
|
|
306
|
+
return true;
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function cmdAccept({ values, positionals: [ref] }) {
|
|
311
|
+
const { client, privateKey } = await unlock();
|
|
312
|
+
const safeId = await resolvePendingShare(client, ref, values.team);
|
|
313
|
+
const confirm = confirmShare(values);
|
|
314
|
+
const result = values.team
|
|
315
|
+
? await acceptTeamShare(client, privateKey, values.team, safeId, { confirm })
|
|
316
|
+
: await acceptShare(client, privateKey, safeId, { confirm });
|
|
317
|
+
note(result.accepted ? 'Accepted.' : 'Left pending.');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function cmdDecline({ values, positionals: [ref] }) {
|
|
321
|
+
const client = connect();
|
|
322
|
+
const safeId = await resolvePendingShare(client, ref, values.team);
|
|
323
|
+
if (values.team) await declineTeamShare(client, values.team, safeId);
|
|
324
|
+
else await declineShare(client, safeId);
|
|
325
|
+
note('Declined.');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Derived from YOUR private key, never the server's copy: the value a teammate checks a
|
|
329
|
+
// key against must not be one the server chose.
|
|
330
|
+
async function cmdFingerprint() {
|
|
331
|
+
const { privateKey } = await unlock();
|
|
332
|
+
say(fingerprintOf(publicKeyOf(privateKey)));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function cmdLogout() {
|
|
336
|
+
const session = store.readSession();
|
|
337
|
+
if (!session?.baseUrl) {
|
|
338
|
+
note('Not signed in.');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const client = createClient({
|
|
343
|
+
baseUrl: apiBase(session.baseUrl),
|
|
344
|
+
cookies: session.cookies,
|
|
345
|
+
userAgent: USER_AGENT,
|
|
346
|
+
});
|
|
347
|
+
const res = await client.call('POST', '/auth/sign-out');
|
|
348
|
+
if (res.status !== 401) expectOk(res, 'sign-out refused'); // 401: already over
|
|
349
|
+
note('Signed out.');
|
|
350
|
+
} catch (err) {
|
|
351
|
+
note(
|
|
352
|
+
`sharedenv: the server did not close the session (${err.message}) — it stays valid ` +
|
|
353
|
+
'there until it expires. Forgotten on this machine.'
|
|
354
|
+
);
|
|
355
|
+
process.exitCode = 1;
|
|
356
|
+
}
|
|
357
|
+
store.clearSession();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const COMMANDS = {
|
|
361
|
+
login: {
|
|
362
|
+
usage: 'login [--api <url>] [--identifier <email or username>]',
|
|
363
|
+
summary: 'Sign in (keeps the session cookies only)',
|
|
364
|
+
details:
|
|
365
|
+
'Asks for what is missing. Without a terminal, nothing is asked: give --identifier\n' +
|
|
366
|
+
'(or SHAREDENV_IDENTIFIER), SHAREDENV_PASSWORD, and SHAREDENV_OTP if the account\n' +
|
|
367
|
+
'has a second factor.',
|
|
368
|
+
options: { api: { type: 'string' }, identifier: { type: 'string' } },
|
|
369
|
+
positionals: 0,
|
|
370
|
+
run: cmdLogin,
|
|
371
|
+
},
|
|
372
|
+
pull: {
|
|
373
|
+
usage: 'pull <vault> [--out <file>] [--format dotenv|json]',
|
|
374
|
+
summary: 'Print or write the decrypted secrets',
|
|
375
|
+
details:
|
|
376
|
+
'<vault> is a vault name or id. --out writes the file readable by you only. A dotenv\n' +
|
|
377
|
+
'file is safe to source in a shell: values are quoted so that none is expanded.',
|
|
378
|
+
options: { out: { type: 'string' }, format: { type: 'string', default: 'dotenv' } },
|
|
379
|
+
positionals: 1,
|
|
380
|
+
run: cmdPull,
|
|
381
|
+
},
|
|
382
|
+
run: {
|
|
383
|
+
usage: 'run <vault> [--allow-unsafe-env] [--override-env] -- <command> [args...]',
|
|
384
|
+
summary: 'Run a command with the secrets in its environment',
|
|
385
|
+
details:
|
|
386
|
+
'The secrets reach the command in memory, never on disk. Held back unless asked:\n' +
|
|
387
|
+
'variables that make the command run code or redirect its traffic (LD_*, PATH,\n' +
|
|
388
|
+
'NODE_OPTIONS, *_PROXY...: --allow-unsafe-env), and variables your environment\n' +
|
|
389
|
+
"already sets (--override-env). The exit code is the command's.",
|
|
390
|
+
options: { 'allow-unsafe-env': { type: 'boolean' }, 'override-env': { type: 'boolean' } },
|
|
391
|
+
positionals: 1,
|
|
392
|
+
takesCommand: true,
|
|
393
|
+
run: cmdRun,
|
|
394
|
+
},
|
|
395
|
+
list: {
|
|
396
|
+
usage: 'list',
|
|
397
|
+
summary: 'Your vaults, then the shares waiting for your answer',
|
|
398
|
+
options: {},
|
|
399
|
+
positionals: 0,
|
|
400
|
+
run: cmdList,
|
|
401
|
+
},
|
|
402
|
+
accept: {
|
|
403
|
+
usage:
|
|
404
|
+
'accept <vault> [--team <id>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
|
|
405
|
+
' [--team-fingerprint <fp>]',
|
|
406
|
+
summary: 'Accept a share after checking who sealed it',
|
|
407
|
+
details:
|
|
408
|
+
"Shows the sender and their key's fingerprint, and asks. --yes: no question for a key\n" +
|
|
409
|
+
'that is new or already known here. --fingerprint: accept only if the key is this\n' +
|
|
410
|
+
'one. --accept-key-change: take a key that differs from the one known here. --team:\n' +
|
|
411
|
+
'a share to a team you lead; if its key must be confirmed, only a human or\n' +
|
|
412
|
+
'--team-fingerprint can.',
|
|
413
|
+
options: {
|
|
414
|
+
team: { type: 'string' },
|
|
415
|
+
yes: { type: 'boolean', short: 'y' },
|
|
416
|
+
fingerprint: { type: 'string' },
|
|
417
|
+
'accept-key-change': { type: 'boolean' },
|
|
418
|
+
'team-fingerprint': { type: 'string' },
|
|
419
|
+
},
|
|
420
|
+
positionals: 1,
|
|
421
|
+
run: cmdAccept,
|
|
422
|
+
},
|
|
423
|
+
decline: {
|
|
424
|
+
usage: 'decline <vault> [--team <id>]',
|
|
425
|
+
summary: 'Decline a share',
|
|
426
|
+
options: { team: { type: 'string' } },
|
|
427
|
+
positionals: 1,
|
|
428
|
+
run: cmdDecline,
|
|
429
|
+
},
|
|
430
|
+
fingerprint: {
|
|
431
|
+
usage: 'fingerprint',
|
|
432
|
+
summary: 'Your public-key fingerprint, to read to a teammate',
|
|
433
|
+
options: {},
|
|
434
|
+
positionals: 0,
|
|
435
|
+
run: cmdFingerprint,
|
|
436
|
+
},
|
|
437
|
+
logout: {
|
|
438
|
+
usage: 'logout',
|
|
439
|
+
summary: 'Close the session, on the server and here',
|
|
440
|
+
options: {},
|
|
441
|
+
positionals: 0,
|
|
442
|
+
run: cmdLogout,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const HELP = `sharedenv ${VERSION} — zero-knowledge secrets, decrypted locally
|
|
447
|
+
|
|
448
|
+
Usage: sharedenv <command> [options]
|
|
449
|
+
|
|
450
|
+
Commands:
|
|
451
|
+
${Object.entries(COMMANDS)
|
|
452
|
+
.map(([name, c]) => ` ${name.padEnd(12)} ${c.summary}`)
|
|
453
|
+
.join('\n')}
|
|
454
|
+
|
|
455
|
+
Run \`sharedenv help <command>\` for its options.
|
|
456
|
+
|
|
457
|
+
Environment:
|
|
458
|
+
SHAREDENV_API API URL (default ${DEFAULT_API})
|
|
459
|
+
SHAREDENV_IDENTIFIER email or username, for a login without questions
|
|
460
|
+
SHAREDENV_PASSWORD master password, for commands without questions
|
|
461
|
+
SHAREDENV_OTP second-factor code, for a login without questions
|
|
462
|
+
SHAREDENV_HOME where the session and the pinned keys live (default ~/.sharedenv)`;
|
|
463
|
+
|
|
464
|
+
function commandHelp(name) {
|
|
465
|
+
const command = COMMANDS[name];
|
|
466
|
+
if (!command) throw new UsageError(`unknown command "${name}" — see \`sharedenv help\``);
|
|
467
|
+
return [`Usage: sharedenv ${command.usage}`, '', command.summary + '.', command.details]
|
|
468
|
+
.filter((line) => line !== undefined)
|
|
469
|
+
.join('\n');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function main(argv) {
|
|
473
|
+
const [name, ...rest] = argv;
|
|
474
|
+
if (!name || name === 'help' || name === '-h' || name === '--help') {
|
|
475
|
+
return say(name === 'help' && rest[0] ? commandHelp(rest[0]) : HELP);
|
|
476
|
+
}
|
|
477
|
+
if (name === 'version' || name === '--version' || name === '-v') return say(VERSION);
|
|
478
|
+
|
|
479
|
+
const command = COMMANDS[name];
|
|
480
|
+
if (!command) throw new UsageError(`unknown command "${name}" — see \`sharedenv help\``);
|
|
481
|
+
const sep = rest.indexOf('--');
|
|
482
|
+
const own = command.takesCommand && sep >= 0 ? rest.slice(0, sep) : rest;
|
|
483
|
+
if (own.includes('-h') || own.includes('--help')) return say(commandHelp(name));
|
|
484
|
+
|
|
485
|
+
let parsed;
|
|
486
|
+
try {
|
|
487
|
+
parsed = parseArgs({ args: own, options: command.options, allowPositionals: true });
|
|
488
|
+
} catch (err) {
|
|
489
|
+
throw new UsageError(`${err.message.split('. ')[0]} — see \`sharedenv help ${name}\``);
|
|
490
|
+
}
|
|
491
|
+
const shell = command.takesCommand ? rest.slice(sep + 1) : [];
|
|
492
|
+
if (
|
|
493
|
+
parsed.positionals.length !== command.positionals ||
|
|
494
|
+
(command.takesCommand && (sep < 0 || shell.length === 0))
|
|
495
|
+
) {
|
|
496
|
+
throw new UsageError(`usage: sharedenv ${command.usage}`);
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
await command.run({ values: parsed.values, positionals: parsed.positionals, command: shell });
|
|
500
|
+
} finally {
|
|
501
|
+
saveSession();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
506
|
+
note(`sharedenv: ${err.message}`);
|
|
507
|
+
process.exitCode = err.exitCode ?? 1;
|
|
508
|
+
});
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// HTTP client for the sharedenv API. Node's fetch keeps no cookies, so the session
|
|
2
|
+
// cookies are threaded by hand; the jar is a plain object the caller persists and
|
|
3
|
+
// restores. Only the session travels this way — the password and the keys never do.
|
|
4
|
+
|
|
5
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
6
|
+
const TIMEOUT_MS = 60_000;
|
|
7
|
+
const PAGE_SIZE = 200; // the API's largest page
|
|
8
|
+
|
|
9
|
+
// → the canonical base URL, or throws. Plain http would carry the login verifier (enough
|
|
10
|
+
// to sign in) and the session cookies in clear: only a local server may use it.
|
|
11
|
+
export function apiBase(raw) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(String(raw));
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(`not a URL: "${raw}"`);
|
|
17
|
+
}
|
|
18
|
+
const local = url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname);
|
|
19
|
+
if (url.protocol !== 'https:' && !local) {
|
|
20
|
+
throw new Error(`refusing ${url.origin}: the API must be https (plain http only on localhost)`);
|
|
21
|
+
}
|
|
22
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
23
|
+
throw new Error('the API URL must not carry credentials, a query or a fragment');
|
|
24
|
+
}
|
|
25
|
+
return url.href.replace(/\/+$/, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A Set-Cookie that expires the cookie (sign-out, rotation) removes it from the jar.
|
|
29
|
+
function expired(attributes) {
|
|
30
|
+
const maxAge = /;\s*max-age=(-?\d+)/i.exec(attributes);
|
|
31
|
+
if (maxAge) return Number(maxAge[1]) <= 0;
|
|
32
|
+
const expires = /;\s*expires=([^;]+)/i.exec(attributes);
|
|
33
|
+
return expires ? Date.parse(expires[1]) <= Date.now() : false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createClient({ baseUrl, cookies = {}, userAgent = 'sharedenv-cli' }) {
|
|
37
|
+
const jar = { ...cookies };
|
|
38
|
+
|
|
39
|
+
function absorb(res) {
|
|
40
|
+
for (const line of res.headers.getSetCookie?.() ?? []) {
|
|
41
|
+
const [pair] = line.split(';');
|
|
42
|
+
const i = pair.indexOf('=');
|
|
43
|
+
if (i <= 0) continue;
|
|
44
|
+
const name = pair.slice(0, i).trim();
|
|
45
|
+
const value = pair.slice(i + 1).trim();
|
|
46
|
+
if (!value || expired(line)) delete jar[name];
|
|
47
|
+
else jar[name] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// → { status, body }. Throws only when the server could not be reached.
|
|
52
|
+
async function call(method, path, body) {
|
|
53
|
+
const headers = { 'User-Agent': userAgent, Accept: 'application/json' };
|
|
54
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
55
|
+
const cookie = Object.entries(jar)
|
|
56
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
57
|
+
.join('; ');
|
|
58
|
+
if (cookie) headers.Cookie = cookie;
|
|
59
|
+
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
66
|
+
redirect: 'error', // the API never redirects: a redirect would carry the session elsewhere
|
|
67
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err.name === 'TimeoutError') {
|
|
71
|
+
throw new Error(`no answer from ${baseUrl} after ${TIMEOUT_MS / 1000} s`);
|
|
72
|
+
}
|
|
73
|
+
const reason = err.cause?.code || err.cause?.message || err.message;
|
|
74
|
+
throw new Error(`cannot reach ${baseUrl} (${reason})`);
|
|
75
|
+
}
|
|
76
|
+
absorb(res);
|
|
77
|
+
let json = null;
|
|
78
|
+
try {
|
|
79
|
+
json = await res.json();
|
|
80
|
+
} catch {
|
|
81
|
+
/* no JSON body */
|
|
82
|
+
}
|
|
83
|
+
return { status: res.status, body: json };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { baseUrl, call, cookies: () => ({ ...jar }) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// An error answer of the API. Its body is { key, message, requestId }: the message is
|
|
90
|
+
// what the user reads, the request id is what support needs.
|
|
91
|
+
export class ApiError extends Error {
|
|
92
|
+
constructor(what, { status, body }) {
|
|
93
|
+
const detail = body?.message || `HTTP ${status}`;
|
|
94
|
+
const ref = body?.requestId ? ` (request ${body.requestId})` : '';
|
|
95
|
+
super(`${what}: ${detail}${ref}`);
|
|
96
|
+
this.status = status;
|
|
97
|
+
this.key = body?.key;
|
|
98
|
+
this.requestId = body?.requestId;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Answers that say what to do next rather than what failed.
|
|
103
|
+
const NEXT_STEP = {
|
|
104
|
+
1013: 'this session has not answered the second factor — run `sharedenv login` again',
|
|
105
|
+
1014: 'the second-factor confirmation is too old — run `sharedenv login` again',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// → the body of a 2xx answer; otherwise throws, with the API's own message.
|
|
109
|
+
export function expectOk(res, what) {
|
|
110
|
+
if (res.status >= 200 && res.status < 300) return res.body;
|
|
111
|
+
if (res.status === 401)
|
|
112
|
+
throw new Error('not signed in, or the session expired — run `sharedenv login`');
|
|
113
|
+
if (NEXT_STEP[res.body?.key]) throw new Error(NEXT_STEP[res.body.key]);
|
|
114
|
+
throw new ApiError(what, res);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Every row of a paginated list route. Reading one page and stopping would silently drop
|
|
118
|
+
// what lies beyond it — a process started with variables missing.
|
|
119
|
+
export async function listAll(client, path, what) {
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (let page = 1; ; page += 1) {
|
|
122
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
123
|
+
const body = expectOk(
|
|
124
|
+
await client.call('GET', `${path}${sep}limit=${PAGE_SIZE}&page=${page}`),
|
|
125
|
+
`cannot list ${what}`
|
|
126
|
+
);
|
|
127
|
+
rows.push(...(body?.data || []));
|
|
128
|
+
if (page >= (body?.pagination?.pages ?? 1)) return rows;
|
|
129
|
+
}
|
|
130
|
+
}
|