@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/lib/api.js
CHANGED
|
@@ -101,6 +101,10 @@ export class ApiError extends Error {
|
|
|
101
101
|
|
|
102
102
|
// Answers that say what to do next rather than what failed.
|
|
103
103
|
const NEXT_STEP = {
|
|
104
|
+
1004:
|
|
105
|
+
'your email is not confirmed yet: open the link emailed to you when the account was ' +
|
|
106
|
+
'created, then run `sharedenv login` again (no email? sign in on the web app, it sends ' +
|
|
107
|
+
'a new link)',
|
|
104
108
|
1013: 'this session has not answered the second factor — run `sharedenv login` again',
|
|
105
109
|
1014: 'the second-factor confirmation is too old — run `sharedenv login` again',
|
|
106
110
|
};
|
|
@@ -128,3 +132,19 @@ export async function listAll(client, path, what) {
|
|
|
128
132
|
if (page >= (body?.pagination?.pages ?? 1)) return rows;
|
|
129
133
|
}
|
|
130
134
|
}
|
|
135
|
+
|
|
136
|
+
// GET /org/list pages the organizations you OWN (`data`), and returns those you are in
|
|
137
|
+
// without owning them whole (`member`, on every page): both, once each.
|
|
138
|
+
export async function listOrgs(client) {
|
|
139
|
+
const orgs = new Map();
|
|
140
|
+
for (let page = 1; ; page += 1) {
|
|
141
|
+
const body = expectOk(
|
|
142
|
+
await client.call('GET', `/org/list?limit=${PAGE_SIZE}&page=${page}`),
|
|
143
|
+
'cannot list organizations'
|
|
144
|
+
);
|
|
145
|
+
for (const org of [...(body?.data || []), ...(body?.member || [])]) {
|
|
146
|
+
orgs.set(String(org._id), org);
|
|
147
|
+
}
|
|
148
|
+
if (page >= (body?.pagination?.pages ?? 1)) return [...orgs.values()];
|
|
149
|
+
}
|
|
150
|
+
}
|
package/lib/auth.js
CHANGED
|
@@ -64,7 +64,12 @@ export async function unlockAccount(client, password) {
|
|
|
64
64
|
try {
|
|
65
65
|
privateKey = unwrapPrivateKey(keyring.encPrivateKey, password, keyring.kdf);
|
|
66
66
|
} catch {
|
|
67
|
-
|
|
67
|
+
const err = new Error(
|
|
68
|
+
'this password does not unlock the vault. If you reset it, the vault is still sealed ' +
|
|
69
|
+
'under the old one: sign in once on the web app and enter your recovery code there'
|
|
70
|
+
);
|
|
71
|
+
err.code = 'VAULT_SEALED';
|
|
72
|
+
throw err;
|
|
68
73
|
}
|
|
69
74
|
// The directory must name the key we hold: a row naming another public key would let
|
|
70
75
|
// the server choose who receives what is sealed to "us".
|
|
@@ -73,3 +78,9 @@ export async function unlockAccount(client, password) {
|
|
|
73
78
|
}
|
|
74
79
|
return { privateKey, userId: keyring.userId };
|
|
75
80
|
}
|
|
81
|
+
|
|
82
|
+
// → your user id, or null (no keyring yet). Needs the session, not the password.
|
|
83
|
+
export async function whoAmI(client) {
|
|
84
|
+
const res = await client.call('GET', '/keyring/me');
|
|
85
|
+
return res.status === 200 && res.body?.userId ? String(res.body.userId) : null;
|
|
86
|
+
}
|
package/lib/crypto/kt.js
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
// n". Whoever already knows a subject and a key (the person sealing to them) computes
|
|
22
22
|
// the same hashes. Subjects: `user:<id>`, `team:<id>`.
|
|
23
23
|
//
|
|
24
|
-
// It only does arithmetic on hashes
|
|
25
|
-
//
|
|
24
|
+
// It only does arithmetic on hashes, and the bookkeeping of what a client checked (the
|
|
25
|
+
// record it keeps between two reads, below). No I/O: every client (browser, command
|
|
26
|
+
// line) reads the log its own way and computes the same results from the same entries.
|
|
26
27
|
|
|
27
28
|
import crypto from 'crypto';
|
|
28
29
|
|
|
@@ -98,3 +99,127 @@ export function replay(view, entries, { watch = {} } = {}) {
|
|
|
98
99
|
// Is `publicKeyHex` the last key the log holds for `subject`?
|
|
99
100
|
export const isLogged = (view, subject, publicKeyHex) =>
|
|
100
101
|
view.latest[subjectHash(subject)] === keyHash(publicKeyHex);
|
|
102
|
+
|
|
103
|
+
// ── What a client keeps between two reads ────────────────────────────────────────
|
|
104
|
+
// One record per log: the view checked, how far each watched subject was checked (by
|
|
105
|
+
// its hash), and the alerts its owner has not acknowledged yet:
|
|
106
|
+
// { seq, hash, latest, watched: { [subjectHash]: seq }, alerts: [{ kind, subject, seq }] }
|
|
107
|
+
// kind 'foreign': entry `seq` logged a key that is not ours under `subject`.
|
|
108
|
+
// kind 'unlogged': when the log stood at `seq`, our key was not the last one of `subject`.
|
|
109
|
+
// An alert stays until acknowledged, whatever later reads find: a substitution that was
|
|
110
|
+
// put back is still the trace of an attack, and a warning printed once (in a CI log, in
|
|
111
|
+
// a closed tab) is easily missed.
|
|
112
|
+
|
|
113
|
+
export const emptyRecord = () => ({ ...emptyView(), watched: {}, alerts: [] });
|
|
114
|
+
|
|
115
|
+
const isAlert = (a) =>
|
|
116
|
+
a &&
|
|
117
|
+
(a.kind === 'foreign' || a.kind === 'unlogged') &&
|
|
118
|
+
typeof a.subject === 'string' &&
|
|
119
|
+
Number.isInteger(a.seq);
|
|
120
|
+
|
|
121
|
+
// -> the record in what storage gave back; anything unusable starts over (the next read
|
|
122
|
+
// replays the log from its first entry). A view kept by an earlier client, without
|
|
123
|
+
// `watched`, is read again from the start once.
|
|
124
|
+
export function toRecord(stored) {
|
|
125
|
+
const sane =
|
|
126
|
+
stored &&
|
|
127
|
+
Number.isInteger(stored.seq) &&
|
|
128
|
+
stored.seq >= 0 &&
|
|
129
|
+
typeof stored.hash === 'string' &&
|
|
130
|
+
stored.latest &&
|
|
131
|
+
typeof stored.latest === 'object';
|
|
132
|
+
if (!sane) return emptyRecord();
|
|
133
|
+
const watched = {};
|
|
134
|
+
if (stored.watched && typeof stored.watched === 'object' && !Array.isArray(stored.watched)) {
|
|
135
|
+
for (const [hashed, seq] of Object.entries(stored.watched)) {
|
|
136
|
+
if (Number.isInteger(seq) && seq >= 0 && seq <= stored.seq) watched[hashed] = seq;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
seq: stored.seq,
|
|
141
|
+
hash: stored.hash,
|
|
142
|
+
latest: stored.latest,
|
|
143
|
+
watched,
|
|
144
|
+
alerts: Array.isArray(stored.alerts) ? stored.alerts.filter(isAlert) : [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const checkedUpTo = (record, subject) => record.watched[subjectHash(subject)] ?? 0;
|
|
149
|
+
|
|
150
|
+
// Where the next read of the log starts: right after what was checked, unless `watch`
|
|
151
|
+
// names a subject that was not checked that far (never watched, or left out of a read
|
|
152
|
+
// since). Nobody read that part of the log on its behalf, so it is read again from the
|
|
153
|
+
// first entry.
|
|
154
|
+
export function readFrom(record, watch = {}) {
|
|
155
|
+
return Object.keys(watch).every((subject) => checkedUpTo(record, subject) === record.seq)
|
|
156
|
+
? record.seq
|
|
157
|
+
: 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Folds the entries read after `from` (readFrom) up to `head` into `record`.
|
|
161
|
+
// -> { record, changed, view, foreign, unlogged, alerts }
|
|
162
|
+
// record what to keep. A read that watched nothing gives back the record as it
|
|
163
|
+
// was: had it moved the position, the entries it went past would never be
|
|
164
|
+
// checked against anyone's name.
|
|
165
|
+
// changed whether `record` is worth writing back
|
|
166
|
+
// view the log as it now stands, kept or not (isLogged reads it)
|
|
167
|
+
// foreign, unlogged what this read found
|
|
168
|
+
// alerts every alert not acknowledged yet, those found by this read included
|
|
169
|
+
// Throws KtError BROKEN: entries that do not chain or do not reach `head`, a log
|
|
170
|
+
// shorter than what was checked, or a replay from the start that disagrees with it.
|
|
171
|
+
export function absorb(record, from, entries, head, { watch = {} } = {}) {
|
|
172
|
+
if (head.seq < record.seq) {
|
|
173
|
+
throw new KtError('BROKEN', 'the log is shorter than what was already checked');
|
|
174
|
+
}
|
|
175
|
+
const start = from === 0 ? emptyView() : record;
|
|
176
|
+
const replayed = replay(start, entries, { watch });
|
|
177
|
+
const { view } = replayed;
|
|
178
|
+
if (view.seq !== head.seq || view.hash !== head.hash) {
|
|
179
|
+
throw new KtError('BROKEN', 'the log head does not match its entries');
|
|
180
|
+
}
|
|
181
|
+
// Read again from the start: the entry already checked must still be the same one.
|
|
182
|
+
if (from === 0 && record.seq > 0 && entries[record.seq - 1]?.hash !== record.hash) {
|
|
183
|
+
throw new KtError('BROKEN', 'the log no longer agrees with what was already checked');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const subjects = Object.keys(watch);
|
|
187
|
+
if (subjects.length === 0) {
|
|
188
|
+
return { record, changed: false, view, foreign: [], unlogged: [], alerts: record.alerts };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Read again from the start, what a subject already had checked is not news (and its
|
|
192
|
+
// alerts may have been acknowledged since).
|
|
193
|
+
const foreign = replayed.foreign.filter((f) => f.seq > checkedUpTo(record, f.subject));
|
|
194
|
+
const { unlogged } = replayed;
|
|
195
|
+
const alerts = [...record.alerts];
|
|
196
|
+
const known = (a) =>
|
|
197
|
+
alerts.some(
|
|
198
|
+
(b) =>
|
|
199
|
+
b.kind === a.kind && b.subject === a.subject && (a.kind === 'unlogged' || b.seq === a.seq)
|
|
200
|
+
);
|
|
201
|
+
const found = [
|
|
202
|
+
...foreign.map((f) => ({ kind: 'foreign', subject: f.subject, seq: f.seq })),
|
|
203
|
+
...unlogged.map((subject) => ({ kind: 'unlogged', subject, seq: head.seq })),
|
|
204
|
+
];
|
|
205
|
+
for (const a of found) if (!known(a)) alerts.push(a);
|
|
206
|
+
|
|
207
|
+
// The subjects watched now are checked up to the head; the others keep how far they
|
|
208
|
+
// were, and the next read that watches them again starts over.
|
|
209
|
+
const watched = { ...record.watched };
|
|
210
|
+
for (const subject of subjects) watched[subjectHash(subject)] = view.seq;
|
|
211
|
+
const next = { ...view, watched, alerts };
|
|
212
|
+
const sameWatched =
|
|
213
|
+
Object.keys(watched).length === Object.keys(record.watched).length &&
|
|
214
|
+
Object.entries(watched).every(([hashed, seq]) => record.watched[hashed] === seq);
|
|
215
|
+
const changed =
|
|
216
|
+
next.seq !== record.seq ||
|
|
217
|
+
next.hash !== record.hash ||
|
|
218
|
+
alerts.length !== record.alerts.length ||
|
|
219
|
+
!sameWatched;
|
|
220
|
+
return { record: next, changed, view, foreign, unlogged, alerts };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The owner has read the alerts: the record without them. Whatever is still true is
|
|
224
|
+
// found again by the next read.
|
|
225
|
+
export const acknowledge = (record) => ({ ...record, alerts: [] });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Safe DEK helper. A "safe" is a container with one random 256-bit data
|
|
2
|
+
// encryption key (DEK). Every secret value is AES-256-GCM encrypted under that
|
|
3
|
+
// DEK, bound to its place (secretBlob.js); the DEK itself only ever travels sealed
|
|
4
|
+
// (see Wallet.seal).
|
|
5
|
+
//
|
|
6
|
+
// Runs CLIENT-SIDE. A server only ever sees the resulting ciphertext blobs.
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
|
|
10
|
+
// → 32-byte DEK as a hex string.
|
|
11
|
+
export function generateSafeKey() {
|
|
12
|
+
return crypto.randomBytes(32).toString('hex');
|
|
13
|
+
}
|
package/lib/crypto.js
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
deriveAuthVerifier,
|
|
7
7
|
unwrapPrivateKey,
|
|
8
8
|
} from './crypto/kdf.js';
|
|
9
|
-
export { openSecret } from './crypto/secretBlob.js';
|
|
9
|
+
export { openSecret, sealSecret } from './crypto/secretBlob.js';
|
|
10
|
+
export { generateSafeKey } from './crypto/safeKey.js';
|
|
10
11
|
export { fingerprintOf } from './crypto/fingerprint.js';
|
|
11
|
-
export {
|
|
12
|
+
export { absorb, acknowledge, isLogged, readFrom, toRecord } from './crypto/kt.js';
|
package/lib/link.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The vault a project uses: `.sharedenv.json`, written by `sharedenv link` in the
|
|
2
|
+
// project's directory and found from there or any directory below it (like .nvmrc). It
|
|
3
|
+
// holds the vault's id and nothing else - no secret - so it can be committed, and the
|
|
4
|
+
// whole team works on the same vault. An id grants nothing: the server still decides
|
|
5
|
+
// who opens the vault, and every command says which vault it used.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
export const LINK_FILE = '.sharedenv.json';
|
|
11
|
+
const VAULT_ID = /^[a-f0-9]{24}$/i;
|
|
12
|
+
|
|
13
|
+
// → { file, vault } — the nearest link from `dir` up, or null when there is none.
|
|
14
|
+
export function findLink(dir = process.cwd()) {
|
|
15
|
+
for (let current = path.resolve(dir); ; current = path.dirname(current)) {
|
|
16
|
+
const file = path.join(current, LINK_FILE);
|
|
17
|
+
let text = null;
|
|
18
|
+
try {
|
|
19
|
+
text = readFileSync(file, 'utf8');
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code !== 'ENOENT') throw new Error(`cannot read ${file} (${err.code})`);
|
|
22
|
+
}
|
|
23
|
+
if (text !== null) return { file, vault: vaultOf(file, text) };
|
|
24
|
+
if (path.dirname(current) === current) return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function vaultOf(file, text) {
|
|
29
|
+
let vault;
|
|
30
|
+
try {
|
|
31
|
+
vault = JSON.parse(text)?.vault;
|
|
32
|
+
} catch {
|
|
33
|
+
vault = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (typeof vault !== 'string' || !VAULT_ID.test(vault)) {
|
|
36
|
+
throw new Error(`${file} does not name a vault: run \`sharedenv link\` again`);
|
|
37
|
+
}
|
|
38
|
+
return vault.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// → the file written.
|
|
42
|
+
export function writeLink(dir, vault) {
|
|
43
|
+
const file = path.join(dir, LINK_FILE);
|
|
44
|
+
writeFileSync(file, `${JSON.stringify({ vault: String(vault) }, null, 2)}\n`);
|
|
45
|
+
return file;
|
|
46
|
+
}
|
package/lib/safes.js
CHANGED
|
@@ -1,30 +1,88 @@
|
|
|
1
|
-
//
|
|
2
|
-
// server only ever
|
|
1
|
+
// Vaults: find one, open it (recover its key, the DEK, locally), read and write its
|
|
2
|
+
// secrets, create one. The server only ever holds ciphertext.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { Buffer } from 'buffer';
|
|
5
|
+
import { Wallet, generateSafeKey, openSecret, sealSecret } from './crypto.js';
|
|
5
6
|
import { expectOk, listAll } from './api.js';
|
|
6
|
-
import {
|
|
7
|
+
import { publicKeyOf } from './auth.js';
|
|
8
|
+
import { PendingShareError, listPendingShares, personLabel } from './shares.js';
|
|
7
9
|
|
|
8
10
|
const wallet = new Wallet();
|
|
9
11
|
const SAFE_ID = /^[a-f0-9]{24}$/i;
|
|
10
12
|
|
|
11
13
|
// Secret names the API accepts. Checked again here: the server is the party
|
|
12
14
|
// zero-knowledge does not trust, and a name lands in a .env line or an environment.
|
|
13
|
-
const SECRET_NAME = /^[A-Za-z0-9_-]+$/;
|
|
15
|
+
export const SECRET_NAME = /^[A-Za-z0-9_-]+$/;
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
export const LEVELS = { read: 10, write: 50, admin: 90 };
|
|
18
|
+
export const levelName = (level) =>
|
|
19
|
+
Object.keys(LEVELS).find((name) => LEVELS[name] === level) || String(level);
|
|
20
|
+
|
|
21
|
+
export const listVaults = (client) => listAll(client, '/safes', 'vaults');
|
|
22
|
+
|
|
23
|
+
// One line that tells a vault from another of the same name: its id, your access, whose
|
|
24
|
+
// it is, and through which of your teams you reach it. `me`: your user id, when known.
|
|
25
|
+
export function describeVault(row, me) {
|
|
26
|
+
const mine = me && String(row.ownerId) === String(me);
|
|
27
|
+
const owner = mine ? 'you' : row.owner ? personLabel(row.owner) : String(row.ownerId);
|
|
28
|
+
const teams = (row.teams || []).map((t) => (t.org?.name ? `${t.name} (${t.org.name})` : t.name));
|
|
29
|
+
return [
|
|
30
|
+
row.name,
|
|
31
|
+
String(row._id),
|
|
32
|
+
levelName(row.level),
|
|
33
|
+
`owner ${owner}`,
|
|
34
|
+
...(teams.length ? [`team ${teams.join(', ')}`] : []),
|
|
35
|
+
].join(' ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A vault by id, or by (case-insensitive) name among every vault you can open. A name
|
|
39
|
+
// that several vaults answer to picks none of them: the candidates are listed. A share
|
|
40
|
+
// still waiting for your answer is named as such rather than reported missing.
|
|
41
|
+
// → the vault id.
|
|
17
42
|
export async function resolveSafe(client, ref) {
|
|
18
|
-
if (SAFE_ID.test(ref)) return ref;
|
|
43
|
+
if (SAFE_ID.test(ref)) return ref.toLowerCase();
|
|
19
44
|
const name = ref.toLowerCase();
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
if (
|
|
45
|
+
const matches = (await listVaults(client)).filter((s) => s.name?.toLowerCase() === name);
|
|
46
|
+
if (matches.length === 1) return String(matches[0]._id);
|
|
47
|
+
if (matches.length > 1) {
|
|
48
|
+
throw Object.assign(
|
|
49
|
+
new Error(
|
|
50
|
+
[
|
|
51
|
+
`"${ref}" names ${matches.length} vaults: give its id (-v <id>)`,
|
|
52
|
+
...matches.map((row) => ` ${describeVault(row)}`),
|
|
53
|
+
].join('\n')
|
|
54
|
+
),
|
|
55
|
+
{ exitCode: 2 }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
23
58
|
const pending = (await listPendingShares(client)).direct.find(
|
|
24
59
|
(p) => p.safe?.name?.toLowerCase() === name
|
|
25
60
|
);
|
|
26
61
|
if (pending) throw new PendingShareError(pending);
|
|
27
|
-
throw new Error(`no vault named "${ref}"`);
|
|
62
|
+
throw new Error(`no vault named "${ref}" (sharedenv vaults lists yours)`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// GET /safes/:id — the vault, your paths to its key, its owner and your teams on it. A
|
|
66
|
+
// share waiting for your answer opens nothing yet: said as such.
|
|
67
|
+
export async function readVault(client, safeId) {
|
|
68
|
+
const res = await client.call('GET', `/safes/${safeId}`);
|
|
69
|
+
if (res.status === 403 && res.body?.key === 'sharePending') {
|
|
70
|
+
const pending = (await listPendingShares(client)).direct.find(
|
|
71
|
+
(p) => String(p.safeId) === String(safeId)
|
|
72
|
+
);
|
|
73
|
+
throw pending
|
|
74
|
+
? new PendingShareError(pending)
|
|
75
|
+
: new Error(
|
|
76
|
+
'this vault is shared with you but waits for your answer: run `sharedenv vaults`'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return expectOk(res, 'cannot open the vault');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Your access to a vault, from what GET /safes/:id says of your paths to it.
|
|
83
|
+
export function accessOf(vault) {
|
|
84
|
+
const levels = [vault.membership?.level, ...(vault.teamAccess || []).map((t) => t.level)];
|
|
85
|
+
return Math.max(0, ...levels.filter((l) => typeof l === 'number'));
|
|
28
86
|
}
|
|
29
87
|
|
|
30
88
|
// Every DEK the caller's paths to a safe yield, CURRENT first: a direct envelope, or a
|
|
@@ -56,51 +114,139 @@ export function resolveDeks(privateKey, safe) {
|
|
|
56
114
|
return deks;
|
|
57
115
|
}
|
|
58
116
|
|
|
117
|
+
// → the vault (readVault) and its `deks`, current first.
|
|
118
|
+
export async function openVault(client, privateKey, safeId) {
|
|
119
|
+
const vault = await readVault(client, safeId);
|
|
120
|
+
const deks = resolveDeks(privateKey, vault);
|
|
121
|
+
if (deks.length === 0) {
|
|
122
|
+
throw new Error('no key to this vault on your account (not a member, or not provisioned yet)');
|
|
123
|
+
}
|
|
124
|
+
return { ...vault, deks };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Every secret row of a vault: names in clear, values sealed. Names are checked: one
|
|
128
|
+
// that is not a variable name, or twice the same, is refused.
|
|
129
|
+
export async function listSecretRows(client, safeId) {
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const rows = await listAll(client, `/secrets/${safeId}`, 'secrets');
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
if (typeof row.key !== 'string' || !SECRET_NAME.test(row.key)) {
|
|
134
|
+
throw new Error(`refusing a secret named ${JSON.stringify(row.key)}: not a variable name`);
|
|
135
|
+
}
|
|
136
|
+
if (seen.has(row.key)) throw new Error(`the server sent "${row.key}" twice for this vault`);
|
|
137
|
+
seen.add(row.key);
|
|
138
|
+
}
|
|
139
|
+
return rows;
|
|
140
|
+
}
|
|
141
|
+
|
|
59
142
|
// AES-GCM authenticates: the first key that opens a value is the one it was written
|
|
60
|
-
// under.
|
|
61
|
-
// another
|
|
62
|
-
function
|
|
143
|
+
// under. The value must have been sealed where it was read (this vault, this name): a
|
|
144
|
+
// blob moved there from another secret or another vault is refused.
|
|
145
|
+
function decryptRow(deks, safeId, row) {
|
|
146
|
+
const place = { safeId, key: row.key };
|
|
63
147
|
let misplaced = null;
|
|
64
148
|
for (const dek of deks) {
|
|
65
149
|
try {
|
|
66
|
-
return openSecret(dek,
|
|
150
|
+
return openSecret(dek, row.ciphertextValue, place);
|
|
67
151
|
} catch (err) {
|
|
68
152
|
if (/belongs to/.test(err.message)) misplaced = err;
|
|
69
153
|
}
|
|
70
154
|
}
|
|
71
|
-
if (misplaced) throw new Error(`refusing "${
|
|
72
|
-
throw new Error(
|
|
73
|
-
`cannot decrypt "${place.key}" — a key rotation may be in progress, retry shortly`
|
|
74
|
-
);
|
|
155
|
+
if (misplaced) throw new Error(`refusing "${row.key}": ${misplaced.message}`);
|
|
156
|
+
throw new Error(`cannot decrypt "${row.key}" — a key rotation may be in progress, retry shortly`);
|
|
75
157
|
}
|
|
76
158
|
|
|
77
|
-
// → { KEY: value } — every secret of the
|
|
78
|
-
// cannot be read fails the pull, so `run` never starts a process
|
|
159
|
+
// → { safe, secrets: { KEY: value } } — every secret of the vault, decrypted. All or
|
|
160
|
+
// nothing: one value that cannot be read fails the pull, so `run` never starts a process
|
|
161
|
+
// with a variable missing.
|
|
79
162
|
export async function pullSecrets(client, privateKey, safeId) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
163
|
+
const vault = await openVault(client, privateKey, safeId);
|
|
164
|
+
const secrets = {};
|
|
165
|
+
for (const row of await listSecretRows(client, safeId)) {
|
|
166
|
+
secrets[row.key] = decryptRow(vault.deks, safeId, row);
|
|
167
|
+
}
|
|
168
|
+
return { safe: vault.safe, secrets };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// → { safe, value } — one secret, decrypted.
|
|
172
|
+
export async function readSecret(client, privateKey, safeId, key) {
|
|
173
|
+
const vault = await openVault(client, privateKey, safeId);
|
|
174
|
+
const row = (await listSecretRows(client, safeId)).find((r) => r.key === key);
|
|
175
|
+
if (!row) throw new Error(`no key "${key}" in vault "${vault.safe.name}"`);
|
|
176
|
+
return { safe: vault.safe, value: decryptRow(vault.deks, safeId, row) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Adds or replaces secrets, sealed here under the vault's current key and bound to their
|
|
180
|
+
// place. A vault someone lost access to still runs under the key they held: nothing new
|
|
181
|
+
// is written under it until its key has changed — and that rotation re-encrypts every
|
|
182
|
+
// secret, which the web app does.
|
|
183
|
+
// `entries`: [[KEY, value]]. → { safe, added: [KEY], replaced: [KEY] }
|
|
184
|
+
export async function setSecrets(client, privateKey, safeId, entries) {
|
|
185
|
+
const vault = await openVault(client, privateKey, safeId);
|
|
186
|
+
const { safe } = vault;
|
|
187
|
+
if (safe.pendingRotation) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`"${safe.name}" must change its key first (someone lost access to it): an admin of ` +
|
|
190
|
+
'the vault opens it in the web app, which does it - then retry'
|
|
84
191
|
);
|
|
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
192
|
}
|
|
89
|
-
const
|
|
193
|
+
const [dek] = vault.deks;
|
|
194
|
+
const keyVersion = safe.keyVersion || 1;
|
|
195
|
+
const existing = new Map((await listSecretRows(client, safeId)).map((r) => [r.key, r]));
|
|
196
|
+
const added = [];
|
|
197
|
+
const replaced = [];
|
|
198
|
+
for (const [key, value] of entries) {
|
|
199
|
+
const ciphertextValue = JSON.stringify(sealSecret(dek, value, { safeId, key }));
|
|
200
|
+
const row = existing.get(key);
|
|
201
|
+
if (row) {
|
|
202
|
+
expectOk(
|
|
203
|
+
await client.call('PUT', `/secrets/${safeId}/${row._id}`, { ciphertextValue, keyVersion }),
|
|
204
|
+
`cannot replace "${key}"`
|
|
205
|
+
);
|
|
206
|
+
replaced.push(key);
|
|
207
|
+
} else {
|
|
208
|
+
expectOk(
|
|
209
|
+
await client.call('POST', `/secrets/${safeId}`, { key, ciphertextValue, keyVersion }),
|
|
210
|
+
`cannot add "${key}"`
|
|
211
|
+
);
|
|
212
|
+
added.push(key);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { safe, added, replaced };
|
|
216
|
+
}
|
|
90
217
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
218
|
+
// Removes secrets by name; every name must exist, or nothing is removed. No key needed:
|
|
219
|
+
// the server only drops ciphertext.
|
|
220
|
+
export async function unsetSecrets(client, safeId, keys) {
|
|
221
|
+
const rows = new Map((await listSecretRows(client, safeId)).map((r) => [r.key, r]));
|
|
222
|
+
const missing = keys.filter((key) => !rows.has(key));
|
|
223
|
+
if (missing.length) {
|
|
224
|
+
throw new Error(`no key ${missing.map((k) => `"${k}"`).join(', ')} in this vault`);
|
|
94
225
|
}
|
|
226
|
+
for (const key of keys) {
|
|
227
|
+
expectOk(
|
|
228
|
+
await client.call('DELETE', `/secrets/${safeId}/${rows.get(key)._id}`),
|
|
229
|
+
`cannot remove "${key}"`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
95
233
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
234
|
+
// A new vault, yours: a fresh DEK, sealed to your own key here. A name you already see
|
|
235
|
+
// is refused — it would stop telling the two vaults apart. → the vault ({ _id, name }).
|
|
236
|
+
export async function createVault(client, privateKey, name) {
|
|
237
|
+
const taken = (await listVaults(client)).find(
|
|
238
|
+
(s) => s.name?.toLowerCase() === String(name).toLowerCase()
|
|
239
|
+
);
|
|
240
|
+
if (taken) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`you already have a vault named "${taken.name}" (${taken._id}): pick another name`
|
|
243
|
+
);
|
|
104
244
|
}
|
|
105
|
-
|
|
245
|
+
const dek = generateSafeKey();
|
|
246
|
+
const sealedSafeKey = wallet.seal(privateKey, publicKeyOf(privateKey), Buffer.from(dek, 'hex'));
|
|
247
|
+
const body = expectOk(
|
|
248
|
+
await client.call('POST', '/safes', { name, sealedSafeKey: JSON.stringify(sealedSafeKey) }),
|
|
249
|
+
'cannot create the vault'
|
|
250
|
+
);
|
|
251
|
+
return body.safe;
|
|
106
252
|
}
|
package/lib/shares.js
CHANGED
|
@@ -13,7 +13,7 @@ export class PendingShareError extends Error {
|
|
|
13
13
|
constructor(share) {
|
|
14
14
|
super(
|
|
15
15
|
`"${share.safe?.name || share.safeId}" is a share from ${senderLabel(share)} waiting for ` +
|
|
16
|
-
`your answer: run \`sharedenv
|
|
16
|
+
`your answer: run \`sharedenv vaults\` to review it, then \`sharedenv accept ` +
|
|
17
17
|
`${share.safeId}\` (or \`decline\`)`
|
|
18
18
|
);
|
|
19
19
|
this.code = 'SHARE_PENDING';
|
|
@@ -21,13 +21,16 @@ export class PendingShareError extends Error {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
// "First Last (username)", as the API resolves a person (`{ _id, username, ... }`).
|
|
25
|
+
export function personLabel(person) {
|
|
26
|
+
const p = person || {};
|
|
27
|
+
const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
|
28
|
+
if (!p.username) return String(p._id || 'unknown');
|
|
29
|
+
return name ? `${name} (${p.username})` : p.username;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export const senderLabel = (share) => personLabel(share?.from);
|
|
33
|
+
|
|
31
34
|
// → { direct: [...], team: [...] }. Each row carries `from` (name + directory key) and
|
|
32
35
|
// `fingerprint`, computed here from that key, never taken from the server.
|
|
33
36
|
export async function listPendingShares(client) {
|
|
@@ -41,15 +44,37 @@ export async function listPendingShares(client) {
|
|
|
41
44
|
return { direct: withFingerprint(direct), team: withFingerprint(team) };
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
// A pending share by
|
|
45
|
-
// that team
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// A pending share by its vault's id or name; `teamRef` (a team's id or name): among the
|
|
48
|
+
// shares to that team. Two that answer to the same names are listed, never one picked.
|
|
49
|
+
// → { safeId, teamId } (teamId null for a share to you).
|
|
50
|
+
export async function resolvePendingShare(client, ref, teamRef) {
|
|
48
51
|
const { direct, team } = await listPendingShares(client);
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const same = (a, b) => String(a ?? '').toLowerCase() === String(b).toLowerCase();
|
|
53
|
+
const rows = teamRef
|
|
54
|
+
? team.filter((p) => same(p.teamId, teamRef) || same(p.team?.name, teamRef))
|
|
55
|
+
: direct;
|
|
56
|
+
const matches = rows.filter((p) => same(p.safeId, ref) || same(p.safe?.name, ref));
|
|
57
|
+
if (matches.length === 0) {
|
|
58
|
+
throw new Error(`no pending share named "${ref}"${teamRef ? ` for team "${teamRef}"` : ''}`);
|
|
59
|
+
}
|
|
60
|
+
if (matches.length > 1) {
|
|
61
|
+
throw Object.assign(
|
|
62
|
+
new Error(
|
|
63
|
+
[
|
|
64
|
+
`"${ref}" names ${matches.length} pending shares: give the vault id` +
|
|
65
|
+
(teamRef ? ' and the team id' : ''),
|
|
66
|
+
...matches.map(
|
|
67
|
+
(p) =>
|
|
68
|
+
` ${p.safe?.name} ${p.safeId} from ${senderLabel(p)}` +
|
|
69
|
+
(p.teamId ? ` for team ${p.team?.name} (${p.teamId})` : '')
|
|
70
|
+
),
|
|
71
|
+
].join('\n')
|
|
72
|
+
),
|
|
73
|
+
{ exitCode: 2 }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const [match] = matches;
|
|
77
|
+
return { safeId: String(match.safeId), teamId: teamRef ? String(match.teamId) : null };
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
function senderKeyOf(share) {
|
|
@@ -75,7 +100,9 @@ export async function openPendingShare(client, privateKey, safeId) {
|
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
// The sender's key (and the team key a team share moves to) is the directory's word:
|
|
78
|
-
// it must be the last key the public key log holds for them. `log`: { store }
|
|
103
|
+
// it must be the last key the public key log holds for them. `log`: { store, watch },
|
|
104
|
+
// the watch of the unlock (bin/sharedenv.js): this read checks the entries it goes past
|
|
105
|
+
// against every key we hold, like any other (lib/transparency.js `syncLog`).
|
|
79
106
|
async function assertSenderLogged(client, log, opened, team) {
|
|
80
107
|
const { view } = await syncLog(client, log);
|
|
81
108
|
assertLogged(view, [
|