@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/lib/sharing.js ADDED
@@ -0,0 +1,112 @@
1
+ // Sharing a vault you administer. With a person: their key comes from the directory, so
2
+ // before anything is sealed to it, it must be the last key the public key log holds for
3
+ // them, and the one this machine knows for them (the bin asks about a new or changed
4
+ // one). With a team you belong to: its public key is derived from the team private key
5
+ // sealed to you, never taken from the server.
6
+
7
+ import { Buffer } from 'buffer';
8
+ import { Wallet } from './crypto.js';
9
+ import { expectOk, listAll, listOrgs } from './api.js';
10
+ import { assertLogged, syncLog } from './transparency.js';
11
+
12
+ const wallet = new Wallet();
13
+
14
+ // → { userId, publicKey, username } of the account behind an email.
15
+ export async function lookupPerson(client, email) {
16
+ const res = await client.call('GET', `/keyring/lookup?email=${encodeURIComponent(email)}`);
17
+ if (res.status === 404) throw new Error(`no sharedenv account with a key for ${email}`);
18
+ const person = expectOk(res, 'cannot look this person up');
19
+ if (!person?.userId || !person?.publicKey) {
20
+ throw new Error(`no sharedenv account with a key for ${email}`);
21
+ }
22
+ return {
23
+ ...person,
24
+ userId: String(person.userId),
25
+ publicKey: String(person.publicKey).toLowerCase(),
26
+ };
27
+ }
28
+
29
+ // The person's key must be the last one the log holds for them. `log`: { store, watch },
30
+ // the watch of the unlock, so this read checks what it goes past like any other.
31
+ export async function assertPersonLogged(client, log, person, label) {
32
+ const { view } = await syncLog(client, log);
33
+ assertLogged(view, [{ subject: `user:${person.userId}`, publicKey: person.publicKey, label }]);
34
+ }
35
+
36
+ // Seals the vault's key to the person and adds them. → 'pending' (until they accept) or
37
+ // 'accepted'.
38
+ export async function shareWithPerson(client, privateKey, vault, person, level) {
39
+ const sealedSafeKey = wallet.seal(
40
+ privateKey,
41
+ person.publicKey,
42
+ Buffer.from(vault.deks[0], 'hex')
43
+ );
44
+ const body = expectOk(
45
+ await client.call('POST', `/members/${vault.safe._id}`, {
46
+ userId: person.userId,
47
+ level,
48
+ sealedSafeKey: JSON.stringify(sealedSafeKey),
49
+ }),
50
+ 'cannot share the vault'
51
+ );
52
+ return body?.status || 'pending';
53
+ }
54
+
55
+ // Every team of every organization you are in. → [{ _id, name, org: { _id, name } }]
56
+ export async function listTeams(client) {
57
+ const teams = [];
58
+ for (const org of await listOrgs(client)) {
59
+ const rows = await listAll(client, `/team/list?orgId=${encodeURIComponent(org._id)}`, 'teams');
60
+ for (const t of rows) {
61
+ teams.push({ _id: String(t._id), name: t.name, memberCount: t.memberCount, org });
62
+ }
63
+ }
64
+ return teams;
65
+ }
66
+
67
+ export const teamLabel = (team) => (team.org?.name ? `${team.name} (${team.org.name})` : team.name);
68
+
69
+ // A team by id or (case-insensitive) name; several answering to it pick none.
70
+ export async function resolveTeam(client, ref) {
71
+ const want = String(ref).toLowerCase();
72
+ const matches = (await listTeams(client)).filter(
73
+ (t) => t._id.toLowerCase() === want || t.name?.toLowerCase() === want
74
+ );
75
+ if (matches.length === 1) return matches[0];
76
+ if (matches.length === 0) throw new Error(`no team named "${ref}" (sharedenv teams lists yours)`);
77
+ throw Object.assign(
78
+ new Error(
79
+ [
80
+ `"${ref}" names ${matches.length} teams: give its id`,
81
+ ...matches.map((t) => ` ${teamLabel(t)} ${t._id}`),
82
+ ].join('\n')
83
+ ),
84
+ { exitCode: 2 }
85
+ );
86
+ }
87
+
88
+ // Seals the vault's key to the team's key — derived from the team private key sealed to
89
+ // you — and grants it. → 'accepted', or 'pending' until someone who answers for the team
90
+ // accepts it.
91
+ export async function shareWithTeam(client, privateKey, vault, team, level) {
92
+ const res = await client.call('GET', `/team-keys/${encodeURIComponent(team._id)}/me`);
93
+ if (res.status === 404) {
94
+ throw new Error(
95
+ `you hold no key for team ${teamLabel(team)}: only one of its members can share with it`
96
+ );
97
+ }
98
+ const mine = expectOk(res, 'cannot read your team key');
99
+ const opened = mine?.sealedTeamKey && wallet.open(privateKey, mine.sealedTeamKey);
100
+ if (!opened) throw new Error(`cannot open your key for team ${teamLabel(team)}`);
101
+ const teamPublicKey = wallet.keypass.publicFromPrivate(opened.toString('utf8'));
102
+ const sealedSafeKey = wallet.seal(privateKey, teamPublicKey, Buffer.from(vault.deks[0], 'hex'));
103
+ const body = expectOk(
104
+ await client.call('POST', `/safes/${vault.safe._id}/share-team`, {
105
+ teamId: team._id,
106
+ level,
107
+ sealedSafeKey: JSON.stringify(sealedSafeKey),
108
+ }),
109
+ 'cannot share the vault with the team'
110
+ );
111
+ return body?.status || 'accepted';
112
+ }
@@ -4,37 +4,38 @@
4
4
  // each entry chained to the previous one by its hash. This CLI:
5
5
  // - seals to, or trusts the seal of, a key only if it is the LAST one the log holds
6
6
  // for its owner: a substituted key would have to be logged, where its owner sees it ;
7
- // - watches your own name: a key logged under it that is not yours is reported ;
7
+ // - watches the keys you hold - your own, and the key of every team sealed to you: a
8
+ // key logged under one of their names that is not the one you hold is reported, and
9
+ // the warning comes back at every unlock until `sharedenv log --ack` ;
8
10
  // - keeps what it checked (~/.sharedenv/log.json) and refuses a log that does not
9
11
  // chain, rewrites what it already saw, or goes back.
10
- // The arithmetic is lib/crypto/kt.js.
12
+ // The arithmetic, and the rules of what is kept, are lib/crypto/kt.js.
11
13
 
12
- import { emptyView, replay, isLogged } from './crypto.js';
13
- import { expectOk } from './api.js';
14
+ import { Wallet, absorb, acknowledge, readFrom, toRecord, isLogged } from './crypto.js';
15
+ import { expectOk, listAll, listOrgs } from './api.js';
14
16
 
15
17
  const PAGE_LIMIT = 500;
16
18
 
17
- const saneView = (v) =>
18
- v &&
19
- Number.isInteger(v.seq) &&
20
- typeof v.hash === 'string' &&
21
- v.latest &&
22
- typeof v.latest === 'object';
19
+ const wallet = new Wallet();
23
20
 
24
21
  // Reads the log after what this machine checked, replays it, keeps the result.
25
22
  // store lib/store.js (or none: replay from the start, keep nothing)
26
- // watch { [subject]: publicKey } — the keys we hold
27
- // → { view, foreign, unlogged }. Throws when the log cannot be read or does not add up.
23
+ // watch { [subject]: publicKey } - EVERY key we hold (heldKeys). A sync that watches
24
+ // nothing does not move what is kept: the entries it went past would never be
25
+ // checked against anyone's name.
26
+ // → { view, foreign, unlogged, alerts }: `alerts`, what the watch found and nobody
27
+ // acknowledged yet, this sync included. Throws when the log cannot be read or does not
28
+ // add up.
28
29
  export async function syncLog(client, { store, watch = {} } = {}) {
29
- const stored = store?.readLogView();
30
- const from = saneView(stored) ? stored : emptyView();
30
+ const record = toRecord(store?.readLogView());
31
+ const from = readFrom(record, watch);
31
32
 
32
33
  const read = async (to, page) => {
33
34
  const bound = to === null ? '' : `&to=${to}`;
34
35
  return expectOk(
35
36
  await client.call(
36
37
  'GET',
37
- `/transparency/log?from=${from.seq}${bound}&page=${page}&limit=${PAGE_LIMIT}`
38
+ `/transparency/log?from=${from}${bound}&page=${page}&limit=${PAGE_LIMIT}`
38
39
  ),
39
40
  'cannot read the public key log'
40
41
  );
@@ -44,7 +45,7 @@ export async function syncLog(client, { store, watch = {} } = {}) {
44
45
  if (!Number.isInteger(head?.seq) || typeof head?.hash !== 'string') {
45
46
  throw new Error('the public key log sent no head');
46
47
  }
47
- if (head.seq < from.seq) {
48
+ if (head.seq < record.seq) {
48
49
  throw new Error('the public key log is shorter than what this machine already checked');
49
50
  }
50
51
  const entries = [...(first.data || [])];
@@ -52,18 +53,81 @@ export async function syncLog(client, { store, watch = {} } = {}) {
52
53
  entries.push(...((await read(head.seq, page)).data || []));
53
54
  }
54
55
 
55
- let replayed;
56
+ let result;
56
57
  try {
57
- replayed = replay(from, entries, { watch });
58
+ result = absorb(record, from, entries, head, { watch });
58
59
  } catch (err) {
59
60
  throw new Error(`the public key log does not add up: ${err.message}`);
60
61
  }
61
- const { view, foreign, unlogged } = replayed;
62
- if (view.seq !== head.seq || view.hash !== head.hash) {
63
- throw new Error('the public key log does not add up: its head does not match its entries');
62
+ if (store && result.changed) store.writeLogView(result.record);
63
+ const { view, foreign, unlogged, alerts } = result;
64
+ return { view, foreign, unlogged, alerts };
65
+ }
66
+
67
+ // The warnings kept on this machine and not acknowledged yet, without reading the log.
68
+ export const keptAlerts = (store) => toRecord(store.readLogView()).alerts;
69
+
70
+ // The owner has read them. → the warnings acknowledged. One still true comes back at
71
+ // the next unlock.
72
+ export function acknowledgeAlerts(store) {
73
+ const record = toRecord(store.readLogView());
74
+ if (record.alerts.length) store.writeLogView(acknowledge(record));
75
+ return record.alerts;
76
+ }
77
+
78
+ // The keys this account holds, as the log should hold them:
79
+ // - its own, `user:<id>`, derived from the private key (never the server's copy) ;
80
+ // - for every team whose key was sealed to it, `team:<id>`, DERIVED from the team
81
+ // private key it opens. That key is watched like our own: a team key swapped in the
82
+ // directory would receive every safe shared with the team, and only those who hold
83
+ // the real one can tell.
84
+ // Teams that cannot be read are left out of this watch: they are read again from the
85
+ // start of the log the next time they are watched (kt.js `readFrom`).
86
+ // → { watch: { [subject]: publicKey }, labels: { [subject]: name } }
87
+ export async function heldKeys(client, { privateKey, userId }) {
88
+ const watch = { [`user:${userId}`]: wallet.keypass.publicFromPrivate(privateKey) };
89
+ const labels = {};
90
+ let orgs = [];
91
+ try {
92
+ orgs = await listOrgs(client);
93
+ } catch {
94
+ return { watch, labels };
64
95
  }
65
- if (store && view.seq > from.seq) store.writeLogView(view);
66
- return { view, foreign, unlogged };
96
+ const teams = new Map();
97
+ for (const org of orgs) {
98
+ try {
99
+ const rows = await listAll(
100
+ client,
101
+ `/team/list?orgId=${encodeURIComponent(org._id)}`,
102
+ 'teams'
103
+ );
104
+ // Every team of the org: the server hands its key only to a member it was sealed to.
105
+ for (const t of rows) teams.set(String(t._id), t.name);
106
+ } catch {
107
+ /* an org that does not answer: its teams wait for the next unlock */
108
+ }
109
+ }
110
+ for (const [teamId, name] of teams) {
111
+ const key = await teamKeyOf(client, privateKey, teamId).catch(() => null);
112
+ if (key) {
113
+ watch[`team:${teamId}`] = key;
114
+ labels[`team:${teamId}`] = name;
115
+ }
116
+ }
117
+ return { watch, labels };
118
+ }
119
+
120
+ // The team's OFFICIAL public key, derived from the team private key sealed to us, or
121
+ // null (not provisioned, or not readable). While a team-key rotation is unfinished our
122
+ // row may already hold the next key: the official one is then the key it held before.
123
+ async function teamKeyOf(client, privateKey, teamId) {
124
+ const res = await client.call('GET', `/team-keys/${encodeURIComponent(teamId)}/me`);
125
+ if (res.status !== 200 || !res.body?.sealedTeamKey) return null;
126
+ const mine = res.body;
127
+ const ahead = (mine.keyVersion || 1) > (mine.teamKeyVersion || 1);
128
+ const blob = ahead ? mine.previousSealedTeamKey : mine.sealedTeamKey;
129
+ const opened = blob && wallet.open(privateKey, blob);
130
+ return opened ? wallet.keypass.publicFromPrivate(opened.toString('utf8')) : null;
67
131
  }
68
132
 
69
133
  // Every target's key must be the last one the log holds for its subject.
@@ -79,21 +143,41 @@ export function assertLogged(view, targets) {
79
143
  }
80
144
  }
81
145
 
82
- // What the watch found, as lines for stderr (none when all is well).
83
- export function watchWarnings({ foreign = [], unlogged = [] }) {
146
+ // The warnings, as lines for stderr (none when all is well). `labels`: team names.
147
+ export function watchWarnings({ alerts = [] } = {}, labels = {}) {
84
148
  const lines = [];
85
- if (foreign.length) {
149
+ const entries = (list) => list.map((a) => a.seq).join(', ');
150
+ const bySubject = (kind) => {
151
+ const groups = new Map();
152
+ for (const a of alerts.filter((x) => x.kind === kind)) {
153
+ groups.set(a.subject, [...(groups.get(a.subject) || []), a]);
154
+ }
155
+ return groups;
156
+ };
157
+ const teamName = (subject) => labels[subject] || subject.slice('team:'.length);
158
+ for (const [subject, list] of bySubject('foreign')) {
86
159
  lines.push(
87
- 'WARNING: a key that is not yours was registered under your name in the public key log ' +
88
- `(entry ${foreign.map((f) => f.seq).join(', ')}). If you did not reset your vault, ` +
89
- 'tell SharedEnv: it is the trace of an attack.'
160
+ subject.startsWith('team:')
161
+ ? `WARNING: a key that is not the one you hold for team "${teamName(subject)}" was ` +
162
+ `registered for it in the public key log (entry ${entries(list)}). Unless a lead ` +
163
+ 'of the team replaced its key, tell SharedEnv: it is the trace of an attack.'
164
+ : 'WARNING: a key that is not yours was registered under your name in the public key ' +
165
+ `log (entry ${entries(list)}). If you did not reset your vault, tell SharedEnv: it ` +
166
+ 'is the trace of an attack.'
90
167
  );
91
168
  }
92
- if (unlogged.length) {
169
+ for (const [subject] of bySubject('unlogged')) {
93
170
  lines.push(
94
- 'WARNING: your key is not the last one the public key log holds for you: nobody can ' +
95
- 'share with you until it is. Tell SharedEnv.'
171
+ subject.startsWith('team:')
172
+ ? `WARNING: the key you hold for team "${teamName(subject)}" is not the last one the ` +
173
+ 'public key log holds for it: nobody can share with the team until it is. Tell ' +
174
+ 'SharedEnv.'
175
+ : 'WARNING: your key is not the last one the public key log holds for you: nobody can ' +
176
+ 'share with you until it is. Tell SharedEnv.'
96
177
  );
97
178
  }
179
+ if (lines.length) {
180
+ lines.push('These warnings come back at every unlock until `sharedenv log --ack`.');
181
+ }
98
182
  return lines;
99
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sharedenv/cli",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "sharedenv CLI — pull and run with your zero-knowledge secrets, decrypted locally.",
6
6
  "license": "Apache-2.0",