@sharedenv/cli 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **The zero-knowledge secrets vault for teams, in your terminal.**
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@sharedenv/cli)](https://www.npmjs.com/package/@sharedenv/cli)
6
- [![license](https://img.shields.io/npm/l/@sharedenv/cli)](LICENSE)
6
+ [![license](https://img.shields.io/github/license/SharedEnv/cli)](LICENSE)
7
7
 
8
8
  Your `.env` files have no business in Slack, in an email or in a git repo. With
9
9
  [sharedenv](https://sharedenv.com), your team keeps its environment variables, API keys and
@@ -64,15 +64,112 @@ is passed through, so scripts and CI behave exactly as without sharedenv.
64
64
  A variable your shell already sets wins over the vault, so a local `DATABASE_URL` is never
65
65
  replaced behind your back: add `--override-env` when you want the vault's value.
66
66
 
67
- ### Write a .env file
67
+ ### Get your secrets as a file, or as JSON
68
68
 
69
69
  ```bash
70
- sharedenv pull prod --out .env # a file only you can read
71
- sharedenv pull prod --format json # for scripts and tools
70
+ sharedenv pull prod # dotenv, on stdout
71
+ sharedenv pull prod --out .env # the same, in a file only you can read
72
+ sharedenv pull prod --format json # JSON, for scripts and programs
72
73
  ```
73
74
 
74
- The file works with dotenv-style libraries and can be sourced by a shell
75
- (`set -a; . ./.env; set +a`): every value is quoted so that nothing in it gets executed.
75
+ stdout carries the secrets and nothing else (questions and messages go to stderr), so
76
+ pipes and redirections get clean data. From a script or a program there is no terminal to
77
+ ask for your master password: set `SHAREDENV_PASSWORD`.
78
+
79
+ #### dotenv (the default)
80
+
81
+ For a vault holding five secrets, `sharedenv pull prod` prints:
82
+
83
+ ```dotenv
84
+ APP_NAME='Acme billing'
85
+ DATABASE_URL=postgres://app:s3cret@db.internal:5432/app
86
+ JWT_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----
87
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASC
88
+ -----END PRIVATE KEY-----'
89
+ SMTP_PASSWORD="it's a pass"
90
+ STRIPE_KEY=sk_test_example
91
+ ```
92
+
93
+ - One `NAME=value` per secret, sorted by name, so two pulls of the same vault give the
94
+ same file.
95
+ - A value is written as is when it holds only plain characters, between single quotes
96
+ otherwise (multi-line values such as a PEM key included), and between double quotes when
97
+ it contains a single quote.
98
+ - Nothing is ever expanded or executed when the file is read. A value that no quoting can
99
+ carry safely (a single quote together with `"`, `$`, a backtick or `\`) is refused with
100
+ its name: use `--format json` for that vault.
101
+
102
+ Every common reader loads it as is:
103
+
104
+ ```bash
105
+ set -a; . ./.env; set +a # a shell (bash, zsh, sh)
106
+ node --env-file=.env server.js # Node.js 20.12 or later
107
+ ```
108
+
109
+ ```js
110
+ import 'dotenv/config'; // Node.js, with the dotenv package
111
+ ```
112
+
113
+ ```python
114
+ from dotenv import load_dotenv # Python, with python-dotenv
115
+ load_dotenv()
116
+ ```
117
+
118
+ One exception: `docker run --env-file` reads values literally (it would keep the quotes)
119
+ and refuses multi-line values. Hand the variables to Docker through `run` instead:
120
+
121
+ ```bash
122
+ sharedenv run prod -- docker run -e DATABASE_URL -e STRIPE_KEY my-app
123
+ ```
124
+
125
+ #### JSON
126
+
127
+ `sharedenv pull prod --format json` prints one object, names sorted, every value a string:
128
+
129
+ ```json
130
+ {
131
+ "APP_NAME": "Acme billing",
132
+ "DATABASE_URL": "postgres://app:s3cret@db.internal:5432/app",
133
+ "JWT_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----",
134
+ "SMTP_PASSWORD": "it's a pass",
135
+ "STRIPE_KEY": "sk_test_example"
136
+ }
137
+ ```
138
+
139
+ One value, from a shell:
140
+
141
+ ```bash
142
+ sharedenv pull prod --format json | jq -r .DATABASE_URL
143
+ ```
144
+
145
+ From a program:
146
+
147
+ ```js
148
+ // Node.js
149
+ import { execFileSync } from 'node:child_process';
150
+
151
+ const secrets = JSON.parse(
152
+ execFileSync('sharedenv', ['pull', 'prod', '--format', 'json'], { encoding: 'utf8' })
153
+ );
154
+ console.log(secrets.DATABASE_URL);
155
+ ```
156
+
157
+ ```python
158
+ # Python
159
+ import json
160
+ import subprocess
161
+
162
+ secrets = json.loads(
163
+ subprocess.run(
164
+ ["sharedenv", "pull", "prod", "--format", "json"],
165
+ check=True, capture_output=True, text=True,
166
+ ).stdout
167
+ )
168
+ print(secrets["DATABASE_URL"])
169
+ ```
170
+
171
+ If the command fails (wrong password, unknown vault, no network), it exits with a non-zero
172
+ code and says why on stderr: `execFileSync` throws, and `check=True` raises.
76
173
 
77
174
  ### Accept what your team shares with you
78
175
 
@@ -151,6 +248,11 @@ Good practice for pipelines:
151
248
  memory for the command, and never written anywhere.
152
249
  - **You know who shares with you.** A share opens only if it was sealed by its sender, and
153
250
  the CLI remembers the keys it has seen: if someone's key ever changes, it stops and asks.
251
+ - **A key cannot be swapped in our database without a trace.** Every public key the server
252
+ hands out is first written to a log that only grows, each entry chained to the previous
253
+ one by its hash. The CLI trusts a key only if it is the last one logged for its owner,
254
+ warns you when a key that is not yours is logged under your name, and refuses a log that
255
+ rewrites what it already checked.
154
256
  - **A vault cannot take over your machine.** Anyone who can edit a vault chooses its
155
257
  values, so `run` holds back variables that would make a program run code or reroute its
156
258
  traffic (`LD_*`, `PATH`, `NODE_OPTIONS`, proxies, custom certificates...) unless you pass
@@ -163,8 +265,10 @@ What it does not protect against, said plainly:
163
265
  - **Names.** Vault names and secret names (like `DATABASE_URL`) are stored in clear, so
164
266
  that they can be listed and organized; values, and the URL and username that go with
165
267
  them, are encrypted. Never put a secret in a name.
166
- - **The first contact.** The first key the CLI sees for someone is the one our directory
167
- serves; comparing fingerprints once settles it for good.
268
+ - **A swap is caught, not prevented.** Someone who can write to our database could still
269
+ log a key of their own: the log keeps the trace, and its owner is warned the next time
270
+ they unlock. Someone who controls our servers themselves could show you a different log.
271
+ Comparing fingerprints once with someone settles it for good.
168
272
  - **After you accept a share**, its sender is not checked again; and an older value of the
169
273
  same secret could be served back to you.
170
274
  - **Your own account on your own machine.** The session file (`~/.sharedenv/session.json`,
@@ -194,9 +298,9 @@ What it does not protect against, said plainly:
194
298
  | `SHAREDENV_API` | API URL (default `https://api.sharedenv.com`). |
195
299
  | `SHAREDENV_HOME` | Where the CLI keeps its files (default `~/.sharedenv`). |
196
300
 
197
- The CLI keeps two files there, both readable by you only: `session.json` (your session)
198
- and `pins.json` (the public keys this machine has checked). Never a secret, never your
199
- password, never your private key.
301
+ The CLI keeps three files there, all readable by you only: `session.json` (your session),
302
+ `pins.json` (the public keys this machine has checked) and `log.json` (how far it checked
303
+ the public key log). Never a secret, never your password, never your private key.
200
304
 
201
305
  Exit codes: `0` success, `1` error, `2` wrong usage, `130` cancelled. With `run`: the
202
306
  command's own code, `128 + n` when signal `n` ended it, `127` when the command does not
package/bin/sharedenv.js CHANGED
@@ -12,7 +12,8 @@ import { Writable } from 'stream';
12
12
  import { parseArgs } from 'util';
13
13
  import { fingerprintOf } from '../lib/crypto.js';
14
14
  import { apiBase, createClient, expectOk, listAll } from '../lib/api.js';
15
- import { login, publicKeyOf, unlockPrivateKey } from '../lib/auth.js';
15
+ import { login, publicKeyOf, unlockAccount } from '../lib/auth.js';
16
+ import { syncLog, watchWarnings } from '../lib/transparency.js';
16
17
  import { childEnv, splitUnsafeEnv } from '../lib/env.js';
17
18
  import { FORMATS } from '../lib/format.js';
18
19
  import { runChild } from '../lib/run.js';
@@ -121,10 +122,25 @@ function saveSession() {
121
122
  connected.session.cookies = cookies;
122
123
  }
123
124
 
125
+ // Each time the vault opens: is the key the public log holds for us still ours? A
126
+ // warning, never a refusal - reading your own secrets seals nothing to anyone.
127
+ async function watchOwnKey(client, { privateKey, userId }) {
128
+ try {
129
+ const report = await syncLog(client, {
130
+ store,
131
+ watch: { [`user:${userId}`]: publicKeyOf(privateKey) },
132
+ });
133
+ for (const line of watchWarnings(report)) note(line);
134
+ } catch (err) {
135
+ note(`WARNING: ${err.message}.`);
136
+ }
137
+ }
138
+
124
139
  async function unlock() {
125
140
  const client = connect();
126
- const privateKey = await unlockPrivateKey(client, await masterPassword());
127
- return { client, privateKey };
141
+ const account = await unlockAccount(client, await masterPassword());
142
+ await watchOwnKey(client, account);
143
+ return { client, privateKey: account.privateKey };
128
144
  }
129
145
 
130
146
  // ── Commands ────────────────────────────────────────────────────────────────────────
@@ -148,9 +164,10 @@ async function cmdLogin({ values }) {
148
164
  const client = createClient({ baseUrl, cookies, userAgent: USER_AGENT });
149
165
  const otp = async () => process.env.SHAREDENV_OTP || (await ask('Two-factor code: '));
150
166
  await login(client, { identifier, password, otp });
151
- await unlockPrivateKey(client, password); // the vault opens: only then is anything kept
167
+ const account = await unlockAccount(client, password); // the vault opens: only then is anything kept
152
168
  store.writeSession({ baseUrl, cookies: client.cookies() });
153
169
  note(`Signed in to ${baseUrl}.`);
170
+ await watchOwnKey(client, account);
154
171
  }
155
172
 
156
173
  async function cmdPull({ values, positionals: [ref] }) {
@@ -311,9 +328,10 @@ async function cmdAccept({ values, positionals: [ref] }) {
311
328
  const { client, privateKey } = await unlock();
312
329
  const safeId = await resolvePendingShare(client, ref, values.team);
313
330
  const confirm = confirmShare(values);
331
+ const log = { store };
314
332
  const result = values.team
315
- ? await acceptTeamShare(client, privateKey, values.team, safeId, { confirm })
316
- : await acceptShare(client, privateKey, safeId, { confirm });
333
+ ? await acceptTeamShare(client, privateKey, values.team, safeId, { confirm, log })
334
+ : await acceptShare(client, privateKey, safeId, { confirm, log });
317
335
  note(result.accepted ? 'Accepted.' : 'Left pending.');
318
336
  }
319
337
 
package/lib/auth.js CHANGED
@@ -42,6 +42,11 @@ export function publicKeyOf(privateKey) {
42
42
 
43
43
  // → the account's private key, in memory only.
44
44
  export async function unlockPrivateKey(client, password) {
45
+ return (await unlockAccount(client, password)).privateKey;
46
+ }
47
+
48
+ // → { privateKey, userId }: the key, and the name it is logged under (transparency.js).
49
+ export async function unlockAccount(client, password) {
45
50
  const keyring = expectOk(await client.call('GET', '/keyring/me'), 'cannot read your keyring');
46
51
  if (!keyring?.encPrivateKey) {
47
52
  throw new Error('this account has no keyring yet: open the web app once to create it');
@@ -66,5 +71,5 @@ export async function unlockPrivateKey(client, password) {
66
71
  if (String(keyring.publicKey || '').toLowerCase() !== publicKeyOf(privateKey)) {
67
72
  throw new Error('the public key on file is not the one of your private key — refusing');
68
73
  }
69
- return privateKey;
74
+ return { privateKey, userId: keyring.userId };
70
75
  }
@@ -0,0 +1,100 @@
1
+ // Key transparency — the public-key directory as a public, append-only log.
2
+ //
3
+ // The server is the key directory, and the directory is only rows in a database: whoever
4
+ // can write there (a leak, an insider, a bug) can answer "Bob" with a key of their own at
5
+ // the moment Alice seals to Bob. A client cannot tell a substituted key from a real one.
6
+ // What it CAN check is that every key is on the record, and that the record never
7
+ // changes behind its back:
8
+ //
9
+ // 1. Every key the server hands out is first written to a log of entries that only
10
+ // grows. Entry n carries the hash of entry n-1, so the head hash sums up the whole
11
+ // history: rewriting one past entry changes every hash after it.
12
+ // 2. A client seals to a key only if it is the LAST key the log holds for that
13
+ // subject. A substituted key must therefore be logged, in public.
14
+ // 3. Each owner watches the log for their own name. A key logged under it that is
15
+ // not theirs is reported: the substitution is caught, and the log is the proof.
16
+ // 4. Each client keeps the log it checked: a past entry rewritten, or a log shorter
17
+ // than what it already saw, is refused.
18
+ //
19
+ // Entries hold HASHES of the subject and of the key, never the ids or the keys: the
20
+ // log is readable by anyone and only says "some key of some subject changed at entry
21
+ // n". Whoever already knows a subject and a key (the person sealing to them) computes
22
+ // the same hashes. Subjects: `user:<id>`, `team:<id>`.
23
+ //
24
+ // It only does arithmetic on hashes. Every client (browser, extension, command line)
25
+ // computes the same hashes for the same entries.
26
+
27
+ import crypto from 'crypto';
28
+
29
+ export const KT_GENESIS = '0'.repeat(64);
30
+
31
+ const sha256 = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
32
+
33
+ export const subjectHash = (subject) => sha256(`sharedenv/kt/subject/v1|${subject}`);
34
+ export const keyHash = (publicKeyHex) =>
35
+ sha256(`sharedenv/kt/key/v1|${String(publicKeyHex).toLowerCase()}`);
36
+ export const entryHash = ({ seq, prev, subject, key }) =>
37
+ sha256(`sharedenv/kt/entry/v1|${seq}|${prev}|${subject}|${key}`);
38
+
39
+ // The next entry after `head` ({ seq, hash }), for a subject and a key.
40
+ export function nextEntry(head, subject, publicKeyHex) {
41
+ const entry = {
42
+ seq: head.seq + 1,
43
+ prev: head.hash,
44
+ subject: subjectHash(subject),
45
+ key: keyHash(publicKeyHex),
46
+ };
47
+ return { ...entry, hash: entryHash(entry) };
48
+ }
49
+
50
+ export class KtError extends Error {
51
+ constructor(code, message) {
52
+ super(message);
53
+ this.name = 'KtError';
54
+ this.code = code;
55
+ }
56
+ }
57
+
58
+ // What a client knows of the log: its head and the last key of every subject.
59
+ export const emptyView = () => ({ seq: 0, hash: KT_GENESIS, latest: {} });
60
+
61
+ // Replays `entries` (ascending, starting right after `view.seq`) onto a copy of `view`.
62
+ // watch { [subject]: publicKeyHex } — subjects whose key WE know (our own, a team
63
+ // whose private key we hold). Once their key is in the log, any later entry
64
+ // for them with another key is reported.
65
+ // -> { view, foreign, unlogged }
66
+ // foreign [{ subject, seq }] — a key that is not ours, logged under our name
67
+ // unlogged [subject] — our key is not the last one the log holds for us
68
+ // Throws KtError BROKEN: a gap, or a hash that does not chain.
69
+ export function replay(view, entries, { watch = {} } = {}) {
70
+ const next = { seq: view.seq, hash: view.hash, latest: { ...view.latest } };
71
+ const watched = Object.entries(watch).map(([subject, publicKey]) => ({
72
+ subject,
73
+ hashed: subjectHash(subject),
74
+ key: keyHash(publicKey),
75
+ armed: false,
76
+ }));
77
+ for (const w of watched) w.armed = next.latest[w.hashed] === w.key;
78
+
79
+ const foreign = [];
80
+ for (const e of entries) {
81
+ if (e.seq !== next.seq + 1 || e.prev !== next.hash || entryHash(e) !== e.hash) {
82
+ throw new KtError('BROKEN', `the log does not chain at entry ${e.seq}`);
83
+ }
84
+ next.seq = e.seq;
85
+ next.hash = e.hash;
86
+ next.latest[e.subject] = e.key;
87
+ for (const w of watched) {
88
+ if (e.subject !== w.hashed) continue;
89
+ if (e.key === w.key) w.armed = true;
90
+ else if (w.armed) foreign.push({ subject: w.subject, seq: e.seq });
91
+ }
92
+ }
93
+
94
+ const unlogged = watched.filter((w) => next.latest[w.hashed] !== w.key).map((w) => w.subject);
95
+ return { view: next, foreign, unlogged };
96
+ }
97
+
98
+ // Is `publicKeyHex` the last key the log holds for `subject`?
99
+ export const isLogged = (view, subject, publicKeyHex) =>
100
+ view.latest[subjectHash(subject)] === keyHash(publicKeyHex);
package/lib/crypto.js CHANGED
@@ -8,3 +8,4 @@ export {
8
8
  } from './crypto/kdf.js';
9
9
  export { openSecret } from './crypto/secretBlob.js';
10
10
  export { fingerprintOf } from './crypto/fingerprint.js';
11
+ export { emptyView, replay, isLogged } from './crypto/kt.js';
package/lib/shares.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { Wallet, fingerprintOf } from './crypto.js';
7
7
  import { expectOk, listAll } from './api.js';
8
+ import { assertLogged, syncLog } from './transparency.js';
8
9
 
9
10
  const wallet = new Wallet();
10
11
 
@@ -73,10 +74,27 @@ export async function openPendingShare(client, privateKey, safeId) {
73
74
  return { share, senderKey, fingerprint: fingerprintOf(senderKey) };
74
75
  }
75
76
 
77
+ // 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 }.
79
+ async function assertSenderLogged(client, log, opened, team) {
80
+ const { view } = await syncLog(client, log);
81
+ assertLogged(view, [
82
+ {
83
+ subject: `user:${opened.share.from?._id}`,
84
+ publicKey: opened.senderKey,
85
+ label: senderLabel(opened.share),
86
+ },
87
+ ...(team
88
+ ? [{ subject: `team:${team}`, publicKey: opened.teamPublicKey, label: 'the team' }]
89
+ : []),
90
+ ]);
91
+ }
92
+
76
93
  // Check, then accept. `confirm(opened)` (async → boolean) is where a human compares the
77
94
  // fingerprint; a no leaves the share pending.
78
- export async function acceptShare(client, privateKey, safeId, { confirm } = {}) {
95
+ export async function acceptShare(client, privateKey, safeId, { confirm, log } = {}) {
79
96
  const opened = await openPendingShare(client, privateKey, safeId);
97
+ await assertSenderLogged(client, log, opened);
80
98
  if (confirm && !(await confirm(opened))) return { ...opened, accepted: false };
81
99
  expectOk(await client.call('POST', `/shares/${safeId}/accept`), 'cannot accept the share');
82
100
  return { ...opened, accepted: true };
@@ -140,8 +158,9 @@ export async function openPendingTeamShare(client, privateKey, teamId, safeId) {
140
158
 
141
159
  // Check, re-seal to the team's current key (by us), accept. The grant version we opened
142
160
  // 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 } = {}) {
161
+ export async function acceptTeamShare(client, privateKey, teamId, safeId, { confirm, log } = {}) {
144
162
  const opened = await openPendingTeamShare(client, privateKey, teamId, safeId);
163
+ await assertSenderLogged(client, log, opened, teamId);
145
164
  // The DEK never moves to a team key nobody vouched for: `confirm` must check it.
146
165
  if (!opened.teamKeyVouched && !confirm) {
147
166
  throw new Error(
package/lib/store.js CHANGED
@@ -1,6 +1,7 @@
1
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.
2
+ // cookies, the public keys this machine has vouched for (trust.js), and how far it
3
+ // checked the public key log (transparency.js). Never the password, never a private
4
+ // key, never a secret.
4
5
 
5
6
  import { randomBytes } from 'crypto';
6
7
  import {
@@ -48,6 +49,7 @@ function readJson(file) {
48
49
  export function createStore(dir = process.env.SHAREDENV_HOME || join(homedir(), '.sharedenv')) {
49
50
  const sessionFile = join(dir, 'session.json');
50
51
  const pinsFile = join(dir, 'pins.json');
52
+ const logFile = join(dir, 'log.json');
51
53
  const save = (file, value) => {
52
54
  mkdirSync(dir, { recursive: true, mode: 0o700 });
53
55
  writePrivate(file, `${JSON.stringify(value, null, 2)}\n`);
@@ -62,5 +64,8 @@ export function createStore(dir = process.env.SHAREDENV_HOME || join(homedir(),
62
64
  // → { [subject]: publicKey }
63
65
  readPins: () => readJson(pinsFile) || {},
64
66
  writePins: (pins) => save(pinsFile, pins),
67
+ // → { seq, hash, latest } | null — the public key log, as far as it was checked
68
+ readLogView: () => readJson(logFile),
69
+ writeLogView: (view) => save(logFile, view),
65
70
  };
66
71
  }
@@ -0,0 +1,99 @@
1
+ // The public key log (key transparency). The server is the key directory, and the
2
+ // directory is rows in a database: whoever can write there could answer "Bob" with a key
3
+ // of their own. So every key it hands out is first written to a log that only grows,
4
+ // each entry chained to the previous one by its hash. This CLI:
5
+ // - seals to, or trusts the seal of, a key only if it is the LAST one the log holds
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 ;
8
+ // - keeps what it checked (~/.sharedenv/log.json) and refuses a log that does not
9
+ // chain, rewrites what it already saw, or goes back.
10
+ // The arithmetic is lib/crypto/kt.js.
11
+
12
+ import { emptyView, replay, isLogged } from './crypto.js';
13
+ import { expectOk } from './api.js';
14
+
15
+ const PAGE_LIMIT = 500;
16
+
17
+ const saneView = (v) =>
18
+ v &&
19
+ Number.isInteger(v.seq) &&
20
+ typeof v.hash === 'string' &&
21
+ v.latest &&
22
+ typeof v.latest === 'object';
23
+
24
+ // Reads the log after what this machine checked, replays it, keeps the result.
25
+ // 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.
28
+ export async function syncLog(client, { store, watch = {} } = {}) {
29
+ const stored = store?.readLogView();
30
+ const from = saneView(stored) ? stored : emptyView();
31
+
32
+ const read = async (to, page) => {
33
+ const bound = to === null ? '' : `&to=${to}`;
34
+ return expectOk(
35
+ await client.call(
36
+ 'GET',
37
+ `/transparency/log?from=${from.seq}${bound}&page=${page}&limit=${PAGE_LIMIT}`
38
+ ),
39
+ 'cannot read the public key log'
40
+ );
41
+ };
42
+ const first = await read(null, 1);
43
+ const { head } = first;
44
+ if (!Number.isInteger(head?.seq) || typeof head?.hash !== 'string') {
45
+ throw new Error('the public key log sent no head');
46
+ }
47
+ if (head.seq < from.seq) {
48
+ throw new Error('the public key log is shorter than what this machine already checked');
49
+ }
50
+ const entries = [...(first.data || [])];
51
+ for (let page = 2; page <= (first.pagination?.pages ?? 1); page += 1) {
52
+ entries.push(...((await read(head.seq, page)).data || []));
53
+ }
54
+
55
+ let replayed;
56
+ try {
57
+ replayed = replay(from, entries, { watch });
58
+ } catch (err) {
59
+ throw new Error(`the public key log does not add up: ${err.message}`);
60
+ }
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');
64
+ }
65
+ if (store && view.seq > from.seq) store.writeLogView(view);
66
+ return { view, foreign, unlogged };
67
+ }
68
+
69
+ // Every target's key must be the last one the log holds for its subject.
70
+ // targets: [{ subject, publicKey, label }]
71
+ export function assertLogged(view, targets) {
72
+ const missing = targets.filter((t) => !isLogged(view, t.subject, t.publicKey));
73
+ if (missing.length) {
74
+ throw new Error(
75
+ `the server handed out a key the public key log does not hold (${missing
76
+ .map((t) => t.label || t.subject)
77
+ .join(', ')}): refusing`
78
+ );
79
+ }
80
+ }
81
+
82
+ // What the watch found, as lines for stderr (none when all is well).
83
+ export function watchWarnings({ foreign = [], unlogged = [] }) {
84
+ const lines = [];
85
+ if (foreign.length) {
86
+ 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.'
90
+ );
91
+ }
92
+ if (unlogged.length) {
93
+ 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.'
96
+ );
97
+ }
98
+ return lines;
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sharedenv/cli",
3
- "version": "0.1.0",
3
+ "version": "1.1.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",