@sharedenv/cli 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -78,3 +78,9 @@ export async function unlockAccount(client, password) {
78
78
  }
79
79
  return { privateKey, userId: keyring.userId };
80
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. Every client (browser, extension, command line)
25
- // computes the same hashes for the same entries.
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 { emptyView, replay, isLogged } from './crypto/kt.js';
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
- // Reading a safe: find it, recover its key (DEK), decrypt every secret — locally. The
2
- // server only ever returns ciphertext.
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 { Wallet, openSecret } from './crypto.js';
4
+ import { Buffer } from 'buffer';
5
+ import { Wallet, generateSafeKey, openSecret, sealSecret } from './crypto.js';
5
6
  import { expectOk, listAll } from './api.js';
6
- import { PendingShareError, listPendingShares } from './shares.js';
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
- // 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 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 safes = await listAll(client, '/safes', 'safes');
21
- const match = safes.find((s) => s.name?.toLowerCase() === name);
22
- if (match) return String(match._id);
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. `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) {
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, blob, place);
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 "${place.key}": ${misplaced.message}`);
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 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.
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 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)
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 safe = expectOk(res, 'cannot open the vault');
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
- 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)');
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
- 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 });
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
- return secrets;
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 list\` to review it, then \`sharedenv accept ` +
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
- export function senderLabel(share) {
25
- const from = share?.from || {};
26
- const name = [from.firstName, from.lastName].filter(Boolean).join(' ').trim();
27
- if (!from.username) return String(from._id || 'unknown');
28
- return name ? `${name} (${from.username})` : from.username;
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 safe id, or by the name of its safe (`teamId`: among the shares to
45
- // that team). → the safe id.
46
- export async function resolvePendingShare(client, ref, teamId) {
47
- if (/^[a-f0-9]{24}$/i.test(ref)) return ref;
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 rows = teamId ? team.filter((p) => String(p.teamId) === String(teamId)) : direct;
50
- const match = rows.find((p) => p.safe?.name?.toLowerCase() === ref.toLowerCase());
51
- if (!match) throw new Error(`no pending share named "${ref}"${teamId ? ' for this team' : ''}`);
52
- return String(match.safeId);
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, [