@coffre/cli 0.1.6 → 0.1.7
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 +1 -0
- package/dist/main.js +1383 -197
- package/dist/templates/node/package.json +3 -3
- package/dist/templates/workers/package.json +4 -4
- package/package.json +4 -4
package/dist/main.js
CHANGED
|
@@ -1,11 +1,325 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { T as init, a as keys, w as KINDS } from "./keys-BCKUEb-Y.js";
|
|
2
|
+
import { T as init, _ as select, a as keys, c as Cancelled, f as openTerminal, h as release, u as hiddenLine, v as style, w as KINDS } from "./keys-BCKUEb-Y.js";
|
|
3
3
|
import { i as isRole, r as assignableToEnvironment, t as ROLES } from "./access-CO1kGjWW.js";
|
|
4
|
-
import { parseArgs, promisify } from "node:util";
|
|
4
|
+
import { inspect, parseArgs, promisify } from "node:util";
|
|
5
5
|
import { execFile, spawn } from "node:child_process";
|
|
6
6
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
|
+
//#region ../client/dist/index.js
|
|
11
|
+
/**
|
|
12
|
+
* The config from `name=value` arguments, as the CLI takes them. A field with
|
|
13
|
+
* several options takes them comma-separated, and one left out gets what the
|
|
14
|
+
* form preselects. Text goes as typed, for the server to check.
|
|
15
|
+
*/
|
|
16
|
+
function configFromArguments(provider, assignments) {
|
|
17
|
+
const config = {};
|
|
18
|
+
for (const assignment of assignments) {
|
|
19
|
+
const equals = assignment.indexOf("=");
|
|
20
|
+
const name = equals === -1 ? assignment : assignment.slice(0, equals);
|
|
21
|
+
const field = provider.fields.find((candidate) => candidate.name === name);
|
|
22
|
+
if (equals === -1 || field === void 0) {
|
|
23
|
+
const names = provider.fields.map((candidate) => candidate.name).join(", ");
|
|
24
|
+
throw new Error(`${provider.label} takes ${names} as name=value; got ${JSON.stringify(assignment)}`);
|
|
25
|
+
}
|
|
26
|
+
if (Object.hasOwn(config, name)) throw new Error(`${name} is given twice`);
|
|
27
|
+
const value = assignment.slice(equals + 1);
|
|
28
|
+
if (field.type === "text") config[name] = value;
|
|
29
|
+
else if (field.multiple) config[name] = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
30
|
+
else config[name] = value;
|
|
31
|
+
}
|
|
32
|
+
for (const field of provider.fields) if (field.type === "options" && !Object.hasOwn(config, field.name)) config[field.name] = field.multiple ? field.initial : field.initial[0];
|
|
33
|
+
return config;
|
|
34
|
+
}
|
|
35
|
+
/** The API's error shape, `{ error, message }`, with the HTTP status. */
|
|
36
|
+
const COFFRE_ERROR = Symbol.for("@coffre/client:CoffreError");
|
|
37
|
+
var CoffreError = class extends Error {
|
|
38
|
+
/**
|
|
39
|
+
* `@coffre/ui`'s prebuilt pages bundle a copy of this class, and a page is
|
|
40
|
+
* handed a client the server made with this package's. So `instanceof`
|
|
41
|
+
* asks for the mark every copy leaves, rather than for this copy's prototype.
|
|
42
|
+
*/
|
|
43
|
+
static [Symbol.hasInstance](value) {
|
|
44
|
+
return typeof value === "object" && value !== null && COFFRE_ERROR in value;
|
|
45
|
+
}
|
|
46
|
+
[COFFRE_ERROR] = true;
|
|
47
|
+
status;
|
|
48
|
+
code;
|
|
49
|
+
/** The vault's own code when it refused: `no_grant`, `removed`, `bulk_limit`, ... */
|
|
50
|
+
reason;
|
|
51
|
+
constructor(status, code, message, reason) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "CoffreError";
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.reason = reason;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/** `market/prod/KEY` as route parameters. */
|
|
60
|
+
function place(path) {
|
|
61
|
+
const [project = "", environment = "", key = ""] = path.replace(/^\/+|\/+$/g, "").split("/");
|
|
62
|
+
return {
|
|
63
|
+
project,
|
|
64
|
+
environment,
|
|
65
|
+
key
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function createClient(options) {
|
|
69
|
+
const origin = options.url.replace(/\/+$/, "");
|
|
70
|
+
const transport = options.transport ?? ((request) => fetch(request));
|
|
71
|
+
async function send(method, path, input) {
|
|
72
|
+
const url = new URL(`${origin}/api${path}`);
|
|
73
|
+
const headers = new Headers(await options.headers?.());
|
|
74
|
+
let body;
|
|
75
|
+
if (method === "GET") {
|
|
76
|
+
for (const [name, value] of Object.entries(input ?? {})) if (value !== void 0 && value !== null) url.searchParams.set(name, String(value));
|
|
77
|
+
} else if (input !== void 0) {
|
|
78
|
+
headers.set("content-type", "application/json");
|
|
79
|
+
body = JSON.stringify(input);
|
|
80
|
+
}
|
|
81
|
+
const response = await transport(new Request(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers,
|
|
84
|
+
body
|
|
85
|
+
}));
|
|
86
|
+
const payload = await response.json().catch(() => null);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const error = payload;
|
|
89
|
+
throw new CoffreError(response.status, typeof error?.error === "string" ? error.error : "http_error", typeof error?.message === "string" ? error.message : `request failed with status ${response.status}`, typeof error?.reason === "string" ? error.reason : void 0);
|
|
90
|
+
}
|
|
91
|
+
return payload;
|
|
92
|
+
}
|
|
93
|
+
/** A route key and its parameters as a method and a path. */
|
|
94
|
+
function address(key, params) {
|
|
95
|
+
const [method, pattern] = key.split(" ");
|
|
96
|
+
return [method, pattern.replace(/:(\w+)/g, (_, name) => encodeURIComponent(params[name]))];
|
|
97
|
+
}
|
|
98
|
+
/** Any route by its key: `call('GET /secrets/:project/:environment', { project, environment })`. */
|
|
99
|
+
async function call(key, ...[params, input]) {
|
|
100
|
+
const [method, path] = address(key, params);
|
|
101
|
+
return await send(method, path, input);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
call,
|
|
105
|
+
/** How this instance signs people in. Answers anyone, signed in or not. */
|
|
106
|
+
auth: () => send("GET", "/auth", void 0),
|
|
107
|
+
/** Who I am, and every place I can reach. */
|
|
108
|
+
me: () => call("GET /me", {}),
|
|
109
|
+
projects: {
|
|
110
|
+
/** With their environments. */
|
|
111
|
+
list: () => call("GET /projects", {}),
|
|
112
|
+
create: (project, input) => call("PUT /projects/:project", { project }, input),
|
|
113
|
+
update: (project, patch) => call("PATCH /projects/:project", { project }, patch)
|
|
114
|
+
},
|
|
115
|
+
environments: {
|
|
116
|
+
create: (path, input) => call("PUT /projects/:project/:environment", place(path), input),
|
|
117
|
+
update: (path, patch) => call("PATCH /projects/:project/:environment", place(path), patch)
|
|
118
|
+
},
|
|
119
|
+
secrets: {
|
|
120
|
+
/** Keys, versions and who changed what; never values. */
|
|
121
|
+
list: (path) => call("GET /secrets/:project/:environment", place(path)),
|
|
122
|
+
/** Decrypts one secret, or every secret in an environment. Logged in your name. */
|
|
123
|
+
reveal: (path) => call("POST /reveals", {}, { path }),
|
|
124
|
+
/** One transaction, a version and an audit entry per key; `null` archives. */
|
|
125
|
+
set: (path, values) => call("PATCH /secrets/:project/:environment", place(path), values),
|
|
126
|
+
/**
|
|
127
|
+
* What `set` would do to each key, `added`, `changed`, `unchanged` or
|
|
128
|
+
* `archived`, and nothing else: no value comes back and nothing is
|
|
129
|
+
* written. Comparing opens the current values, logged as reads.
|
|
130
|
+
*/
|
|
131
|
+
dryRun: (path, values) => {
|
|
132
|
+
const [method, route] = address("PATCH /secrets/:project/:environment", place(path));
|
|
133
|
+
return send(method, `${route}?dryRun=1`, values);
|
|
134
|
+
},
|
|
135
|
+
history: (path) => call("GET /secrets/:project/:environment/:key/versions", place(path)),
|
|
136
|
+
/** A new version holding the old one's value. */
|
|
137
|
+
restore: (path, version) => call("POST /secrets/:project/:environment/:key/restore", place(path), { version }),
|
|
138
|
+
rename: (path, key) => call("PATCH /secrets/:project/:environment/:key", place(path), { key }),
|
|
139
|
+
update: (path, patch) => call("PATCH /secrets/:project/:environment/:key", place(path), patch)
|
|
140
|
+
},
|
|
141
|
+
members: {
|
|
142
|
+
/** People and tokens, their role and their access; at a place, those who reach it. */
|
|
143
|
+
list: (path) => call("GET /members", {}, { path }),
|
|
144
|
+
/** What they hold, and what to rotate if they leave. */
|
|
145
|
+
get: (member) => call("GET /members/:member", { member }),
|
|
146
|
+
add: (member, input = {}) => call("PUT /members/:member", { member }, input),
|
|
147
|
+
/** Offboards; returns what to rotate. */
|
|
148
|
+
remove: (member) => call("DELETE /members/:member", { member })
|
|
149
|
+
},
|
|
150
|
+
tokens: {
|
|
151
|
+
list: (member) => call("GET /members/:member/tokens", { member }),
|
|
152
|
+
/** The value is in the answer, and only there. */
|
|
153
|
+
issue: (member, input) => call("POST /members/:member/tokens", { member }, input),
|
|
154
|
+
revoke: (member, id) => call("DELETE /members/:member/tokens/:id", {
|
|
155
|
+
member,
|
|
156
|
+
id
|
|
157
|
+
})
|
|
158
|
+
},
|
|
159
|
+
access: {
|
|
160
|
+
/** What they should hold at each place; the server applies the difference. `null` revokes. */
|
|
161
|
+
set: (member, access) => call("PATCH /access/:member", { member }, access) },
|
|
162
|
+
/** Where I am signed in. Only where coffre runs its own sign-in. */
|
|
163
|
+
sessions: {
|
|
164
|
+
list: () => call("GET /sessions", {}),
|
|
165
|
+
revoke: (id) => call("DELETE /sessions/:id", { id })
|
|
166
|
+
},
|
|
167
|
+
/** The accounts I sign in with. */
|
|
168
|
+
identities: {
|
|
169
|
+
list: () => call("GET /identities", {}),
|
|
170
|
+
unlink: (id) => call("DELETE /identities/:id", { id })
|
|
171
|
+
},
|
|
172
|
+
/** A `coffre login` waiting for someone to approve it, by the code it shows. */
|
|
173
|
+
deviceLogins: {
|
|
174
|
+
get: (code) => call("GET /device-logins/:code", { code }),
|
|
175
|
+
decide: (code, approve) => call("POST /device-logins/:code", { code }, { approve })
|
|
176
|
+
},
|
|
177
|
+
syncs: {
|
|
178
|
+
/** Where a sync can push on this instance, and what each asks for. */
|
|
179
|
+
providers: () => call("GET /syncs/providers", {}),
|
|
180
|
+
list: (path) => call("GET /syncs/:project/:environment", place(path)),
|
|
181
|
+
add: (path, input) => call("POST /syncs/:project/:environment", place(path), input),
|
|
182
|
+
update: (id, patch) => call("PATCH /syncs/by-id/:id", { id }, patch),
|
|
183
|
+
remove: (id) => call("DELETE /syncs/by-id/:id", { id }),
|
|
184
|
+
run: (id) => call("POST /syncs/by-id/:id/runs", { id })
|
|
185
|
+
},
|
|
186
|
+
audit: {
|
|
187
|
+
list: (query = {}) => call("GET /audit", {}, query),
|
|
188
|
+
verify: () => call("GET /audit/verification", {}),
|
|
189
|
+
/** What an escrowed key is checked against: public material, owners and root admins only. */
|
|
190
|
+
keys: () => call("GET /audit/keys", {})
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* What writing these entries (a parsed `.env` file) would do, from a dry run
|
|
196
|
+
* of the write: the server compares, so no value leaves it, and the values it
|
|
197
|
+
* opens to compare are logged as reads. `changes` is what to pass to
|
|
198
|
+
* `secrets.set`: the keys that differ, so an unchanged value does not become
|
|
199
|
+
* a new version.
|
|
200
|
+
*/
|
|
201
|
+
async function planImport(coffre, path, entries) {
|
|
202
|
+
const values = Object.create(null);
|
|
203
|
+
for (const { key, value } of entries) values[key] = value;
|
|
204
|
+
const [listed, compared] = await Promise.allSettled([coffre.secrets.list(path), coffre.secrets.dryRun(path, { ...values })]);
|
|
205
|
+
if (listed.status === "rejected") throw listed.reason;
|
|
206
|
+
if (compared.status === "rejected") throw compared.reason;
|
|
207
|
+
const [{ keys }, dryRun] = [listed.value, compared.value];
|
|
208
|
+
const versions = new Map(keys.map((entry) => [entry.key, entry.version]));
|
|
209
|
+
const plan = [];
|
|
210
|
+
const changes = Object.create(null);
|
|
211
|
+
for (const [key, value] of Object.entries(values)) {
|
|
212
|
+
const action = dryRun.keys[key];
|
|
213
|
+
plan.push({
|
|
214
|
+
key,
|
|
215
|
+
action,
|
|
216
|
+
version: versions.get(key) ?? null
|
|
217
|
+
});
|
|
218
|
+
if (action !== "unchanged") changes[key] = value;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
plan,
|
|
222
|
+
changes: { ...changes }
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/verify/checks.ts
|
|
227
|
+
/** A check that failed: what went wrong, and what was seen instead. */
|
|
228
|
+
var Failure = class extends Error {
|
|
229
|
+
detail;
|
|
230
|
+
constructor(message, detail) {
|
|
231
|
+
super(message);
|
|
232
|
+
this.detail = detail;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
/** A check that cannot run against this instance, and why. */
|
|
236
|
+
var Skip = class extends Error {};
|
|
237
|
+
function expect(condition, message, detail) {
|
|
238
|
+
if (!condition) throw new Failure(message, detail);
|
|
239
|
+
}
|
|
240
|
+
/** The status coffre turned a call away with. A call that went through fails the check: `what` says what it should not have done. */
|
|
241
|
+
async function refused(what, call) {
|
|
242
|
+
const outcome = await call.then((value) => ({ value }), (error) => ({ error }));
|
|
243
|
+
if ("value" in outcome) throw new Failure(`${what}, and was let`, outcome.value);
|
|
244
|
+
if (!(outcome.error instanceof CoffreError)) throw outcome.error;
|
|
245
|
+
expect(outcome.error.status >= 400 && outcome.error.status < 500, `${what}, and failed with ${outcome.error.status}`, outcome.error.message);
|
|
246
|
+
return outcome.error;
|
|
247
|
+
}
|
|
248
|
+
/** End the command at once, for a mistake in how it was called: `message` on stderr, or on stdout for an exit 0 (help). */
|
|
249
|
+
function stop(code, message) {
|
|
250
|
+
(code === 0 ? process.stdout : process.stderr).write(`${message}\n`);
|
|
251
|
+
process.exit(code);
|
|
252
|
+
}
|
|
253
|
+
/** The API as a service or the CLI calls it, with a bearer token: every refusal thrown, as a check wants it. */
|
|
254
|
+
function bearer(origin, token) {
|
|
255
|
+
return createClient({
|
|
256
|
+
url: origin,
|
|
257
|
+
headers: () => ({ authorization: `Bearer ${token}` })
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** The checks of one run, each printed as it ends: a mark, its name, and a line. */
|
|
261
|
+
var Checks = class {
|
|
262
|
+
#out;
|
|
263
|
+
#results = [];
|
|
264
|
+
constructor(out) {
|
|
265
|
+
this.#out = out;
|
|
266
|
+
}
|
|
267
|
+
get results() {
|
|
268
|
+
return this.#results;
|
|
269
|
+
}
|
|
270
|
+
get failed() {
|
|
271
|
+
return this.#results.filter(({ status }) => status === "fail").map(({ name }) => name);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Run one check. `needs` holds what earlier checks made: if one is
|
|
275
|
+
* missing, this one is skipped rather than run against an instance that
|
|
276
|
+
* is not in the state it expects.
|
|
277
|
+
*/
|
|
278
|
+
async check(name, needs, run) {
|
|
279
|
+
const missing = Object.keys(needs).filter((key) => needs[key] === void 0 || needs[key] === null);
|
|
280
|
+
if (missing.length > 0) {
|
|
281
|
+
this.#print(name, "skip", `no ${missing.join(" or ")}: an earlier check failed`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const passed = await run(needs);
|
|
286
|
+
this.#print(name, "ok", typeof passed === "string" ? passed : passed.detail);
|
|
287
|
+
return typeof passed === "string" ? void 0 : passed.value;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error instanceof Skip) {
|
|
290
|
+
this.#print(name, "skip", error.message);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const detail = error instanceof Failure ? error.detail : error instanceof Error ? error.stack : error;
|
|
294
|
+
this.#print(name, "fail", error instanceof Error ? error.message : String(error), detail);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
#print(name, status, line, detail) {
|
|
299
|
+
this.#results.push({
|
|
300
|
+
name,
|
|
301
|
+
status,
|
|
302
|
+
line
|
|
303
|
+
});
|
|
304
|
+
const s = style(this.#out);
|
|
305
|
+
const mark = {
|
|
306
|
+
ok: s.green("✓"),
|
|
307
|
+
fail: s.red("✗"),
|
|
308
|
+
skip: s.dim("–")
|
|
309
|
+
}[status];
|
|
310
|
+
const text = status === "skip" ? s.dim(line) : status === "fail" ? s.red(line) : line;
|
|
311
|
+
this.#out.write(` ${mark} ${status === "skip" ? s.dim(name.padEnd(19)) : name.padEnd(19)} ${text}\n`);
|
|
312
|
+
if (status === "fail" && detail !== void 0) {
|
|
313
|
+
const shown = typeof detail === "string" ? detail : inspect(detail, {
|
|
314
|
+
depth: 6,
|
|
315
|
+
breakLength: 100,
|
|
316
|
+
colors: false
|
|
317
|
+
});
|
|
318
|
+
this.#out.write(`${s.dim(shown.replace(/^/gm, " ".repeat(24)))}\n`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
9
323
|
//#region src/instance.ts
|
|
10
324
|
const MODES = ["signin", "cloudflare"];
|
|
11
325
|
function emptyStore() {
|
|
@@ -190,219 +504,1064 @@ function withoutSession(store, origin) {
|
|
|
190
504
|
};
|
|
191
505
|
}
|
|
192
506
|
//#endregion
|
|
193
|
-
//#region ../client/dist/
|
|
507
|
+
//#region ../client/dist/routes.js
|
|
508
|
+
function getCalls({ places, secrets, members, services }) {
|
|
509
|
+
const first = places[0];
|
|
510
|
+
return {
|
|
511
|
+
"GET /me": [{ params: {} }],
|
|
512
|
+
"GET /projects": [{ params: {} }],
|
|
513
|
+
"GET /secrets/:project/:environment": places.map((params) => ({ params })),
|
|
514
|
+
"GET /secrets/:project/:environment/:key/versions": secrets.map((params) => ({ params })),
|
|
515
|
+
"GET /members": [{ params: {} }, ...first === void 0 ? [] : [{
|
|
516
|
+
params: {},
|
|
517
|
+
input: { path: `${first.project}/${first.environment}` }
|
|
518
|
+
}]],
|
|
519
|
+
"GET /members/:member": members.map((member) => ({ params: { member } })),
|
|
520
|
+
"GET /members/:member/tokens": services.map((member) => ({ params: { member } })),
|
|
521
|
+
"GET /sessions": [{ params: {} }],
|
|
522
|
+
"GET /identities": [{ params: {} }],
|
|
523
|
+
"GET /device-logins/:code": [{ params: { code: "BCDF-GHJK" } }],
|
|
524
|
+
"GET /syncs/providers": [{ params: {} }],
|
|
525
|
+
"GET /syncs/:project/:environment": places.map((params) => ({ params })),
|
|
526
|
+
"GET /audit": [{
|
|
527
|
+
params: {},
|
|
528
|
+
input: { limit: 500 }
|
|
529
|
+
}, {
|
|
530
|
+
params: {},
|
|
531
|
+
input: {
|
|
532
|
+
limit: 500,
|
|
533
|
+
detail: "1"
|
|
534
|
+
}
|
|
535
|
+
}],
|
|
536
|
+
"GET /audit/verification": [{ params: {} }],
|
|
537
|
+
"GET /audit/keys": [{ params: {} }]
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** Each GET call as a URL, with its parameters and query filled in. */
|
|
541
|
+
function getUrls(origin, calls) {
|
|
542
|
+
const urls = [];
|
|
543
|
+
for (const [key, asks] of Object.entries(calls)) for (const { params, input } of asks) urls.push({
|
|
544
|
+
key,
|
|
545
|
+
url: address(origin, key, params, input)
|
|
546
|
+
});
|
|
547
|
+
return urls;
|
|
548
|
+
}
|
|
549
|
+
/** Every route, whatever its method. */
|
|
550
|
+
const EVERY_ROUTE = {
|
|
551
|
+
"GET /me": true,
|
|
552
|
+
"GET /projects": true,
|
|
553
|
+
"PUT /projects/:project": true,
|
|
554
|
+
"PATCH /projects/:project": true,
|
|
555
|
+
"PUT /projects/:project/:environment": true,
|
|
556
|
+
"PATCH /projects/:project/:environment": true,
|
|
557
|
+
"GET /secrets/:project/:environment": true,
|
|
558
|
+
"PATCH /secrets/:project/:environment": true,
|
|
559
|
+
"PATCH /secrets/:project/:environment/:key": true,
|
|
560
|
+
"GET /secrets/:project/:environment/:key/versions": true,
|
|
561
|
+
"POST /secrets/:project/:environment/:key/restore": true,
|
|
562
|
+
"POST /reveals": true,
|
|
563
|
+
"GET /members": true,
|
|
564
|
+
"GET /members/:member": true,
|
|
565
|
+
"PUT /members/:member": true,
|
|
566
|
+
"DELETE /members/:member": true,
|
|
567
|
+
"GET /members/:member/tokens": true,
|
|
568
|
+
"POST /members/:member/tokens": true,
|
|
569
|
+
"DELETE /members/:member/tokens/:id": true,
|
|
570
|
+
"PATCH /access/:member": true,
|
|
571
|
+
"GET /sessions": true,
|
|
572
|
+
"DELETE /sessions/:id": true,
|
|
573
|
+
"GET /identities": true,
|
|
574
|
+
"DELETE /identities/:id": true,
|
|
575
|
+
"GET /device-logins/:code": true,
|
|
576
|
+
"POST /device-logins/:code": true,
|
|
577
|
+
"GET /syncs/providers": true,
|
|
578
|
+
"GET /syncs/:project/:environment": true,
|
|
579
|
+
"POST /syncs/:project/:environment": true,
|
|
580
|
+
"PATCH /syncs/by-id/:id": true,
|
|
581
|
+
"DELETE /syncs/by-id/:id": true,
|
|
582
|
+
"POST /syncs/by-id/:id/runs": true,
|
|
583
|
+
"GET /audit": true,
|
|
584
|
+
"GET /audit/verification": true,
|
|
585
|
+
"GET /audit/keys": true
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* Every route as a method and a URL, each parameter a made-up value: what
|
|
589
|
+
* a caller with no right to any of them would send.
|
|
590
|
+
*/
|
|
591
|
+
function everyRoute(origin) {
|
|
592
|
+
return Object.keys(EVERY_ROUTE).map((key) => {
|
|
593
|
+
const [method, pattern] = key.split(" ");
|
|
594
|
+
return {
|
|
595
|
+
key,
|
|
596
|
+
method,
|
|
597
|
+
url: `${origin}/api${pattern.replace(/:(\w+)/g, (_, name) => encodeURIComponent(name === "member" ? "token:conformance-nobody" : "conformance-nobody"))}`
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function address(origin, key, params, input) {
|
|
602
|
+
const path = key.slice(4).replace(/:(\w+)/g, (_, name) => encodeURIComponent(params[name]));
|
|
603
|
+
const url = new URL(`${origin}/api${path}`);
|
|
604
|
+
for (const [name, value] of Object.entries(input ?? {})) url.searchParams.set(name, String(value));
|
|
605
|
+
return url.href;
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/verify/anonymous.ts
|
|
609
|
+
/** The page anyone may open. Every other page sends no one to /login. */
|
|
610
|
+
const OPEN_PAGES = ["/login"];
|
|
611
|
+
/** The pages behind sign-in, whatever the instance holds. */
|
|
612
|
+
const CLOSED_PAGES = [
|
|
613
|
+
"/",
|
|
614
|
+
"/account",
|
|
615
|
+
"/access",
|
|
616
|
+
"/audit",
|
|
617
|
+
"/auth/device",
|
|
618
|
+
"/settings",
|
|
619
|
+
"/projects",
|
|
620
|
+
"/users",
|
|
621
|
+
"/tokens",
|
|
622
|
+
"/unregistered"
|
|
623
|
+
];
|
|
624
|
+
/** A made-up place and member, which no instance has. */
|
|
625
|
+
const NOWHERE = {
|
|
626
|
+
project: "conformance-nowhere",
|
|
627
|
+
environment: "none"
|
|
628
|
+
};
|
|
629
|
+
const NOBODY = "token:conformance-nobody";
|
|
630
|
+
/** The checks anyone can run: no sign-in, nothing written. */
|
|
631
|
+
async function anonymousChecks(report, origin) {
|
|
632
|
+
await report.check("health", {}, () => reachable(origin));
|
|
633
|
+
await report.check("headers", {}, () => headers(origin));
|
|
634
|
+
await report.check("anonymous api", {}, () => anonymousApi(origin));
|
|
635
|
+
await report.check("forged cross-site", {}, () => forgedCrossSite(origin));
|
|
636
|
+
await report.check("sign-in info", {}, () => signinInfo(origin));
|
|
637
|
+
await report.check("anonymous answers", {}, () => anonymousAnswers(origin));
|
|
638
|
+
}
|
|
639
|
+
/** An instance someone else runs: up, and its scheduled job beating. */
|
|
640
|
+
async function reachable(origin) {
|
|
641
|
+
const live = await fetch(`${origin}/livez`);
|
|
642
|
+
expect(live.ok, `/livez answered ${live.status}`);
|
|
643
|
+
const ready = await fetch(`${origin}/readyz`);
|
|
644
|
+
expect(ready.ok, `/readyz answered ${ready.status}: is the scheduled job running?`, await ready.text());
|
|
645
|
+
return "/livez and /readyz";
|
|
646
|
+
}
|
|
647
|
+
async function headers(origin) {
|
|
648
|
+
const anonymous = await fetch(`${origin}/api/me`);
|
|
649
|
+
expect(anonymous.status === 401, `/api/me answered ${anonymous.status} to no one`);
|
|
650
|
+
securityHeaders(anonymous, "a refusal");
|
|
651
|
+
const login = await fetch(`${origin}/login`);
|
|
652
|
+
expect(login.status === 200, `/login answered ${login.status}`);
|
|
653
|
+
const csp = securityHeaders(login, "/login");
|
|
654
|
+
const html = await login.text();
|
|
655
|
+
const nonce = /'nonce-([^']+)'/.exec(csp)[1];
|
|
656
|
+
expect(html.includes(`nonce="${nonce}"`), "/login's scripts do not carry the policy's nonce");
|
|
657
|
+
expect(!securityHeaders(await fetch(`${origin}/login`), "/login").includes(`'nonce-${nonce}'`), "two pages were served with the same nonce");
|
|
658
|
+
const asset = /\/_coffre\/assets\/[\w.-]+\.js/.exec(html)?.[0];
|
|
659
|
+
expect(asset !== void 0, "/login loads no script from /_coffre/assets/", html.slice(0, 2e3));
|
|
660
|
+
const script = await fetch(`${origin}${asset}`);
|
|
661
|
+
expect(script.ok && /javascript/.test(script.headers.get("content-type") ?? ""), `${asset} answered ${script.status} ${script.headers.get("content-type")}`);
|
|
662
|
+
const allowed = (await fetch(`${origin}/api/me`, { headers: { origin: "https://attacker.example" } })).headers.get("access-control-allow-origin");
|
|
663
|
+
expect(allowed === null, `the API allows another origin to read it: ${allowed}`);
|
|
664
|
+
return `a fresh CSP nonce per page, frames refused, nosniff; no CORS; ${asset} served`;
|
|
665
|
+
}
|
|
666
|
+
function securityHeaders(response, what) {
|
|
667
|
+
const csp = response.headers.get("content-security-policy") ?? "";
|
|
668
|
+
expect(/script-src 'self' 'nonce-[^']+'/.test(csp), `${what}: no nonce-based script-src`, csp);
|
|
669
|
+
expect(csp.includes("frame-ancestors 'none'"), `${what}: frames not refused`, csp);
|
|
670
|
+
expect(response.headers.get("x-content-type-options") === "nosniff", `${what}: no nosniff`);
|
|
671
|
+
expect(response.headers.get("x-frame-options") === "DENY", `${what}: no x-frame-options DENY`);
|
|
672
|
+
expect(response.headers.get("cross-origin-resource-policy") === "same-origin", `${what}: other sites may embed it`);
|
|
673
|
+
expect(/no-store/.test(response.headers.get("cache-control") ?? ""), `${what}: may be cached`);
|
|
674
|
+
if (response.url.startsWith("https:")) {
|
|
675
|
+
const hsts = response.headers.get("strict-transport-security") ?? "";
|
|
676
|
+
expect(/max-age=\d{7,}/.test(hsts), `${what}: no strict-transport-security for months or more`, hsts);
|
|
677
|
+
}
|
|
678
|
+
return csp;
|
|
679
|
+
}
|
|
680
|
+
/** Every route, whatever its method, turns away a caller with no credential, and says nothing else. */
|
|
681
|
+
async function anonymousApi(origin) {
|
|
682
|
+
const routes = everyRoute(origin);
|
|
683
|
+
for (const { key, method, url } of routes) {
|
|
684
|
+
const response = await fetch(url, {
|
|
685
|
+
method,
|
|
686
|
+
headers: { "content-type": "application/json" },
|
|
687
|
+
body: method === "GET" ? void 0 : "{}"
|
|
688
|
+
});
|
|
689
|
+
const text = await response.text();
|
|
690
|
+
expect(response.status === 401, `${key} answered ${response.status} to no one`, text);
|
|
691
|
+
expect(onlyRefusal(text), `${key} refused no one with more than a refusal`, text);
|
|
692
|
+
}
|
|
693
|
+
return `${routes.length} routes, every method: 401, and nothing but the refusal`;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* A change sent with a session cookie from another site's page is refused
|
|
697
|
+
* before the cookie is even looked at, so a made-up one shows it: a real
|
|
698
|
+
* cookie would get the same answer. Behind Cloudflare Access the same holds
|
|
699
|
+
* for its cookie and assertion.
|
|
700
|
+
*/
|
|
701
|
+
async function forgedCrossSite(origin) {
|
|
702
|
+
const changes = everyRoute(origin).filter((route) => route.method !== "GET");
|
|
703
|
+
const forged = {
|
|
704
|
+
cookie: "coffre_session=conformance-forged; __Host-coffre_session=conformance-forged; CF_Authorization=conformance-forged",
|
|
705
|
+
"cf-access-jwt-assertion": "conformance-forged",
|
|
706
|
+
origin: "https://attacker.example",
|
|
707
|
+
"sec-fetch-site": "cross-site",
|
|
708
|
+
"content-type": "application/json"
|
|
709
|
+
};
|
|
710
|
+
for (const { key, method, url } of [...changes, {
|
|
711
|
+
key: "POST /auth/signout",
|
|
712
|
+
method: "POST",
|
|
713
|
+
url: `${origin}/auth/signout`
|
|
714
|
+
}]) {
|
|
715
|
+
const response = await fetch(url, {
|
|
716
|
+
method,
|
|
717
|
+
headers: forged,
|
|
718
|
+
body: "{}",
|
|
719
|
+
redirect: "manual"
|
|
720
|
+
});
|
|
721
|
+
const text = await response.text();
|
|
722
|
+
expect(response.status === 403, `${key} from another site answered ${response.status}`, text);
|
|
723
|
+
}
|
|
724
|
+
return `${changes.length + 1} changes, sign-out included, from another site with a session cookie: 403`;
|
|
725
|
+
}
|
|
726
|
+
/** `GET /api/auth` names how to sign in, and nothing more. */
|
|
727
|
+
async function signinInfo(origin) {
|
|
728
|
+
const response = await fetch(`${origin}/api/auth`);
|
|
729
|
+
expect(response.ok, `/api/auth answered ${response.status}`);
|
|
730
|
+
const info = await response.json();
|
|
731
|
+
expect(sameKeys(info, ["access", "signin"]), "/api/auth says more than how to sign in", info);
|
|
732
|
+
expect(info.signin === null !== (info.access === null), "/api/auth names neither or both ways in", info);
|
|
733
|
+
if (info.access !== null) {
|
|
734
|
+
expect(sameKeys(info.access, ["assertion"]) && info.access.assertion === false, "/api/auth says more of Access than whether it vouched", info);
|
|
735
|
+
return "Cloudflare Access, and nothing more";
|
|
736
|
+
}
|
|
737
|
+
const signin = info.signin;
|
|
738
|
+
expect(sameKeys(signin, [
|
|
739
|
+
"note",
|
|
740
|
+
"providers",
|
|
741
|
+
"title"
|
|
742
|
+
]), "/api/auth says more of its sign-in than a page needs", signin);
|
|
743
|
+
for (const provider of signin.providers) expect(sameKeys(provider, [
|
|
744
|
+
"brand",
|
|
745
|
+
"id",
|
|
746
|
+
"label"
|
|
747
|
+
]), `/api/auth says more of ${provider.id} than its button`, provider);
|
|
748
|
+
return `coffre's sign-in through ${signin.providers.map((provider) => provider.id).join(", ")}, and nothing more`;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Nothing that could be a stored value in what no one is shown. Without a
|
|
752
|
+
* canary this is a best effort: the API's refusals are checked to be only
|
|
753
|
+
* refusals above, every closed page must send no one to /login with an
|
|
754
|
+
* empty body, and the open pages must carry no credential coffre issues.
|
|
755
|
+
*/
|
|
756
|
+
async function anonymousAnswers(origin) {
|
|
757
|
+
const pages = [
|
|
758
|
+
...CLOSED_PAGES,
|
|
759
|
+
`/projects/${NOWHERE.project}`,
|
|
760
|
+
`/projects/${NOWHERE.project}/${NOWHERE.environment}`
|
|
761
|
+
];
|
|
762
|
+
for (const path of pages) {
|
|
763
|
+
let at = path;
|
|
764
|
+
for (let hop = 0; at !== "/login"; hop++) {
|
|
765
|
+
const response = await fetch(`${origin}${at}`, { redirect: "manual" });
|
|
766
|
+
const text = await response.text();
|
|
767
|
+
const to = new URL(response.headers.get("location") ?? at, origin);
|
|
768
|
+
expect(hop < 3 && response.status >= 300 && response.status < 400 && to.origin === origin, `${at} answered no one ${response.status}${response.headers.has("location") ? ` to ${to.href}` : ""}, not a redirect on to /login`, text.slice(0, 2e3));
|
|
769
|
+
expect(text.trim().length < 512, `${at} sent no one a body with its redirect`, text.slice(0, 2e3));
|
|
770
|
+
at = to.pathname;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
for (const path of OPEN_PAGES) {
|
|
774
|
+
const response = await fetch(`${origin}${path}`, { redirect: "manual" });
|
|
775
|
+
const text = await response.text();
|
|
776
|
+
expect(response.status === 200, `${path} answered ${response.status}`);
|
|
777
|
+
const issued = /coffre_(svc|cli|web)_[A-Za-z0-9_-]{20,}/.exec(text);
|
|
778
|
+
expect(issued === null, `${path} carries a credential coffre issues`, issued?.[0]);
|
|
779
|
+
}
|
|
780
|
+
return `best effort, without a canary: ${pages.length} closed pages send no one to /login with nothing else; /login carries no credential`;
|
|
781
|
+
}
|
|
782
|
+
/** `{ error, message }` and nothing else. */
|
|
783
|
+
function onlyRefusal(text) {
|
|
784
|
+
try {
|
|
785
|
+
return sameKeys(JSON.parse(text), ["error", "message"]);
|
|
786
|
+
} catch {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function sameKeys(value, keys) {
|
|
791
|
+
return typeof value === "object" && value !== null && JSON.stringify(Object.keys(value).sort()) === JSON.stringify(keys);
|
|
792
|
+
}
|
|
793
|
+
//#endregion
|
|
794
|
+
//#region src/verify/token.ts
|
|
795
|
+
/** Parse `<project>/<environment>/<KEY>=<value>`, or the path alone with the value given apart. */
|
|
796
|
+
function parseCanary(text, value) {
|
|
797
|
+
const at = text.indexOf("=");
|
|
798
|
+
const path = at === -1 ? text : text.slice(0, at);
|
|
799
|
+
const given = at === -1 ? value : text.slice(at + 1);
|
|
800
|
+
const parts = path.split("/");
|
|
801
|
+
if (parts.length !== 3 || parts.some((part) => part === "")) throw new Error(`the canary is <project>/<environment>/<KEY>, not "${path}"`);
|
|
802
|
+
if (given === void 0 || given === "") throw new Error("the canary has no value: pass it after =, on stdin, or in COFFRE_CONFORMANCE_CANARY");
|
|
803
|
+
const [project, environment, key] = parts;
|
|
804
|
+
return {
|
|
805
|
+
project,
|
|
806
|
+
environment,
|
|
807
|
+
key,
|
|
808
|
+
value: given
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* The checks with a service token that reads the canary, and audits its
|
|
813
|
+
* project. `prefix` names them apart, run twice; `verification` false leaves
|
|
814
|
+
* out the last, which a token can only skip, for a caller that verifies the
|
|
815
|
+
* chain otherwise.
|
|
816
|
+
*/
|
|
817
|
+
async function tokenChecks(report, origin, setup, { prefix = "", verification = true } = {}) {
|
|
818
|
+
const api = setup.token === void 0 ? void 0 : bearer(origin, setup.token);
|
|
819
|
+
const token = await report.check(`${prefix}token`, {
|
|
820
|
+
api,
|
|
821
|
+
canary: setup.canary
|
|
822
|
+
}, ({ api, canary }) => whoIsToken(origin, api, canary));
|
|
823
|
+
const needs = {
|
|
824
|
+
api,
|
|
825
|
+
canary: setup.canary,
|
|
826
|
+
token,
|
|
827
|
+
secret: setup.token
|
|
828
|
+
};
|
|
829
|
+
await report.check(`${prefix}token reveal`, needs, ({ api, canary, token }) => tokenReveal(api, canary, token));
|
|
830
|
+
await report.check(`${prefix}token scan`, needs, ({ canary, secret }) => tokenScan(origin, secret, canary));
|
|
831
|
+
await report.check(`${prefix}token scope`, needs, ({ api, canary }) => tokenScope(api, canary));
|
|
832
|
+
if (verification) await report.check(`${prefix}token verification`, { api }, ({ api }) => tokenVerification(api));
|
|
833
|
+
}
|
|
834
|
+
/** The token is a service's, and reads the canary's environment. */
|
|
835
|
+
async function whoIsToken(origin, api, canary) {
|
|
836
|
+
if ((await (await fetch(`${origin}/api/auth`)).json()).signin === null) throw new Skip("behind Cloudflare Access, coffre issues no service tokens: the token's checks need coffre's own sign-in");
|
|
837
|
+
const me = await api.me();
|
|
838
|
+
expect(me.principal.type === "service", `the token is ${me.principal.type}:${me.principal.id}'s, not a service's`);
|
|
839
|
+
const member = `token:${me.principal.id}`;
|
|
840
|
+
const place = `${canary.project}/${canary.environment}`;
|
|
841
|
+
expect(me.environments.find((env) => `${env.project}/${env.environment}` === place)?.permissions.includes("secret.read"), `${member} cannot read ${place}`, me.environments);
|
|
842
|
+
const { keys } = await api.secrets.list(place);
|
|
843
|
+
expect(keys.some((key) => key.key === canary.key), `${place} holds no ${canary.key}`, keys.map((key) => key.key));
|
|
844
|
+
return {
|
|
845
|
+
detail: `${member}, reading ${place}`,
|
|
846
|
+
value: { member }
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/** Reveal the canary once, and find its `secret.read` in the audit log, by the reveal's request. */
|
|
850
|
+
async function tokenReveal(api, canary, token) {
|
|
851
|
+
const path = `${canary.project}/${canary.environment}`;
|
|
852
|
+
const { operationId, values } = await api.secrets.reveal(`${path}/${canary.key}`);
|
|
853
|
+
expect(values[canary.key] === canary.value, `${path}/${canary.key} is not the value given as the canary`);
|
|
854
|
+
let entries;
|
|
855
|
+
try {
|
|
856
|
+
({entries} = await api.audit.list({
|
|
857
|
+
path,
|
|
858
|
+
actor: token.member,
|
|
859
|
+
limit: 50
|
|
860
|
+
}));
|
|
861
|
+
} catch (error) {
|
|
862
|
+
if (error instanceof CoffreError && error.status === 403) throw new Skip(`the token holds no audit.read on ${canary.project}, so the reveal's entry cannot be looked for`);
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
const read = entries.filter((entry) => entry.operationId === operationId);
|
|
866
|
+
expect(read.length === 1, `the reveal is logged ${read.length} times, not once`, entries.slice(0, 5));
|
|
867
|
+
const [entry] = read;
|
|
868
|
+
expect(entry.action === "secret.read" && entry.decision === "allow" && entry.key === canary.key && entry.requestId !== null, "the reveal's entry is not an allowed read of the canary, under a request", entry);
|
|
869
|
+
return `one secret.read of ${canary.key}, entry ${entry.seq}, under request ${entry.requestId}`;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The canary's value, in any answer to a GET route as the token or as no
|
|
873
|
+
* one, or in any page. The reveal is the only answer allowed to carry it.
|
|
874
|
+
*/
|
|
875
|
+
async function tokenScan(origin, token, canary) {
|
|
876
|
+
const me = await bearer(origin, token).me();
|
|
877
|
+
const member = `token:${me.principal.id}`;
|
|
878
|
+
const listed = me.environments.map(({ project, environment }) => ({
|
|
879
|
+
project,
|
|
880
|
+
environment
|
|
881
|
+
}));
|
|
882
|
+
const calls = getCalls({
|
|
883
|
+
places: [...listed, NOWHERE],
|
|
884
|
+
secrets: [{
|
|
885
|
+
project: canary.project,
|
|
886
|
+
environment: canary.environment,
|
|
887
|
+
key: canary.key
|
|
888
|
+
}, {
|
|
889
|
+
...NOWHERE,
|
|
890
|
+
key: "NONE"
|
|
891
|
+
}],
|
|
892
|
+
members: [
|
|
893
|
+
member,
|
|
894
|
+
NOBODY,
|
|
895
|
+
"user:nobody@conformance.example"
|
|
896
|
+
],
|
|
897
|
+
services: [member, NOBODY]
|
|
898
|
+
});
|
|
899
|
+
const forms = [
|
|
900
|
+
canary.value,
|
|
901
|
+
Buffer.from(canary.value).toString("base64"),
|
|
902
|
+
Buffer.from(canary.value).toString("hex")
|
|
903
|
+
];
|
|
904
|
+
const leaks = [];
|
|
905
|
+
const callers = [["the token", { authorization: `Bearer ${token}` }], ["no one", {}]];
|
|
906
|
+
let answers = 0;
|
|
907
|
+
const look = async (what, url, headers) => {
|
|
908
|
+
const text = await (await fetch(url, {
|
|
909
|
+
headers,
|
|
910
|
+
redirect: "manual"
|
|
911
|
+
})).text();
|
|
912
|
+
answers++;
|
|
913
|
+
if (forms.some((form) => text.includes(form))) leaks.push(what);
|
|
914
|
+
};
|
|
915
|
+
for (const { key, url } of getUrls(origin, calls)) for (const [name, headers] of callers) await look(`${key} as ${name}`, url, headers);
|
|
916
|
+
const pages = [
|
|
917
|
+
...OPEN_PAGES,
|
|
918
|
+
...CLOSED_PAGES,
|
|
919
|
+
...listed.flatMap(({ project, environment }) => [`/projects/${project}`, `/projects/${project}/${environment}`])
|
|
920
|
+
];
|
|
921
|
+
for (const path of new Set(pages)) for (const [name, headers] of callers) await look(`the page ${path} as ${name}`, `${origin}${path}`, headers);
|
|
922
|
+
expect(leaks.length === 0, "the canary's value was found outside its reveal", leaks);
|
|
923
|
+
return `${answers} answers from ${Object.keys(calls).length} GET routes and ${new Set(pages).size} pages, as the token and as no one: no value`;
|
|
924
|
+
}
|
|
925
|
+
/** The token reaches the canary's project and nothing else: not another environment, project or member. */
|
|
926
|
+
async function tokenScope(api, canary) {
|
|
927
|
+
const { projects } = await api.projects.list();
|
|
928
|
+
const seen = projects.map((project) => project.slug);
|
|
929
|
+
expect(seen.length === 1 && seen[0] === canary.project, `the token sees ${seen.join(", ") || "no project"}, not only ${canary.project}`);
|
|
930
|
+
const me = await api.me();
|
|
931
|
+
const own = `${canary.project}/${canary.environment}`;
|
|
932
|
+
const reads = me.environments.filter((env) => env.permissions.includes("secret.read")).map((env) => `${env.project}/${env.environment}`);
|
|
933
|
+
expect(reads.length === 1 && reads[0] === own, `the token reads ${reads.join(", ")}, not only ${own}`);
|
|
934
|
+
const more = [...new Set(me.environments.flatMap((env) => env.permissions))].filter((permission) => permission !== "secret.read" && permission !== "audit.read");
|
|
935
|
+
expect(more.length === 0, `the token holds more than reading: ${more.join(", ")}`);
|
|
936
|
+
const others = projects[0].environments.map((env) => `${canary.project}/${env.slug}`).filter((path) => path !== own);
|
|
937
|
+
const nowhere = `${NOWHERE.project}/${NOWHERE.environment}`;
|
|
938
|
+
let refusals = 0;
|
|
939
|
+
for (const path of [...others, nowhere]) {
|
|
940
|
+
await refused(`the token listed ${path}`, api.secrets.list(path));
|
|
941
|
+
await refused(`the token revealed ${path}`, api.secrets.reveal(path));
|
|
942
|
+
await refused(`the token read ${path}'s syncs`, api.syncs.list(path));
|
|
943
|
+
refusals += 3;
|
|
944
|
+
}
|
|
945
|
+
await refused(`the token read ${nowhere}'s audit`, api.audit.list({
|
|
946
|
+
path: nowhere,
|
|
947
|
+
limit: 1
|
|
948
|
+
}));
|
|
949
|
+
await refused("the token listed the members", api.members.list());
|
|
950
|
+
await refused(`the token read ${NOBODY}`, api.members.get(NOBODY));
|
|
951
|
+
refusals += 3;
|
|
952
|
+
try {
|
|
953
|
+
const { entries } = await api.audit.list({ limit: 500 });
|
|
954
|
+
const outside = entries.filter((entry) => entry.project !== null && entry.project !== canary.project);
|
|
955
|
+
expect(outside.length === 0, `the token reads audit entries about ${[...new Set(outside.map((entry) => entry.project))].join(", ")}`);
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (!(error instanceof CoffreError && error.status === 403)) throw error;
|
|
958
|
+
}
|
|
959
|
+
const elsewhere = others.length === 0 ? "no other environment" : `${others.length} other environment${others.length === 1 ? "" : "s"}`;
|
|
960
|
+
return `only ${own}; ${refusals} reads elsewhere refused: ${elsewhere} of ${canary.project}, a made-up place, the members`;
|
|
961
|
+
}
|
|
962
|
+
async function tokenVerification(api) {
|
|
963
|
+
let verified;
|
|
964
|
+
try {
|
|
965
|
+
verified = await api.audit.verify();
|
|
966
|
+
} catch (error) {
|
|
967
|
+
if (error instanceof CoffreError && error.status === 403) throw new Skip("verification is for owners and root admins, which this token is not: not checked");
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
expect(verified.ok, "the audit log does not verify", verified);
|
|
971
|
+
return `${verified.entries} entries verify, through entry ${verified.through}`;
|
|
972
|
+
}
|
|
973
|
+
//#endregion
|
|
974
|
+
//#region src/verify/instance.ts
|
|
975
|
+
/** The run's own place on an instance, made the first time, and found after. Nothing else is touched. */
|
|
976
|
+
const PROBE = {
|
|
977
|
+
project: "conformance",
|
|
978
|
+
environment: "live",
|
|
979
|
+
service: "token:conformance-probe",
|
|
980
|
+
key: "SIGN_IN_CANARY"
|
|
981
|
+
};
|
|
982
|
+
const INSTANCE_USAGE = `usage:
|
|
983
|
+
coffre verify instance [<url>]
|
|
984
|
+
COFFRE_TOKEN=<service token> coffre verify instance [<url>] --canary <project>/<environment>/<KEY>[=<value>]
|
|
985
|
+
|
|
986
|
+
Checks an instance from outside, the current one unless <url> names another:
|
|
987
|
+
|
|
988
|
+
as no one health, headers, every route refusing no one, changes from
|
|
989
|
+
another site refused, /api/auth, nothing like a value shown
|
|
990
|
+
as an owner with your \`coffre login\` session, as an owner or a root
|
|
991
|
+
admin: finds or makes conformance/live and
|
|
992
|
+
token:conformance-probe, issues it a credential, writes a
|
|
993
|
+
fresh canary, runs the token's checks below, then verifies
|
|
994
|
+
the whole audit log as you. The credential is revoked
|
|
995
|
+
however the run ends, and your session stays signed in
|
|
996
|
+
with a token a service token in COFFRE_TOKEN, for CI, that reads the
|
|
997
|
+
canary's environment and holds auditor on its project: its
|
|
998
|
+
value nowhere but its reveal, the reveal audited, nothing
|
|
999
|
+
else in reach
|
|
1000
|
+
--canary the canary the token reads; its value after =, or in
|
|
1001
|
+
COFFRE_CONFORMANCE_CANARY, or on stdin, so that it stays
|
|
1002
|
+
out of the shell's history
|
|
1003
|
+
|
|
1004
|
+
Each run adds a few entries to the instance's audit log, which is
|
|
1005
|
+
append-only, so they stay: the canary's read, and the reads it was refused.
|
|
1006
|
+
A run as an owner leaves its project, environment and service for the next,
|
|
1007
|
+
and writes its canary and its grants. Exits 1 when a check fails.`;
|
|
1008
|
+
async function verifyInstance(args, store, env, out = process.stdout) {
|
|
1009
|
+
const { values, positionals } = parseArgs({
|
|
1010
|
+
args,
|
|
1011
|
+
allowPositionals: true,
|
|
1012
|
+
options: {
|
|
1013
|
+
canary: { type: "string" },
|
|
1014
|
+
help: {
|
|
1015
|
+
type: "boolean",
|
|
1016
|
+
short: "h"
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
if (values.help) stop(0, INSTANCE_USAGE);
|
|
1021
|
+
if (positionals.length > 1) stop(2, INSTANCE_USAGE);
|
|
1022
|
+
const requested = positionals[0] ?? (env.COFFRE_API_URL?.trim() || store.current);
|
|
1023
|
+
if (!requested) stop(1, "coffre: not signed in anywhere yet: run `coffre login <url>` first");
|
|
1024
|
+
let origin;
|
|
1025
|
+
try {
|
|
1026
|
+
origin = instanceOrigin(requested);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
stop(2, `coffre: ${error instanceof Error ? error.message : String(error)}`);
|
|
1029
|
+
}
|
|
1030
|
+
const s = style(out);
|
|
1031
|
+
const report = new Checks(out);
|
|
1032
|
+
const token = env.COFFRE_TOKEN?.trim();
|
|
1033
|
+
if (token !== void 0 && token !== "") {
|
|
1034
|
+
if (values.canary === void 0) stop(2, "coffre: with a token, name its canary: --canary <project>/<environment>/<KEY>[=<value>]");
|
|
1035
|
+
let canary;
|
|
1036
|
+
try {
|
|
1037
|
+
canary = parseCanary(values.canary, values.canary.includes("=") ? void 0 : await canaryValue(env));
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
stop(2, `coffre: ${error instanceof Error ? error.message : String(error)}`);
|
|
1040
|
+
}
|
|
1041
|
+
out.write(`${s.bold(`Checking ${origin}`)}, as no one and with the token in COFFRE_TOKEN\n`);
|
|
1042
|
+
out.write(s.dim(" The token's reads add a few entries to the instance's audit log, for good.\n\n"));
|
|
1043
|
+
await anonymousChecks(report, origin);
|
|
1044
|
+
await tokenChecks(report, origin, {
|
|
1045
|
+
token,
|
|
1046
|
+
canary
|
|
1047
|
+
});
|
|
1048
|
+
return finish(out, report);
|
|
1049
|
+
}
|
|
1050
|
+
if (values.canary !== void 0) stop(2, "coffre: --canary goes with a service token in COFFRE_TOKEN; as an owner, the run writes a canary of its own");
|
|
1051
|
+
if (store.instances[origin] === void 0) stop(1, `coffre: not signed in to ${origin}: run \`coffre login ${origin}\` first`);
|
|
1052
|
+
let api;
|
|
1053
|
+
try {
|
|
1054
|
+
const to = resolveTarget({
|
|
1055
|
+
...env,
|
|
1056
|
+
COFFRE_API_URL: origin,
|
|
1057
|
+
COFFRE_TOKEN: void 0
|
|
1058
|
+
}, store);
|
|
1059
|
+
if (to.mode === "cloudflare") stop(1, `coffre: ${origin} is behind Cloudflare Access, which turns everyone away before coffre answers, so it cannot be checked from outside. \`coffre verify log\` verifies its audit log, as you.`);
|
|
1060
|
+
api = createClient({
|
|
1061
|
+
url: origin,
|
|
1062
|
+
headers: () => credentialHeaders(to.mode, to.credential)
|
|
1063
|
+
});
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
stop(1, `coffre: ${error instanceof Error ? error.message : String(error)}`);
|
|
1066
|
+
}
|
|
1067
|
+
out.write(`${s.bold(`Checking ${origin}`)}, as no one, then as you\n`);
|
|
1068
|
+
out.write(s.dim(` It keeps ${PROBE.project}/${PROBE.environment} and ${PROBE.service} for the next run, and its reads stay in the audit log, for good.\n\n`));
|
|
1069
|
+
await anonymousChecks(report, origin);
|
|
1070
|
+
await ownerChecks(report, origin, api);
|
|
1071
|
+
finish(out, report);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* As the signed-in owner: who they are, set up, the token's checks,
|
|
1075
|
+
* verification; and, however it ends, a Ctrl-C included, the credential
|
|
1076
|
+
* revoked. The session stays as it was.
|
|
1077
|
+
*/
|
|
1078
|
+
async function ownerChecks(report, origin, api) {
|
|
1079
|
+
if (await report.check("owner", {}, () => signedIn(api, origin)) === void 0) return;
|
|
1080
|
+
const issued = [];
|
|
1081
|
+
let ended = null;
|
|
1082
|
+
const end = () => ended ??= cleanUp(api, issued);
|
|
1083
|
+
const interrupted = (code) => () => void end().finally(() => process.exit(code));
|
|
1084
|
+
const onInt = interrupted(130);
|
|
1085
|
+
const onTerm = interrupted(143);
|
|
1086
|
+
process.once("SIGINT", onInt);
|
|
1087
|
+
process.once("SIGTERM", onTerm);
|
|
1088
|
+
try {
|
|
1089
|
+
await tokenChecks(report, origin, await report.check("setup", {}, () => setUp(api, issued)) ?? {}, { verification: false });
|
|
1090
|
+
await report.check("owner verification", {}, () => verification(api));
|
|
1091
|
+
} finally {
|
|
1092
|
+
process.off("SIGINT", onInt);
|
|
1093
|
+
process.off("SIGTERM", onTerm);
|
|
1094
|
+
await report.check("clean-up", {}, end);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/** The session's owner, or a root admin: anyone else is stopped before anything is made. */
|
|
1098
|
+
async function signedIn(api, origin) {
|
|
1099
|
+
let me;
|
|
1100
|
+
try {
|
|
1101
|
+
me = await api.me();
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
if (error instanceof CoffreError && error.status === 401) throw new Failure(`your session on ${origin} is missing, expired or revoked: run \`coffre login ${origin}\`, then again`);
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
const who = me.principal.id;
|
|
1107
|
+
if (me.instanceRole === "user") throw new Failure(`${who} is neither an owner nor a root admin of ${origin}: nothing was made. Sign in as one, or use a service token in COFFRE_TOKEN with --canary`);
|
|
1108
|
+
return {
|
|
1109
|
+
detail: `${who}, ${me.instanceRole === "owner" ? "an owner" : "a root admin"}, with this CLI's session`,
|
|
1110
|
+
value: who
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
/** Make what is missing of the run's place, and the service's two grants; then a fresh canary, and a fresh credential. */
|
|
1114
|
+
async function setUp(api, issued) {
|
|
1115
|
+
const place = `${PROBE.project}/${PROBE.environment}`;
|
|
1116
|
+
const made = [];
|
|
1117
|
+
const { projects } = await api.projects.list();
|
|
1118
|
+
const project = projects.find(({ slug }) => slug === PROBE.project);
|
|
1119
|
+
expect(project?.archivedAt == null, `${PROBE.project} is archived: unarchive it, or the run has nowhere to go`);
|
|
1120
|
+
if (project === void 0) {
|
|
1121
|
+
await api.projects.create(PROBE.project, { name: "Conformance" });
|
|
1122
|
+
made.push(`the project ${PROBE.project}`);
|
|
1123
|
+
}
|
|
1124
|
+
const environment = project?.environments.find(({ slug }) => slug === PROBE.environment);
|
|
1125
|
+
expect(environment?.details?.archivedAt == null, `${place} is archived: unarchive it, or the run has nowhere to go`);
|
|
1126
|
+
if (environment === void 0) {
|
|
1127
|
+
await api.environments.create(place, { name: "Live" });
|
|
1128
|
+
made.push(`the environment ${place}`);
|
|
1129
|
+
}
|
|
1130
|
+
if ((await api.members.add(PROBE.service)).created) made.push(`the service ${PROBE.service}`);
|
|
1131
|
+
const { changes } = await api.access.set(PROBE.service, {
|
|
1132
|
+
[place]: "viewer",
|
|
1133
|
+
[PROBE.project]: "auditor"
|
|
1134
|
+
});
|
|
1135
|
+
const granted = Object.entries(changes).filter(([, change]) => change !== "unchanged");
|
|
1136
|
+
if (granted.length > 0) made.push(`its grants on ${granted.map(([where]) => where).join(" and ")}`);
|
|
1137
|
+
const value = `coffre-canary-${randomBytes(12).toString("hex")}`;
|
|
1138
|
+
await api.secrets.set(place, { [PROBE.key]: value });
|
|
1139
|
+
const credential = await api.tokens.issue(PROBE.service, {
|
|
1140
|
+
label: "coffre verify instance, one run",
|
|
1141
|
+
expiresInDays: 1
|
|
1142
|
+
});
|
|
1143
|
+
issued.push({
|
|
1144
|
+
member: PROBE.service,
|
|
1145
|
+
id: credential.id
|
|
1146
|
+
});
|
|
1147
|
+
return {
|
|
1148
|
+
detail: `${made.length === 0 ? "all found" : `made ${made.join(", ")}`}; a fresh ${place}/${PROBE.key} and a fresh credential for ${PROBE.service}`,
|
|
1149
|
+
value: {
|
|
1150
|
+
token: credential.token,
|
|
1151
|
+
canary: {
|
|
1152
|
+
project: PROBE.project,
|
|
1153
|
+
environment: PROBE.environment,
|
|
1154
|
+
key: PROBE.key,
|
|
1155
|
+
value
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
/** What the whole audit chain says, read by the owner, which a token cannot be. */
|
|
1161
|
+
async function verification(api) {
|
|
1162
|
+
const verified = await api.audit.verify();
|
|
1163
|
+
expect(verified.ok, "the audit log does not verify", verified);
|
|
1164
|
+
return `the whole chain verifies, as you: ${verified.entries} entries, through entry ${verified.through}`;
|
|
1165
|
+
}
|
|
1166
|
+
/** Revoke what the run issued, and say what stays: the session among it. */
|
|
1167
|
+
async function cleanUp(api, issued) {
|
|
1168
|
+
for (const { member, id } of issued) await api.tokens.revoke(member, id);
|
|
1169
|
+
const { tokens } = await api.tokens.list(PROBE.service).catch(() => ({ tokens: [] }));
|
|
1170
|
+
const others = tokens.length === 0 ? "no working credential" : `${tokens.length} credential${tokens.length === 1 ? "" : "s"} this run did not issue, left as they are`;
|
|
1171
|
+
return `${issued.length === 0 ? "nothing issued" : "the credential revoked"}; your session stays. Staying: ${PROBE.project}/${PROBE.environment} and its ${PROBE.key}, ${PROBE.service} with ${others}, and this run's entries in the audit log`;
|
|
1172
|
+
}
|
|
1173
|
+
/** The canary's value from the environment, or the first line of stdin when it is piped. */
|
|
1174
|
+
async function canaryValue(env) {
|
|
1175
|
+
const given = env.COFFRE_CONFORMANCE_CANARY;
|
|
1176
|
+
if (given !== void 0 && given !== "") return given;
|
|
1177
|
+
if (process.stdin.isTTY) return void 0;
|
|
1178
|
+
let text = "";
|
|
1179
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
1180
|
+
return text.split(/\r?\n/)[0];
|
|
1181
|
+
}
|
|
1182
|
+
/** The verdict, under the checks, and the exit code: 0 when every one passed, or was skipped for a reason given. */
|
|
1183
|
+
function finish(out, report) {
|
|
1184
|
+
const s = style(out);
|
|
1185
|
+
const { failed } = report;
|
|
1186
|
+
out.write(failed.length === 0 ? `\n${s.green("✓")} ${s.bold("Conformant")}\n` : `\n${s.red("✗")} ${s.bold("Not conformant:")} ${failed.join(", ")}\n`);
|
|
1187
|
+
process.exitCode = failed.length === 0 ? 0 : 1;
|
|
1188
|
+
}
|
|
1189
|
+
Buffer.from("coffre.audit.mac.v2", "utf8");
|
|
1190
|
+
Buffer.from("coffre.audit.chain.v2", "utf8");
|
|
1191
|
+
Buffer.alloc(32, 0);
|
|
1192
|
+
/**
|
|
1193
|
+
* An author's log key, derived from one of its secrets: `auditChainKey` for
|
|
1194
|
+
* the app. The id is a fingerprint of the key, so a rotation shows in every
|
|
1195
|
+
* entry, and an old key can be kept for verification by its id.
|
|
1196
|
+
*/
|
|
1197
|
+
function deriveLogKey(author, secret) {
|
|
1198
|
+
if (secret.length < 32) throw new Error("a log key needs at least 32 bytes of secret");
|
|
1199
|
+
const key = Buffer.from(hkdfSync("sha256", secret, /* @__PURE__ */ new Uint8Array(0), `coffre.audit.${author}.v2`, 32));
|
|
1200
|
+
return {
|
|
1201
|
+
author,
|
|
1202
|
+
keyId: `${author}:${createHash("sha256").update(key).digest("hex").slice(0, 16)}`,
|
|
1203
|
+
key
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
//#endregion
|
|
1207
|
+
//#region ../core/dist/types-Mi7-Tzj9.js
|
|
1208
|
+
/**
|
|
1209
|
+
* Version prefix for the AAD encoding. If the encoding ever changes, this
|
|
1210
|
+
* changes with it, and old ciphertexts keep decrypting under the old rule.
|
|
1211
|
+
*/
|
|
1212
|
+
const AAD_VERSION = "coffre.aad.v1";
|
|
1213
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
1214
|
+
function assertUuid(value, field) {
|
|
1215
|
+
if (typeof value !== "string" || !UUID_RE.test(value)) throw new Error(`context.${field} must be a lowercase UUID, got: ${String(value)}`);
|
|
1216
|
+
}
|
|
1217
|
+
/** Throw unless every field is a lowercase UUID. */
|
|
1218
|
+
function checkContext(ctx) {
|
|
1219
|
+
assertUuid(ctx.projectId, "projectId");
|
|
1220
|
+
assertUuid(ctx.environmentId, "environmentId");
|
|
1221
|
+
assertUuid(ctx.secretId, "secretId");
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Encode a context into AAD bytes.
|
|
1225
|
+
*
|
|
1226
|
+
* Every field is validated as a UUID first, which is what makes joining with
|
|
1227
|
+
* `|` unambiguous: a UUID cannot contain the separator, so no two distinct
|
|
1228
|
+
* contexts can encode to the same bytes.
|
|
1229
|
+
*/
|
|
1230
|
+
function encodeAad(ctx) {
|
|
1231
|
+
checkContext(ctx);
|
|
1232
|
+
return Buffer.from(`${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}`, "utf8");
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* The key service could not answer: it is down, throttling past the
|
|
1236
|
+
* retries, or refusing coffre's own credentials. Nothing is known about the
|
|
1237
|
+
* wrapped key, so the vault fails the call instead of refusing it.
|
|
1238
|
+
*/
|
|
1239
|
+
var KekUnavailableError = class extends Error {
|
|
1240
|
+
name = "KekUnavailableError";
|
|
1241
|
+
uncertain;
|
|
1242
|
+
constructor(message, uncertain = false) {
|
|
1243
|
+
super(message);
|
|
1244
|
+
this.uncertain = uncertain;
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
/** No more work may start. A request already sent may have been accepted by KMS. */
|
|
1248
|
+
var KekCancelledError = class extends KekUnavailableError {
|
|
1249
|
+
name = "KekCancelledError";
|
|
1250
|
+
constructor(uncertain = false) {
|
|
1251
|
+
super("key operation was cancelled", uncertain);
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
/** The ciphertext does not open under the claimed key and context. */
|
|
1255
|
+
var KekBadClaimError = class extends Error {
|
|
1256
|
+
name = "KekBadClaimError";
|
|
1257
|
+
};
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region ../core/dist/kek.js
|
|
1260
|
+
function checkOperation(operation, uncertain = false) {
|
|
1261
|
+
if (operation !== void 0 && (operation.signal.aborted || Date.now() >= operation.deadline)) throw new KekCancelledError(uncertain);
|
|
1262
|
+
}
|
|
1263
|
+
const IV_BYTES = 12;
|
|
1264
|
+
const KEK_BYTES = 32;
|
|
194
1265
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
1266
|
+
* A KEK held in local process memory, loaded from configuration.
|
|
1267
|
+
*
|
|
1268
|
+
* This is the local-development implementation of `KekProvider`. It is a
|
|
1269
|
+
* different implementation of the same interface, not a branch that skips
|
|
1270
|
+
* wrapping: the envelope format, the context binding and the failure modes are
|
|
1271
|
+
* identical to what a remote KMS-backed provider will produce.
|
|
198
1272
|
*/
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
1273
|
+
var LocalKekProvider = class LocalKekProvider {
|
|
1274
|
+
provider = "local";
|
|
1275
|
+
keyId;
|
|
1276
|
+
keyVersion;
|
|
1277
|
+
#kek;
|
|
1278
|
+
constructor(kek, keyId, keyVersion = "1") {
|
|
1279
|
+
if (kek.length !== KEK_BYTES) throw new Error(`a local vault key must be ${KEK_BYTES} bytes, got ${kek.length}`);
|
|
1280
|
+
this.#kek = kek;
|
|
1281
|
+
this.keyId = keyId;
|
|
1282
|
+
this.keyVersion = keyVersion;
|
|
1283
|
+
}
|
|
1284
|
+
/** Build a provider from a base64-encoded 32-byte key, e.g. from an env var. */
|
|
1285
|
+
static fromBase64(encoded, keyId, keyVersion = "1") {
|
|
1286
|
+
return new LocalKekProvider(Buffer.from(encoded, "base64"), keyId, keyVersion);
|
|
1287
|
+
}
|
|
1288
|
+
/** Generate a fresh random KEK. Used by tests and by the dev seed script. */
|
|
1289
|
+
static generate(keyId, keyVersion = "1") {
|
|
1290
|
+
return new LocalKekProvider(randomBytes(KEK_BYTES), keyId, keyVersion);
|
|
214
1291
|
}
|
|
215
|
-
for (const field of provider.fields) if (field.type === "options" && !Object.hasOwn(config, field.name)) config[field.name] = field.multiple ? field.initial : field.initial[0];
|
|
216
|
-
return config;
|
|
217
|
-
}
|
|
218
|
-
/** The API's error shape, `{ error, message }`, with the HTTP status. */
|
|
219
|
-
const COFFRE_ERROR = Symbol.for("@coffre/client:CoffreError");
|
|
220
|
-
var CoffreError = class extends Error {
|
|
221
1292
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
1293
|
+
* AAD for the wrap operation.
|
|
1294
|
+
*
|
|
1295
|
+
* Binds the wrapped DEK both to the secret it belongs to and to the KEK
|
|
1296
|
+
* identity that produced it, so a wrapped DEK cannot be replayed against a
|
|
1297
|
+
* row claiming a different KEK.
|
|
225
1298
|
*/
|
|
226
|
-
|
|
227
|
-
return
|
|
1299
|
+
#wrapAad(ctx) {
|
|
1300
|
+
return Buffer.concat([encodeAad(ctx), Buffer.from(`|${this.provider}|${this.keyId}|${this.keyVersion}`, "utf8")]);
|
|
228
1301
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
1302
|
+
async wrap(dek, ctx, operation) {
|
|
1303
|
+
checkOperation(operation);
|
|
1304
|
+
if (dek.length !== 32) throw new Error(`DEK must be 32 bytes, got ${dek.length}`);
|
|
1305
|
+
const iv = randomBytes(IV_BYTES);
|
|
1306
|
+
const cipher = createCipheriv("aes-256-gcm", this.#kek, iv);
|
|
1307
|
+
cipher.setAAD(this.#wrapAad(ctx));
|
|
1308
|
+
const body = Buffer.concat([cipher.update(dek), cipher.final()]);
|
|
1309
|
+
return {
|
|
1310
|
+
kekProvider: this.provider,
|
|
1311
|
+
kekId: this.keyId,
|
|
1312
|
+
kekVersion: this.keyVersion,
|
|
1313
|
+
bytes: Buffer.concat([
|
|
1314
|
+
iv,
|
|
1315
|
+
cipher.getAuthTag(),
|
|
1316
|
+
body
|
|
1317
|
+
])
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
async unwrap(wrapped, ctx, operation) {
|
|
1321
|
+
checkOperation(operation);
|
|
1322
|
+
if (wrapped.kekProvider !== this.provider || wrapped.kekId !== this.keyId) throw new KekBadClaimError(`wrapped DEK is for ${wrapped.kekProvider}:${wrapped.kekId}, not ${this.provider}:${this.keyId}`);
|
|
1323
|
+
if (wrapped.bytes.length !== 60) throw new KekBadClaimError("wrapped DEK has the wrong length");
|
|
1324
|
+
const iv = wrapped.bytes.subarray(0, IV_BYTES);
|
|
1325
|
+
const tag = wrapped.bytes.subarray(IV_BYTES, 28);
|
|
1326
|
+
const body = wrapped.bytes.subarray(28);
|
|
1327
|
+
const decipher = createDecipheriv("aes-256-gcm", this.#kek, iv);
|
|
1328
|
+
decipher.setAAD(this.#wrapAad(ctx));
|
|
1329
|
+
decipher.setAuthTag(tag);
|
|
1330
|
+
let dek;
|
|
1331
|
+
const partial = decipher.update(body);
|
|
1332
|
+
try {
|
|
1333
|
+
dek = Buffer.concat([partial, decipher.final()]);
|
|
1334
|
+
} catch {
|
|
1335
|
+
throw new KekBadClaimError("wrapped DEK does not open under this context");
|
|
1336
|
+
} finally {
|
|
1337
|
+
partial.fill(0);
|
|
1338
|
+
}
|
|
1339
|
+
if (dek.length !== 32) {
|
|
1340
|
+
dek.fill(0);
|
|
1341
|
+
throw new KekBadClaimError("unwrapped DEK has the wrong length");
|
|
1342
|
+
}
|
|
1343
|
+
return dek;
|
|
240
1344
|
}
|
|
241
1345
|
};
|
|
242
|
-
/**
|
|
243
|
-
function
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
1346
|
+
/** Constant-time buffer comparison, for tests and callers that need it. */
|
|
1347
|
+
function equalBytes(a, b) {
|
|
1348
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1349
|
+
}
|
|
1350
|
+
/** The known value a check wraps: the size of a data key. */
|
|
1351
|
+
const KEY_CHECK_VALUE = createHash("sha256").update("coffre.kek.check.v1").digest();
|
|
1352
|
+
const NIL = "00000000-0000-0000-0000-000000000000";
|
|
1353
|
+
/** The context a check is wrapped in, which no secret has: the nil UUID throughout. */
|
|
1354
|
+
const KEY_CHECK_CONTEXT = {
|
|
1355
|
+
projectId: NIL,
|
|
1356
|
+
environmentId: NIL,
|
|
1357
|
+
secretId: NIL
|
|
1358
|
+
};
|
|
1359
|
+
/**
|
|
1360
|
+
* Whether `key`, a local vault key, is the one that wrapped `check`: it
|
|
1361
|
+
* opens it, under the vault ID the check names, to the known value. The ID
|
|
1362
|
+
* is bound into the wrap, so a match also says the ID is the escrowed one's.
|
|
1363
|
+
*/
|
|
1364
|
+
async function opensKeyCheck(key, check) {
|
|
1365
|
+
if (check.kekProvider !== "local" || key.length !== 32) return false;
|
|
1366
|
+
try {
|
|
1367
|
+
const value = await new LocalKekProvider(key, check.kekId, check.kekVersion).unwrap(check, KEY_CHECK_CONTEXT);
|
|
1368
|
+
const right = equalBytes(value, KEY_CHECK_VALUE);
|
|
1369
|
+
value.fill(0);
|
|
1370
|
+
return right;
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
if (error instanceof KekBadClaimError) return false;
|
|
1373
|
+
throw error;
|
|
1374
|
+
}
|
|
250
1375
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
1376
|
+
/** The id the log records for entries the app signs with `key`, its APP_KEY. */
|
|
1377
|
+
function appLogKeyId(key) {
|
|
1378
|
+
return deriveLogKey("app", key).keyId;
|
|
1379
|
+
}
|
|
1380
|
+
//#endregion
|
|
1381
|
+
//#region src/verify/keys.ts
|
|
1382
|
+
const KEYS_USAGE = `usage:
|
|
1383
|
+
coffre verify keys [--vault-id <id>]
|
|
1384
|
+
|
|
1385
|
+
Asks for the vault key, then the app key, without showing them, and checks
|
|
1386
|
+
each against the current instance, on this machine: neither is sent. Enter
|
|
1387
|
+
alone skips one. As an owner or a root admin.
|
|
1388
|
+
|
|
1389
|
+
--vault-id the vault ID kept with the vault key, to check as well; or in
|
|
1390
|
+
COFFRE_VAULT_KEY_ID. It is shown either way
|
|
1391
|
+
|
|
1392
|
+
In a script: the keys in COFFRE_VAULT_KEY and COFFRE_APP_KEY, or on stdin,
|
|
1393
|
+
the vault key on the first line and the app key on the second, never as
|
|
1394
|
+
arguments. Exits 1 when a key is not the instance's.`;
|
|
1395
|
+
/**
|
|
1396
|
+
* A key's text, as an operator pastes it: 32 bytes in base64, and nothing
|
|
1397
|
+
* more. Node's decoder, which the deployment uses, stops where it can, so a
|
|
1398
|
+
* key pasted twice would decode to the key: the text must be the key's own.
|
|
1399
|
+
*/
|
|
1400
|
+
function decoded(text) {
|
|
1401
|
+
const key = Buffer.from(text, "base64");
|
|
1402
|
+
const unpadded = text.replace(/=+$/, "");
|
|
1403
|
+
return key.length === 32 && (unpadded === key.toString("base64").replace(/=+$/, "") || unpadded === key.toString("base64url")) ? key : null;
|
|
1404
|
+
}
|
|
1405
|
+
function malformed(what, text) {
|
|
1406
|
+
return new Failure(`not ${what}: one is 32 bytes in base64, 44 characters, and these ${text.length} characters are not`);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* The vault key: the one the vault wraps under now if it opens that key's
|
|
1410
|
+
* check; a key the vault replaced if it opens an older one's, which is said;
|
|
1411
|
+
* otherwise none of this instance's. The vault ID is bound into every
|
|
1412
|
+
* check, so the one opened names the key's.
|
|
1413
|
+
*/
|
|
1414
|
+
async function vaultKeyVerdict(text, material, vaultId) {
|
|
1415
|
+
if (text === "") throw new Skip("not given: not checked");
|
|
1416
|
+
const key = decoded(text);
|
|
1417
|
+
if (key === null) throw malformed("a vault key", text);
|
|
1418
|
+
try {
|
|
1419
|
+
const { current, checks } = material;
|
|
1420
|
+
let opened;
|
|
1421
|
+
for (const check of checks) if (await opensKeyCheck(key, {
|
|
1422
|
+
kekProvider: check.provider,
|
|
1423
|
+
kekId: check.vaultId,
|
|
1424
|
+
kekVersion: check.version,
|
|
1425
|
+
bytes: Buffer.from(check.wrapped, "base64")
|
|
1426
|
+
})) {
|
|
1427
|
+
opened = check;
|
|
1428
|
+
break;
|
|
263
1429
|
}
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}));
|
|
269
|
-
const payload = await response.json().catch(() => null);
|
|
270
|
-
if (!response.ok) {
|
|
271
|
-
const error = payload;
|
|
272
|
-
throw new CoffreError(response.status, typeof error?.error === "string" ? error.error : "http_error", typeof error?.message === "string" ? error.message : `request failed with status ${response.status}`, typeof error?.reason === "string" ? error.reason : void 0);
|
|
1430
|
+
const now = current.provider === "local" ? `vault ID ${current.vaultId}` : `${current.vaultId}, in ${current.provider}`;
|
|
1431
|
+
if (opened === void 0) {
|
|
1432
|
+
if (checks.length === 0) throw new Failure(`the vault holds no check to hold a key to yet. It wraps under ${now}`);
|
|
1433
|
+
throw new Failure(`not this instance's vault key: ${checks.length === 1 ? "it does not open the vault key's check" : `it opens none of the checks of its ${checks.length} vault keys, current or replaced`}. The vault wraps under ${now}`);
|
|
273
1434
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return [method, pattern.replace(/:(\w+)/g, (_, name) => encodeURIComponent(params[name]))];
|
|
1435
|
+
if (opened.vaultId !== current.vaultId || opened.provider !== current.provider) throw new Failure(`this is a previous vault key (vault ID ${opened.vaultId}), not the current one: the vault wraps under ${now}`);
|
|
1436
|
+
if (vaultId !== void 0 && vaultId !== opened.vaultId) throw new Failure(`the key is right, but its vault ID is ${opened.vaultId}, not ${vaultId}: keep the right one with it`);
|
|
1437
|
+
return `the current one, vault ID ${opened.vaultId}${vaultId === void 0 ? "" : ", as given"}`;
|
|
1438
|
+
} finally {
|
|
1439
|
+
key.fill(0);
|
|
280
1440
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
1441
|
+
}
|
|
1442
|
+
/** The app key: right if the log's entries the app signs now would carry the fingerprint it makes. */
|
|
1443
|
+
function appKeyVerdict(text, material) {
|
|
1444
|
+
if (text === "") throw new Skip("not given: not checked");
|
|
1445
|
+
const key = decoded(text);
|
|
1446
|
+
if (key === null) throw malformed("an app key", text);
|
|
1447
|
+
try {
|
|
1448
|
+
if (appLogKeyId(key) !== material.keyId) throw new Failure("not this instance's app key: the app signs with another");
|
|
1449
|
+
return "the one the app signs with now";
|
|
1450
|
+
} finally {
|
|
1451
|
+
key.fill(0);
|
|
285
1452
|
}
|
|
286
|
-
return {
|
|
287
|
-
call,
|
|
288
|
-
/** How this instance signs people in. Answers anyone, signed in or not. */
|
|
289
|
-
auth: () => send("GET", "/auth", void 0),
|
|
290
|
-
/** Who I am, and every place I can reach. */
|
|
291
|
-
me: () => call("GET /me", {}),
|
|
292
|
-
projects: {
|
|
293
|
-
/** With their environments. */
|
|
294
|
-
list: () => call("GET /projects", {}),
|
|
295
|
-
create: (project, input) => call("PUT /projects/:project", { project }, input),
|
|
296
|
-
update: (project, patch) => call("PATCH /projects/:project", { project }, patch)
|
|
297
|
-
},
|
|
298
|
-
environments: {
|
|
299
|
-
create: (path, input) => call("PUT /projects/:project/:environment", place(path), input),
|
|
300
|
-
update: (path, patch) => call("PATCH /projects/:project/:environment", place(path), patch)
|
|
301
|
-
},
|
|
302
|
-
secrets: {
|
|
303
|
-
/** Keys, versions and who changed what; never values. */
|
|
304
|
-
list: (path) => call("GET /secrets/:project/:environment", place(path)),
|
|
305
|
-
/** Decrypts one secret, or every secret in an environment. Logged in your name. */
|
|
306
|
-
reveal: (path) => call("POST /reveals", {}, { path }),
|
|
307
|
-
/** One transaction, a version and an audit entry per key; `null` archives. */
|
|
308
|
-
set: (path, values) => call("PATCH /secrets/:project/:environment", place(path), values),
|
|
309
|
-
/**
|
|
310
|
-
* What `set` would do to each key, `added`, `changed`, `unchanged` or
|
|
311
|
-
* `archived`, and nothing else: no value comes back and nothing is
|
|
312
|
-
* written. Comparing opens the current values, logged as reads.
|
|
313
|
-
*/
|
|
314
|
-
dryRun: (path, values) => {
|
|
315
|
-
const [method, route] = address("PATCH /secrets/:project/:environment", place(path));
|
|
316
|
-
return send(method, `${route}?dryRun=1`, values);
|
|
317
|
-
},
|
|
318
|
-
history: (path) => call("GET /secrets/:project/:environment/:key/versions", place(path)),
|
|
319
|
-
/** A new version holding the old one's value. */
|
|
320
|
-
restore: (path, version) => call("POST /secrets/:project/:environment/:key/restore", place(path), { version }),
|
|
321
|
-
rename: (path, key) => call("PATCH /secrets/:project/:environment/:key", place(path), { key }),
|
|
322
|
-
update: (path, patch) => call("PATCH /secrets/:project/:environment/:key", place(path), patch)
|
|
323
|
-
},
|
|
324
|
-
members: {
|
|
325
|
-
/** People and tokens, their role and their access; at a place, those who reach it. */
|
|
326
|
-
list: (path) => call("GET /members", {}, { path }),
|
|
327
|
-
/** What they hold, and what to rotate if they leave. */
|
|
328
|
-
get: (member) => call("GET /members/:member", { member }),
|
|
329
|
-
add: (member, input = {}) => call("PUT /members/:member", { member }, input),
|
|
330
|
-
/** Offboards; returns what to rotate. */
|
|
331
|
-
remove: (member) => call("DELETE /members/:member", { member })
|
|
332
|
-
},
|
|
333
|
-
tokens: {
|
|
334
|
-
list: (member) => call("GET /members/:member/tokens", { member }),
|
|
335
|
-
/** The value is in the answer, and only there. */
|
|
336
|
-
issue: (member, input) => call("POST /members/:member/tokens", { member }, input),
|
|
337
|
-
revoke: (member, id) => call("DELETE /members/:member/tokens/:id", {
|
|
338
|
-
member,
|
|
339
|
-
id
|
|
340
|
-
})
|
|
341
|
-
},
|
|
342
|
-
access: {
|
|
343
|
-
/** What they should hold at each place; the server applies the difference. `null` revokes. */
|
|
344
|
-
set: (member, access) => call("PATCH /access/:member", { member }, access) },
|
|
345
|
-
/** Where I am signed in. Only where coffre runs its own sign-in. */
|
|
346
|
-
sessions: {
|
|
347
|
-
list: () => call("GET /sessions", {}),
|
|
348
|
-
revoke: (id) => call("DELETE /sessions/:id", { id })
|
|
349
|
-
},
|
|
350
|
-
/** The accounts I sign in with. */
|
|
351
|
-
identities: {
|
|
352
|
-
list: () => call("GET /identities", {}),
|
|
353
|
-
unlink: (id) => call("DELETE /identities/:id", { id })
|
|
354
|
-
},
|
|
355
|
-
/** A `coffre login` waiting for someone to approve it, by the code it shows. */
|
|
356
|
-
deviceLogins: {
|
|
357
|
-
get: (code) => call("GET /device-logins/:code", { code }),
|
|
358
|
-
decide: (code, approve) => call("POST /device-logins/:code", { code }, { approve })
|
|
359
|
-
},
|
|
360
|
-
syncs: {
|
|
361
|
-
/** Where a sync can push on this instance, and what each asks for. */
|
|
362
|
-
providers: () => call("GET /syncs/providers", {}),
|
|
363
|
-
list: (path) => call("GET /syncs/:project/:environment", place(path)),
|
|
364
|
-
add: (path, input) => call("POST /syncs/:project/:environment", place(path), input),
|
|
365
|
-
update: (id, patch) => call("PATCH /syncs/by-id/:id", { id }, patch),
|
|
366
|
-
remove: (id) => call("DELETE /syncs/by-id/:id", { id }),
|
|
367
|
-
run: (id) => call("POST /syncs/by-id/:id/runs", { id })
|
|
368
|
-
},
|
|
369
|
-
audit: {
|
|
370
|
-
list: (query = {}) => call("GET /audit", {}, query),
|
|
371
|
-
verify: () => call("GET /audit/verification", {})
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
1453
|
}
|
|
375
1454
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* opens to compare are logged as reads. `changes` is what to pass to
|
|
379
|
-
* `secrets.set`: the keys that differ, so an unchanged value does not become
|
|
380
|
-
* a new version.
|
|
1455
|
+
* The two keys, from where they are: each one's environment variable, then
|
|
1456
|
+
* stdin's lines in order when it is piped, or hidden prompts on a terminal.
|
|
381
1457
|
*/
|
|
382
|
-
async function
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
action,
|
|
397
|
-
version: versions.get(key) ?? null
|
|
1458
|
+
async function readKeys(env) {
|
|
1459
|
+
const given = {
|
|
1460
|
+
vault: env.COFFRE_VAULT_KEY?.trim(),
|
|
1461
|
+
app: env.COFFRE_APP_KEY?.trim()
|
|
1462
|
+
};
|
|
1463
|
+
const missing = ["vault", "app"].filter((which) => !given[which]);
|
|
1464
|
+
const asked = {};
|
|
1465
|
+
if (missing.length > 0 && !process.stdin.isTTY) {
|
|
1466
|
+
let text = "";
|
|
1467
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
1468
|
+
const lines = text.split(/\r?\n/);
|
|
1469
|
+
missing.forEach((which, i) => asked[which] = {
|
|
1470
|
+
text: lines[i]?.trim() ?? "",
|
|
1471
|
+
from: "stdin"
|
|
398
1472
|
});
|
|
399
|
-
|
|
1473
|
+
} else if (missing.length > 0) {
|
|
1474
|
+
const terminal = openTerminal();
|
|
1475
|
+
if (terminal === null) stop(2, "coffre: no terminal to ask for the keys on: pass them in COFFRE_VAULT_KEY and COFFRE_APP_KEY, or on stdin, the vault key first");
|
|
1476
|
+
const s = style(terminal.out);
|
|
1477
|
+
try {
|
|
1478
|
+
for (const which of missing) {
|
|
1479
|
+
const name = which === "vault" ? "Vault key" : "App key";
|
|
1480
|
+
asked[which] = {
|
|
1481
|
+
text: await hiddenLine(terminal.keys, terminal.out, s, `${name}?`, "Paste it: it stays hidden, and on this machine. Enter alone skips it."),
|
|
1482
|
+
from: "the prompt"
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
} finally {
|
|
1486
|
+
release(terminal.keys);
|
|
1487
|
+
}
|
|
400
1488
|
}
|
|
1489
|
+
const source = (which, variable) => given[which] ? {
|
|
1490
|
+
text: given[which],
|
|
1491
|
+
from: variable
|
|
1492
|
+
} : asked[which];
|
|
401
1493
|
return {
|
|
402
|
-
|
|
403
|
-
|
|
1494
|
+
vault: source("vault", "COFFRE_VAULT_KEY"),
|
|
1495
|
+
app: source("app", "COFFRE_APP_KEY")
|
|
404
1496
|
};
|
|
405
1497
|
}
|
|
1498
|
+
async function verifyKeys(args, api, origin, env, out = process.stdout) {
|
|
1499
|
+
const { values } = parseArgs({
|
|
1500
|
+
args,
|
|
1501
|
+
allowPositionals: false,
|
|
1502
|
+
options: {
|
|
1503
|
+
"vault-id": { type: "string" },
|
|
1504
|
+
help: {
|
|
1505
|
+
type: "boolean",
|
|
1506
|
+
short: "h"
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
if (values.help) stop(0, KEYS_USAGE);
|
|
1511
|
+
const vaultId = values["vault-id"] ?? (env.COFFRE_VAULT_KEY_ID?.trim() || void 0);
|
|
1512
|
+
const material = await api.audit.keys();
|
|
1513
|
+
const s = style(out);
|
|
1514
|
+
out.write(`${s.bold(`Checking your keys against ${origin}`)}\n`);
|
|
1515
|
+
out.write(s.dim(" On this machine: what they're checked against was read from the instance, and the keys go nowhere.\n\n"));
|
|
1516
|
+
const keys = await readKeys(env);
|
|
1517
|
+
const report = new Checks(out);
|
|
1518
|
+
await report.check("vault key", {}, () => said(keys.vault, () => vaultKeyVerdict(keys.vault.text, material.vault, vaultId)));
|
|
1519
|
+
await report.check("app key", {}, () => said(keys.app, () => appKeyVerdict(keys.app.text, material.app)));
|
|
1520
|
+
const { failed, results } = report;
|
|
1521
|
+
const right = results.filter(({ status }) => status === "ok").map(({ name }) => name);
|
|
1522
|
+
out.write(failed.length > 0 ? `\n${s.red("✗")} ${s.bold(failed.length === 2 ? `Neither key is ${origin}'s.` : `The ${failed[0]} is not ${origin}'s.`)}\n` : right.length === 0 ? `\n${s.red("✗")} ${s.bold("No key given: nothing checked.")}\n` : `\n${s.green("✓")} ${s.bold(right.length === 2 ? `Both keys are ${origin}'s.` : `The ${right[0]} is ${origin}'s.`)}\n`);
|
|
1523
|
+
process.exitCode = failed.length > 0 || right.length === 0 ? 1 : 0;
|
|
1524
|
+
}
|
|
1525
|
+
/** A key's verdict, and where the key came from when not a prompt: so that a script's run says which variable it read. */
|
|
1526
|
+
async function said(source, verdict) {
|
|
1527
|
+
const where = source.from === "the prompt" ? "" : ` (from ${source.from})`;
|
|
1528
|
+
try {
|
|
1529
|
+
return `${await verdict()}${where}`;
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
if (error instanceof Failure) throw new Failure(`${error.message}${where}`, error.detail);
|
|
1532
|
+
throw error;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
//#endregion
|
|
1536
|
+
//#region src/verify/index.ts
|
|
1537
|
+
/** The checks, in the order they are offered: each one's name, and what it checks. */
|
|
1538
|
+
const CHECKS = [
|
|
1539
|
+
["instance", "the instance from outside: as no one, then as you, an owner"],
|
|
1540
|
+
["keys", "the vault key and app key you keep, against the instance"],
|
|
1541
|
+
["log", "the whole audit log, as you, an owner"]
|
|
1542
|
+
];
|
|
1543
|
+
const VERIFY_USAGE = `usage: coffre verify instance | keys | log
|
|
1544
|
+
|
|
1545
|
+
${CHECKS.map(([name, what]) => ` ${name.padEnd(10)}${what}`).join("\n")}
|
|
1546
|
+
|
|
1547
|
+
On a terminal, \`coffre verify\` alone asks which. \`coffre verify <check> --help\`
|
|
1548
|
+
says more about each.`;
|
|
1549
|
+
/** The check named, or, with none, the one chosen on the terminal; without a terminal, the list, and exit 2. */
|
|
1550
|
+
async function pickCheck() {
|
|
1551
|
+
const terminal = openTerminal();
|
|
1552
|
+
if (terminal === null) stop(2, VERIFY_USAGE);
|
|
1553
|
+
const s = style(terminal.out);
|
|
1554
|
+
try {
|
|
1555
|
+
const at = await select(terminal.keys, terminal.out, s, "Which check?", CHECKS.map(([name, what]) => `${name.padEnd(10)}${s.dim(what)}`));
|
|
1556
|
+
terminal.out.write("\n");
|
|
1557
|
+
return CHECKS[at][0];
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
if (error instanceof Cancelled) process.exit(130);
|
|
1560
|
+
throw error;
|
|
1561
|
+
} finally {
|
|
1562
|
+
release(terminal.keys);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
406
1565
|
//#endregion
|
|
407
1566
|
//#region ../core/dist/dotenv.js
|
|
408
1567
|
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
@@ -1225,7 +2384,30 @@ async function audit(args) {
|
|
|
1225
2384
|
process.stdout.write(`${entry.occurredAt} ${entry.decision.padEnd(5)} ${entry.actorId.padEnd(28)} ${entry.action.padEnd(16)} ${place}\n`);
|
|
1226
2385
|
}
|
|
1227
2386
|
}
|
|
1228
|
-
|
|
2387
|
+
/** One check of the instance, named, or chosen on the terminal (`verify/index.ts`). */
|
|
2388
|
+
async function verify(args) {
|
|
2389
|
+
const [named, ...more] = args;
|
|
2390
|
+
if (named === "--help" || named === "-h") {
|
|
2391
|
+
process.stdout.write(`${VERIFY_USAGE}\n`);
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const check = named ?? await pickCheck();
|
|
2395
|
+
switch (check) {
|
|
2396
|
+
case "instance": return verifyInstance(more, readStore(), process.env);
|
|
2397
|
+
case "keys": {
|
|
2398
|
+
const to = target();
|
|
2399
|
+
return verifyKeys(more, client(to), to.origin, process.env);
|
|
2400
|
+
}
|
|
2401
|
+
case "log":
|
|
2402
|
+
if (more.length > 0) fail("usage: coffre verify log");
|
|
2403
|
+
return verifyLog();
|
|
2404
|
+
default:
|
|
2405
|
+
process.stderr.write(`coffre: no check named ${check}\n${VERIFY_USAGE}\n`);
|
|
2406
|
+
process.exit(2);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
/** The whole audit log, verified by the app and the vault, as an owner. */
|
|
2410
|
+
async function verifyLog() {
|
|
1229
2411
|
const result = await client().audit.verify();
|
|
1230
2412
|
if (result.ok) {
|
|
1231
2413
|
const through = result.through === null ? "empty" : `verified through entry ${result.through}`;
|
|
@@ -1435,7 +2617,11 @@ const USAGE = `coffre - secrets, with an audit log
|
|
|
1435
2617
|
|
|
1436
2618
|
Audit
|
|
1437
2619
|
coffre audit [--limit N] [--actor <id>] [--denied] [--detail]
|
|
1438
|
-
|
|
2620
|
+
|
|
2621
|
+
Verify (coffre verify alone asks which, on a terminal)
|
|
2622
|
+
coffre verify instance [<url>] the instance from outside: as no one, then as you, an owner
|
|
2623
|
+
coffre verify keys the vault key and app key you keep, checked on this machine
|
|
2624
|
+
coffre verify log the whole audit log, as an owner
|
|
1439
2625
|
|
|
1440
2626
|
Environment (each overrides the saved session for one command)
|
|
1441
2627
|
COFFRE_API_URL which instance to talk to
|
|
@@ -1513,7 +2699,7 @@ switch (command) {
|
|
|
1513
2699
|
await audit(rest);
|
|
1514
2700
|
break;
|
|
1515
2701
|
case "verify":
|
|
1516
|
-
await verify();
|
|
2702
|
+
await verify(rest);
|
|
1517
2703
|
break;
|
|
1518
2704
|
default:
|
|
1519
2705
|
process.stdout.write(USAGE);
|