@sharedenv/cli 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +304 -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/lib/run.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Runs the child of `sharedenv run` and reports how it ended, the way a shell would: its
|
|
2
|
+
// exit code, or 128 + n when signal n ended it (a CI must not read an OOM kill as a
|
|
3
|
+
// success). The signals the CLI receives are passed on, so stopping the CLI (`docker
|
|
4
|
+
// stop`, Ctrl-C, a closed terminal) stops the child instead of orphaning it.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { constants } from 'os';
|
|
8
|
+
|
|
9
|
+
const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
10
|
+
|
|
11
|
+
// A command that could not start, with the exit code a shell gives it.
|
|
12
|
+
const UNSTARTABLE = {
|
|
13
|
+
ENOENT: ['command not found', 127],
|
|
14
|
+
EACCES: ['permission denied', 126],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function spawnError(command, err) {
|
|
18
|
+
if (!UNSTARTABLE[err.code]) return err;
|
|
19
|
+
const [what, exitCode] = UNSTARTABLE[err.code];
|
|
20
|
+
return Object.assign(new Error(`${what}: ${command}`), { exitCode });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// → Promise<exit code>. Rejects (with `exitCode`) when the command cannot be started.
|
|
24
|
+
export function runChild(command, args, env, { stdio = 'inherit' } = {}) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const child = spawn(command, args, { stdio, env });
|
|
27
|
+
const forward = (signal) => child.kill(signal);
|
|
28
|
+
let settled = false;
|
|
29
|
+
const settle = (fn, value) => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
for (const signal of FORWARDED) process.off(signal, forward);
|
|
33
|
+
fn(value);
|
|
34
|
+
};
|
|
35
|
+
for (const signal of FORWARDED) process.on(signal, forward);
|
|
36
|
+
child.on('error', (err) => settle(reject, spawnError(command, err)));
|
|
37
|
+
child.on('exit', (code, signal) =>
|
|
38
|
+
settle(resolve, signal ? 128 + (constants.signals[signal] ?? 0) : code ?? 0)
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
package/lib/safes.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Reading a safe: find it, recover its key (DEK), decrypt every secret — locally. The
|
|
2
|
+
// server only ever returns ciphertext.
|
|
3
|
+
|
|
4
|
+
import { Wallet, openSecret } from './crypto.js';
|
|
5
|
+
import { expectOk, listAll } from './api.js';
|
|
6
|
+
import { PendingShareError, listPendingShares } from './shares.js';
|
|
7
|
+
|
|
8
|
+
const wallet = new Wallet();
|
|
9
|
+
const SAFE_ID = /^[a-f0-9]{24}$/i;
|
|
10
|
+
|
|
11
|
+
// Secret names the API accepts. Checked again here: the server is the party
|
|
12
|
+
// zero-knowledge does not trust, and a name lands in a .env line or an environment.
|
|
13
|
+
const SECRET_NAME = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
|
|
15
|
+
// A safe by id, or by (case-insensitive) name. A share still waiting for the caller's
|
|
16
|
+
// answer is not in their list: it is named as such rather than reported missing.
|
|
17
|
+
export async function resolveSafe(client, ref) {
|
|
18
|
+
if (SAFE_ID.test(ref)) return ref;
|
|
19
|
+
const name = ref.toLowerCase();
|
|
20
|
+
const safes = await listAll(client, '/safes', 'safes');
|
|
21
|
+
const match = safes.find((s) => s.name?.toLowerCase() === name);
|
|
22
|
+
if (match) return String(match._id);
|
|
23
|
+
const pending = (await listPendingShares(client)).direct.find(
|
|
24
|
+
(p) => p.safe?.name?.toLowerCase() === name
|
|
25
|
+
);
|
|
26
|
+
if (pending) throw new PendingShareError(pending);
|
|
27
|
+
throw new Error(`no vault named "${ref}"`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Every DEK the caller's paths to a safe yield, CURRENT first: a direct envelope, or a
|
|
31
|
+
// team grant (own key → team key → DEK). More than one only while a key rotation is in
|
|
32
|
+
// flight: each row then also carries the blob it held before (`previous…`), and secrets
|
|
33
|
+
// may sit under either key.
|
|
34
|
+
export function resolveDeks(privateKey, safe) {
|
|
35
|
+
const deks = [];
|
|
36
|
+
const add = (opened) => {
|
|
37
|
+
const hex = opened ? opened.toString('hex') : null;
|
|
38
|
+
if (hex && !deks.includes(hex)) deks.push(hex);
|
|
39
|
+
};
|
|
40
|
+
const m = safe.membership;
|
|
41
|
+
for (const blob of [m?.sealedSafeKey, m?.previousSealedSafeKey]) {
|
|
42
|
+
if (blob) add(wallet.open(privateKey, blob));
|
|
43
|
+
}
|
|
44
|
+
for (const grant of safe.teamAccess || []) {
|
|
45
|
+
const teamKeys = [grant.sealedTeamKey, grant.previousSealedTeamKey]
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((blob) => wallet.open(privateKey, blob))
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((opened) => opened.toString('utf8'));
|
|
50
|
+
for (const blob of [grant.sealedSafeKey, grant.previousSealedSafeKey]) {
|
|
51
|
+
if (!blob) continue;
|
|
52
|
+
const opened = teamKeys.map((teamKey) => wallet.open(teamKey, blob)).find(Boolean);
|
|
53
|
+
if (opened) add(opened);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return deks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// AES-GCM authenticates: the first key that opens a value is the one it was written
|
|
60
|
+
// under. `place` is where the value was read: a blob moved there from another secret or
|
|
61
|
+
// another safe is refused.
|
|
62
|
+
function decryptWithAny(deks, blob, place) {
|
|
63
|
+
let misplaced = null;
|
|
64
|
+
for (const dek of deks) {
|
|
65
|
+
try {
|
|
66
|
+
return openSecret(dek, blob, place);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (/belongs to/.test(err.message)) misplaced = err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (misplaced) throw new Error(`refusing "${place.key}": ${misplaced.message}`);
|
|
72
|
+
throw new Error(
|
|
73
|
+
`cannot decrypt "${place.key}" — a key rotation may be in progress, retry shortly`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// → { KEY: value } — every secret of the safe, decrypted. All or nothing: one value that
|
|
78
|
+
// cannot be read fails the pull, so `run` never starts a process with a variable missing.
|
|
79
|
+
export async function pullSecrets(client, privateKey, safeId) {
|
|
80
|
+
const res = await client.call('GET', `/safes/${safeId}`);
|
|
81
|
+
if (res.status === 403 && res.body?.key === 'sharePending') {
|
|
82
|
+
const pending = (await listPendingShares(client)).direct.find(
|
|
83
|
+
(p) => String(p.safeId) === String(safeId)
|
|
84
|
+
);
|
|
85
|
+
throw pending
|
|
86
|
+
? new PendingShareError(pending)
|
|
87
|
+
: new Error('this vault is shared with you but waits for your answer: run `sharedenv list`');
|
|
88
|
+
}
|
|
89
|
+
const safe = expectOk(res, 'cannot open the vault');
|
|
90
|
+
|
|
91
|
+
const deks = resolveDeks(privateKey, safe);
|
|
92
|
+
if (deks.length === 0) {
|
|
93
|
+
throw new Error('no key to this vault on your account (not a member, or not provisioned yet)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const secrets = {};
|
|
97
|
+
for (const row of await listAll(client, `/secrets/${safeId}`, 'secrets')) {
|
|
98
|
+
if (typeof row.key !== 'string' || !SECRET_NAME.test(row.key)) {
|
|
99
|
+
throw new Error(`refusing a secret named ${JSON.stringify(row.key)}: not a variable name`);
|
|
100
|
+
}
|
|
101
|
+
if (Object.hasOwn(secrets, row.key))
|
|
102
|
+
throw new Error(`the server sent "${row.key}" twice for this vault`);
|
|
103
|
+
secrets[row.key] = decryptWithAny(deks, row.ciphertextValue, { safeId, key: row.key });
|
|
104
|
+
}
|
|
105
|
+
return secrets;
|
|
106
|
+
}
|
package/lib/shares.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Shares received. A share someone sends waits for its recipient: until accepted it
|
|
2
|
+
// opens nothing. To accept, the envelope is opened with the SENDER's directory key —
|
|
3
|
+
// `open(…, { from })` — never with the key the envelope names itself (any key can seal
|
|
4
|
+
// a DEK to ours): a share not sealed by the person it claims to come from is refused.
|
|
5
|
+
|
|
6
|
+
import { Wallet, fingerprintOf } from './crypto.js';
|
|
7
|
+
import { expectOk, listAll } from './api.js';
|
|
8
|
+
|
|
9
|
+
const wallet = new Wallet();
|
|
10
|
+
|
|
11
|
+
export class PendingShareError extends Error {
|
|
12
|
+
constructor(share) {
|
|
13
|
+
super(
|
|
14
|
+
`"${share.safe?.name || share.safeId}" is a share from ${senderLabel(share)} waiting for ` +
|
|
15
|
+
`your answer: run \`sharedenv list\` to review it, then \`sharedenv accept ` +
|
|
16
|
+
`${share.safeId}\` (or \`decline\`)`
|
|
17
|
+
);
|
|
18
|
+
this.code = 'SHARE_PENDING';
|
|
19
|
+
this.share = share;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function senderLabel(share) {
|
|
24
|
+
const from = share?.from || {};
|
|
25
|
+
const name = [from.firstName, from.lastName].filter(Boolean).join(' ').trim();
|
|
26
|
+
if (!from.username) return String(from._id || 'unknown');
|
|
27
|
+
return name ? `${name} (${from.username})` : from.username;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// → { direct: [...], team: [...] }. Each row carries `from` (name + directory key) and
|
|
31
|
+
// `fingerprint`, computed here from that key, never taken from the server.
|
|
32
|
+
export async function listPendingShares(client) {
|
|
33
|
+
const withFingerprint = (rows) =>
|
|
34
|
+
rows.map((row) => ({
|
|
35
|
+
...row,
|
|
36
|
+
fingerprint: row.from?.publicKey ? fingerprintOf(row.from.publicKey) : null,
|
|
37
|
+
}));
|
|
38
|
+
const direct = await listAll(client, '/shares', 'pending shares');
|
|
39
|
+
const team = await listAll(client, '/team-shares', 'pending team shares');
|
|
40
|
+
return { direct: withFingerprint(direct), team: withFingerprint(team) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A pending share by safe id, or by the name of its safe (`teamId`: among the shares to
|
|
44
|
+
// that team). → the safe id.
|
|
45
|
+
export async function resolvePendingShare(client, ref, teamId) {
|
|
46
|
+
if (/^[a-f0-9]{24}$/i.test(ref)) return ref;
|
|
47
|
+
const { direct, team } = await listPendingShares(client);
|
|
48
|
+
const rows = teamId ? team.filter((p) => String(p.teamId) === String(teamId)) : direct;
|
|
49
|
+
const match = rows.find((p) => p.safe?.name?.toLowerCase() === ref.toLowerCase());
|
|
50
|
+
if (!match) throw new Error(`no pending share named "${ref}"${teamId ? ' for this team' : ''}`);
|
|
51
|
+
return String(match.safeId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function senderKeyOf(share) {
|
|
55
|
+
const key = share?.from?.publicKey;
|
|
56
|
+
if (!key) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${senderLabel(share)} has no key on file any more (keyring reset?): ask them to share again, or decline`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return String(key).toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// → { share, senderKey, fingerprint }; throws when the seal is not the sender's.
|
|
65
|
+
export async function openPendingShare(client, privateKey, safeId) {
|
|
66
|
+
const { share } = expectOk(await client.call('GET', `/shares/${safeId}`), 'no pending share');
|
|
67
|
+
const senderKey = senderKeyOf(share);
|
|
68
|
+
if (!wallet.open(privateKey, share.sealedSafeKey, { from: senderKey })) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`this share was NOT sealed by ${senderLabel(share)} (key ${fingerprintOf(senderKey)}): decline it`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return { share, senderKey, fingerprint: fingerprintOf(senderKey) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check, then accept. `confirm(opened)` (async → boolean) is where a human compares the
|
|
77
|
+
// fingerprint; a no leaves the share pending.
|
|
78
|
+
export async function acceptShare(client, privateKey, safeId, { confirm } = {}) {
|
|
79
|
+
const opened = await openPendingShare(client, privateKey, safeId);
|
|
80
|
+
if (confirm && !(await confirm(opened))) return { ...opened, accepted: false };
|
|
81
|
+
expectOk(await client.call('POST', `/shares/${safeId}/accept`), 'cannot accept the share');
|
|
82
|
+
return { ...opened, accepted: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function declineShare(client, safeId) {
|
|
86
|
+
expectOk(await client.call('POST', `/shares/${safeId}/decline`), 'cannot decline the share');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A pending TEAM grant, opened by someone who answers for the team and holds its key:
|
|
91
|
+
// own key → team key (current, or the previous one: a team-key rotation leaves pending
|
|
92
|
+
// grants where they are) → DEK, checked against the sender's key.
|
|
93
|
+
//
|
|
94
|
+
// → { share, dek, senderKey, fingerprint, teamPublicKey, teamKeyVouched }, where
|
|
95
|
+
// `teamPublicKey` is the team's CURRENT key, derived from the private key we hold.
|
|
96
|
+
// Holding it proves nothing on its own (any key can seal a team key to ours, and the
|
|
97
|
+
// keyring row is the server's): `teamKeyVouched` is false when the DEK opened with the
|
|
98
|
+
// PREVIOUS key and the current one was not sealed by us — the caller must then check
|
|
99
|
+
// it before the DEK moves to it (acceptTeamShare).
|
|
100
|
+
export async function openPendingTeamShare(client, privateKey, teamId, safeId) {
|
|
101
|
+
const { share } = expectOk(
|
|
102
|
+
await client.call('GET', `/team-shares/${teamId}/${safeId}`),
|
|
103
|
+
'no pending team share'
|
|
104
|
+
);
|
|
105
|
+
const senderKey = senderKeyOf(share);
|
|
106
|
+
const current = share.sealedTeamKey && wallet.open(privateKey, share.sealedTeamKey);
|
|
107
|
+
if (!current) throw new Error('cannot open your team key for this team');
|
|
108
|
+
const previous =
|
|
109
|
+
share.previousSealedTeamKey && wallet.open(privateKey, share.previousSealedTeamKey);
|
|
110
|
+
const teamKeys = [current, previous].filter(Boolean).map((key) => key.toString('utf8'));
|
|
111
|
+
|
|
112
|
+
let dek = false;
|
|
113
|
+
let openedWith = -1;
|
|
114
|
+
for (const [i, teamKey] of teamKeys.entries()) {
|
|
115
|
+
dek = wallet.open(teamKey, share.sealedSafeKey, { from: senderKey });
|
|
116
|
+
if (dek) {
|
|
117
|
+
openedWith = i;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!dek) {
|
|
122
|
+
const opensAnyway = teamKeys.some((key) => wallet.open(key, share.sealedSafeKey));
|
|
123
|
+
throw new Error(
|
|
124
|
+
opensAnyway
|
|
125
|
+
? `this share was NOT sealed by ${senderLabel(share)} (key ${fingerprintOf(senderKey)}): decline it`
|
|
126
|
+
: 'this share is sealed to a team key replaced since: ask the sender to share it again, or decline'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const ownKey = wallet.keypass.publicFromPrivate(privateKey);
|
|
130
|
+
return {
|
|
131
|
+
share,
|
|
132
|
+
dek,
|
|
133
|
+
senderKey,
|
|
134
|
+
fingerprint: fingerprintOf(senderKey),
|
|
135
|
+
teamPublicKey: wallet.keypass.publicFromPrivate(teamKeys[0]),
|
|
136
|
+
teamKeyVouched:
|
|
137
|
+
openedWith === 0 || Boolean(wallet.open(privateKey, share.sealedTeamKey, { from: ownKey })),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check, re-seal to the team's current key (by us), accept. The grant version we opened
|
|
142
|
+
// travels with it: a rotation in between makes it a 409, never an old DEK.
|
|
143
|
+
export async function acceptTeamShare(client, privateKey, teamId, safeId, { confirm } = {}) {
|
|
144
|
+
const opened = await openPendingTeamShare(client, privateKey, teamId, safeId);
|
|
145
|
+
// The DEK never moves to a team key nobody vouched for: `confirm` must check it.
|
|
146
|
+
if (!opened.teamKeyVouched && !confirm) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'the current team key is not vouched for on this machine: accept interactively'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (confirm && !(await confirm(opened))) return { ...opened, accepted: false };
|
|
152
|
+
const sealedSafeKey = wallet.seal(privateKey, opened.teamPublicKey, opened.dek);
|
|
153
|
+
expectOk(
|
|
154
|
+
await client.call('POST', `/team-shares/${teamId}/${safeId}/accept`, {
|
|
155
|
+
sealedSafeKey: JSON.stringify(sealedSafeKey),
|
|
156
|
+
keyVersion: opened.share.keyVersion,
|
|
157
|
+
}),
|
|
158
|
+
'cannot accept the team share'
|
|
159
|
+
);
|
|
160
|
+
return { ...opened, accepted: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function declineTeamShare(client, teamId, safeId) {
|
|
164
|
+
expectOk(
|
|
165
|
+
await client.call('POST', `/team-shares/${teamId}/${safeId}/decline`),
|
|
166
|
+
'cannot decline the team share'
|
|
167
|
+
);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What the CLI keeps on disk, under ~/.sharedenv (or $SHAREDENV_HOME): the session
|
|
2
|
+
// cookies, and the public keys this machine has vouched for (trust.js). Never the
|
|
3
|
+
// password, never a private key, never a secret.
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
import {
|
|
7
|
+
closeSync,
|
|
8
|
+
fsyncSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeSync,
|
|
15
|
+
} from 'fs';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { basename, dirname, join } from 'path';
|
|
18
|
+
|
|
19
|
+
// Writes `data` to `file`, readable by its owner only. Through a fresh temporary file
|
|
20
|
+
// then a rename: the target is never readable by others, not even for an instant (an
|
|
21
|
+
// existing .env is often 0644), a symlink planted at its path is replaced rather than
|
|
22
|
+
// followed, and a crash never leaves half a file.
|
|
23
|
+
export function writePrivate(file, data) {
|
|
24
|
+
const tmp = join(dirname(file), `.${basename(file)}.${randomBytes(6).toString('hex')}.tmp`);
|
|
25
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
26
|
+
try {
|
|
27
|
+
writeSync(fd, data);
|
|
28
|
+
fsyncSync(fd);
|
|
29
|
+
} finally {
|
|
30
|
+
closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
renameSync(tmp, file);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
rmSync(tmp, { force: true });
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJson(file) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createStore(dir = process.env.SHAREDENV_HOME || join(homedir(), '.sharedenv')) {
|
|
49
|
+
const sessionFile = join(dir, 'session.json');
|
|
50
|
+
const pinsFile = join(dir, 'pins.json');
|
|
51
|
+
const save = (file, value) => {
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
writePrivate(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
dir,
|
|
58
|
+
// → { baseUrl, cookies } | null
|
|
59
|
+
readSession: () => readJson(sessionFile),
|
|
60
|
+
writeSession: (session) => save(sessionFile, session),
|
|
61
|
+
clearSession: () => rmSync(sessionFile, { force: true }),
|
|
62
|
+
// → { [subject]: publicKey }
|
|
63
|
+
readPins: () => readJson(pinsFile) || {},
|
|
64
|
+
writePins: (pins) => save(pinsFile, pins),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/lib/trust.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Whose keys this machine believes. The server is the key directory, and a directory
|
|
2
|
+
// can lie; a key legitimately changes in one case only (its owner reset their keyring,
|
|
3
|
+
// or a team rotated its key) — exactly the event a lying server would fake. So:
|
|
4
|
+
//
|
|
5
|
+
// - trust on first use: the first key seen for someone (`user:<id>`, `team:<id>`) is
|
|
6
|
+
// pinned on this machine (~/.sharedenv/pins.json), and a DIFFERENT key later is
|
|
7
|
+
// never taken silently;
|
|
8
|
+
// - a fingerprint given on the command line (checked with its owner over another
|
|
9
|
+
// channel) settles the question for scripts, instead of a blind yes.
|
|
10
|
+
//
|
|
11
|
+
// Pure functions: the bin asks the questions and persists the pins.
|
|
12
|
+
|
|
13
|
+
// → 'new' (never seen here) | 'match' | 'changed'
|
|
14
|
+
export function pinStatus(pins, subject, publicKey) {
|
|
15
|
+
const pinned = pins?.[subject];
|
|
16
|
+
if (!pinned) return 'new';
|
|
17
|
+
return pinned === String(publicKey).toLowerCase() ? 'match' : 'changed';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pinKey(pins, subject, publicKey) {
|
|
21
|
+
pins[subject] = String(publicKey).toLowerCase();
|
|
22
|
+
return pins;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const normalized = (fingerprint) => String(fingerprint).replace(/\s+/g, '').toUpperCase();
|
|
26
|
+
|
|
27
|
+
export const sameFingerprint = (a, b) => normalized(a) === normalized(b);
|
|
28
|
+
|
|
29
|
+
// The sender of a share. → 'accept' | 'ask' | 'refuse'
|
|
30
|
+
// expected — `--fingerprint`: the key must be this one, nothing else counts;
|
|
31
|
+
// acceptKeyChange — `--accept-key-change`: take a key that differs from the pin;
|
|
32
|
+
// yes — `--yes`: no question for a key that is new or already pinned.
|
|
33
|
+
export function senderVerdict(status, fingerprint, { expected, acceptKeyChange, yes } = {}) {
|
|
34
|
+
if (expected) return sameFingerprint(expected, fingerprint) ? 'accept' : 'refuse';
|
|
35
|
+
if (status === 'changed') return acceptKeyChange ? 'accept' : 'ask';
|
|
36
|
+
return yes ? 'accept' : 'ask';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The team key a share moves to on accept, when no seal of ours vouches for it: a planted
|
|
40
|
+
// key would receive the safe's key. Pinned, or confirmed by its fingerprint — by a human
|
|
41
|
+
// or by `--team-fingerprint` — and no flag says yes blindly. → 'accept' | 'ask' | 'refuse'
|
|
42
|
+
export function teamKeyVerdict(status, fingerprint, { expected } = {}) {
|
|
43
|
+
if (expected) return sameFingerprint(expected, fingerprint) ? 'accept' : 'refuse';
|
|
44
|
+
return status === 'match' ? 'accept' : 'ask';
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sharedenv/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "sharedenv CLI — pull and run with your zero-knowledge secrets, decrypted locally.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"secrets",
|
|
9
|
+
"env",
|
|
10
|
+
"dotenv",
|
|
11
|
+
"zero-knowledge",
|
|
12
|
+
"e2ee",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://sharedenv.com",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/SharedEnv/cli.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/SharedEnv/cli/issues"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"sharedenv": "bin/sharedenv.js"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20.19"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@noble/curves": "^2.2.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin",
|
|
34
|
+
"lib",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"NOTICE"
|
|
38
|
+
]
|
|
39
|
+
}
|