@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 +115 -11
- package/bin/sharedenv.js +24 -6
- package/lib/auth.js +6 -1
- package/lib/crypto/kt.js +100 -0
- package/lib/crypto.js +1 -0
- package/lib/shares.js +21 -2
- package/lib/store.js +7 -2
- package/lib/transparency.js +99 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**The zero-knowledge secrets vault for teams, in your terminal.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@sharedenv/cli)
|
|
6
|
-
[](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
|
-
###
|
|
67
|
+
### Get your secrets as a file, or as JSON
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
sharedenv pull prod
|
|
71
|
-
sharedenv pull prod --
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
- **
|
|
167
|
-
|
|
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
|
|
198
|
-
|
|
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,
|
|
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
|
|
127
|
-
|
|
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
|
|
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
|
}
|
package/lib/crypto/kt.js
ADDED
|
@@ -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
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,
|
|
3
|
-
//
|
|
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
|
+
}
|