@coffre/vault 0.14.0 → 0.14.2
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/dist/cloudflare.d.ts +1 -0
- package/dist/cloudflare.js +1 -1
- package/dist/node.js +1 -1
- package/dist/{vault-C5n9wSvj.js → vault-hLffpMy5.js} +83 -41
- package/package.json +3 -3
- package/src/vault.ts +81 -32
package/dist/cloudflare.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
|
18
18
|
#private;
|
|
19
19
|
unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<{
|
|
20
20
|
keys: string[];
|
|
21
|
+
accessRevision?: string;
|
|
21
22
|
}>>;
|
|
22
23
|
wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
|
23
24
|
rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
|
|
3
3
|
import { isUnavailable } from "@coffre/db/dialect";
|
|
4
4
|
import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
|
|
5
5
|
import { Agent, createServer, request } from "node:http";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
1
|
+
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
2
2
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
3
|
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { prepared, tablesOf } from "@coffre/db";
|
|
@@ -1891,6 +1891,7 @@ const MESSAGES = {
|
|
|
1891
1891
|
last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
|
|
1892
1892
|
invalid: "not something the rules allow",
|
|
1893
1893
|
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
1894
|
+
access_changed: "what this member holds changed since the read was decided: try again",
|
|
1894
1895
|
log_broken: "the vault log does not hold from the last checkpoint",
|
|
1895
1896
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
1896
1897
|
tampered: "this member's record failed the vault's integrity check"
|
|
@@ -2390,6 +2391,7 @@ var VaultService = class {
|
|
|
2390
2391
|
const { principal } = input;
|
|
2391
2392
|
validateText(input.purpose);
|
|
2392
2393
|
if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
|
|
2394
|
+
if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
|
|
2393
2395
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
2394
2396
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
2395
2397
|
const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
|
|
@@ -2431,8 +2433,8 @@ var VaultService = class {
|
|
|
2431
2433
|
secretVersionId: versionIds.get(secret)
|
|
2432
2434
|
};
|
|
2433
2435
|
};
|
|
2434
|
-
if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
|
|
2435
|
-
if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
|
|
2436
|
+
if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2437
|
+
if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2436
2438
|
const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
|
|
2437
2439
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
2438
2440
|
return this.#keys({
|
|
@@ -2453,13 +2455,16 @@ var VaultService = class {
|
|
|
2453
2455
|
key,
|
|
2454
2456
|
wipe: () => key.fill(0)
|
|
2455
2457
|
};
|
|
2456
|
-
}), (opened) => ({
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2458
|
+
}), (opened) => ({
|
|
2459
|
+
keys: opened.map(({ key }) => {
|
|
2460
|
+
try {
|
|
2461
|
+
return base64(key);
|
|
2462
|
+
} finally {
|
|
2463
|
+
key.fill(0);
|
|
2464
|
+
}
|
|
2465
|
+
}),
|
|
2466
|
+
...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
|
|
2467
|
+
}));
|
|
2463
2468
|
}
|
|
2464
2469
|
async wrap(input) {
|
|
2465
2470
|
const { principal, items } = input;
|
|
@@ -2510,7 +2515,7 @@ var VaultService = class {
|
|
|
2510
2515
|
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
|
|
2511
2516
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
|
|
2512
2517
|
secretVersionId: source.id
|
|
2513
|
-
})), LOOSER);
|
|
2518
|
+
})), { message: LOOSER });
|
|
2514
2519
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
2515
2520
|
const loosened = new Set(items.flatMap((item, i) => loosens[i] ? [item.secret] : []));
|
|
2516
2521
|
const entry = (secret, decision, code) => ({
|
|
@@ -2572,7 +2577,7 @@ var VaultService = class {
|
|
|
2572
2577
|
if ([...found.values()].some((version) => version.deleted)) return this.#refuseVersions(input.principal, "deleted", [...found.values()].map((version) => ({
|
|
2573
2578
|
...keyEntry(action, input.principal, version.secret, "deny", "deleted", input, detail),
|
|
2574
2579
|
secretVersionId: version.id
|
|
2575
|
-
})));
|
|
2580
|
+
})), { asked: input.accessRevision });
|
|
2576
2581
|
if (ids.some((id) => !found.has(id))) return this.#refuseVersions(input.principal, "bad_claim", ids.map((id) => {
|
|
2577
2582
|
const version = found.get(id);
|
|
2578
2583
|
if (version === void 0) return {
|
|
@@ -2592,14 +2597,23 @@ var VaultService = class {
|
|
|
2592
2597
|
...keyEntry(action, input.principal, version.secret, "deny", "bad_claim", input, detail),
|
|
2593
2598
|
secretVersionId: id
|
|
2594
2599
|
};
|
|
2595
|
-
}));
|
|
2600
|
+
}), { asked: input.accessRevision });
|
|
2596
2601
|
return {
|
|
2597
2602
|
ok: true,
|
|
2598
2603
|
versions: ids.map((id) => found.get(id))
|
|
2599
2604
|
};
|
|
2600
2605
|
}
|
|
2601
|
-
|
|
2602
|
-
|
|
2606
|
+
/**
|
|
2607
|
+
* Refuse a call for what its versions are, under the principal's row, as
|
|
2608
|
+
* any decision: and a read on a kept answer no longer theirs as that, not
|
|
2609
|
+
* for its versions, logging no read (`accessRevision`).
|
|
2610
|
+
*/
|
|
2611
|
+
async #refuseVersions(principal, code, entries, { message = MESSAGES[code], asked } = {}) {
|
|
2612
|
+
return this.#decide([principal], async (d) => {
|
|
2613
|
+
if (asked !== void 0) {
|
|
2614
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
2615
|
+
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
|
|
2616
|
+
}
|
|
2603
2617
|
throw new Refused(refusal(code, message), entries);
|
|
2604
2618
|
});
|
|
2605
2619
|
}
|
|
@@ -2636,7 +2650,10 @@ var VaultService = class {
|
|
|
2636
2650
|
});
|
|
2637
2651
|
const owners = /* @__PURE__ */ new Map();
|
|
2638
2652
|
const check = async (d) => {
|
|
2639
|
-
const
|
|
2653
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
2654
|
+
const asked = call.input.accessRevision;
|
|
2655
|
+
if (asked !== void 0 && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
|
|
2656
|
+
const reader = through(standing, call.input);
|
|
2640
2657
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
2641
2658
|
const environments = d.reads.environments;
|
|
2642
2659
|
owners.clear();
|
|
@@ -2725,7 +2742,7 @@ var VaultService = class {
|
|
|
2725
2742
|
try {
|
|
2726
2743
|
await check(d);
|
|
2727
2744
|
} catch (error) {
|
|
2728
|
-
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item]
|
|
2745
|
+
if (error instanceof Refused && !(error.refusal.code === "access_changed" && intentSeq === null)) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item]?.code ?? error.refusal.code)));
|
|
2729
2746
|
throw error;
|
|
2730
2747
|
}
|
|
2731
2748
|
};
|
|
@@ -3110,7 +3127,7 @@ var VaultService = class {
|
|
|
3110
3127
|
}
|
|
3111
3128
|
#access(principal, row, held, at, tampered, policy) {
|
|
3112
3129
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
|
|
3113
|
-
|
|
3130
|
+
const access = {
|
|
3114
3131
|
principal,
|
|
3115
3132
|
status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
|
|
3116
3133
|
generation: row?.generation ?? 0,
|
|
@@ -3123,6 +3140,16 @@ var VaultService = class {
|
|
|
3123
3140
|
by: row?.statusChangedBy ?? null,
|
|
3124
3141
|
policy
|
|
3125
3142
|
};
|
|
3143
|
+
return {
|
|
3144
|
+
...access,
|
|
3145
|
+
revision: accessRevision(access)
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
/** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
|
|
3149
|
+
#revisionOf(standing, d) {
|
|
3150
|
+
const { policy } = standing.live;
|
|
3151
|
+
if (standing.status !== "active" || policy === void 0) return null;
|
|
3152
|
+
return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision;
|
|
3126
3153
|
}
|
|
3127
3154
|
/**
|
|
3128
3155
|
* What `principal` holds now. Read without locks, the fast way; a row that
|
|
@@ -3959,15 +3986,16 @@ var VaultService = class {
|
|
|
3959
3986
|
/**
|
|
3960
3987
|
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
3961
3988
|
*
|
|
3962
|
-
*
|
|
3963
|
-
*
|
|
3964
|
-
* write nothing to the log; the prefix the newest checkpoint signed
|
|
3965
|
-
* there, its hash where it was, which a rewrite chained again
|
|
3966
|
-
* every entry of it, which a cut changes
|
|
3967
|
-
*
|
|
3968
|
-
*
|
|
3969
|
-
*
|
|
3970
|
-
*
|
|
3989
|
+
* Without the log's lock, so that no append waits on it, and in one short
|
|
3990
|
+
* snapshot: every member's row checked, since rows changed around the
|
|
3991
|
+
* vault write nothing to the log; the prefix the newest checkpoint signed
|
|
3992
|
+
* still there, its hash where it was, which a rewrite chained again
|
|
3993
|
+
* changes, and every entry of it, which a cut changes. Then, in no
|
|
3994
|
+
* transaction, a batch a statement, the chain recomputed from there, every
|
|
3995
|
+
* hash from content and every vault entry by its MAC, no further than one
|
|
3996
|
+
* walk (`walks.checkpoint`); and a stretch of the prefixes signed before
|
|
3997
|
+
* rehashed (`#scrub`). Then, under the lock, the entries appended since
|
|
3998
|
+
* the walk, so that it signs the head.
|
|
3971
3999
|
*
|
|
3972
4000
|
* A walk cut short signs where it got to, behind the head: the log after
|
|
3973
4001
|
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
@@ -3980,7 +4008,7 @@ var VaultService = class {
|
|
|
3980
4008
|
const { walks } = this.#prepared.options;
|
|
3981
4009
|
const found = [];
|
|
3982
4010
|
let seen = null;
|
|
3983
|
-
const
|
|
4011
|
+
const resumed = await this.#db.transaction(async (tx) => {
|
|
3984
4012
|
seen = (await newestCheckpointEntry(tx))?.seq ?? null;
|
|
3985
4013
|
await this.#sweep(tx, found);
|
|
3986
4014
|
const from = await this.#resume(tx, "newest");
|
|
@@ -3997,20 +4025,9 @@ var VaultService = class {
|
|
|
3997
4025
|
failedAtSeq: Number(gap.found),
|
|
3998
4026
|
reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
|
|
3999
4027
|
};
|
|
4000
|
-
|
|
4001
|
-
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
4002
|
-
if (!chain.ok) return chain;
|
|
4003
|
-
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
4004
|
-
if (unsigned !== null) return unsigned;
|
|
4005
|
-
return {
|
|
4006
|
-
ok: true,
|
|
4007
|
-
from,
|
|
4008
|
-
tally: walk.tally(),
|
|
4009
|
-
digest: walk.digest(),
|
|
4010
|
-
at: chain.at,
|
|
4011
|
-
done: chain.done
|
|
4012
|
-
};
|
|
4028
|
+
return from;
|
|
4013
4029
|
}, SNAPSHOT);
|
|
4030
|
+
const walked = resumed.ok ? await this.#walkOn(resumed, walks.checkpoint) : resumed;
|
|
4014
4031
|
let scrub = {
|
|
4015
4032
|
ok: true,
|
|
4016
4033
|
next: walked.ok ? walked.from.scrub : null
|
|
@@ -4120,6 +4137,31 @@ var VaultService = class {
|
|
|
4120
4137
|
});
|
|
4121
4138
|
}
|
|
4122
4139
|
/**
|
|
4140
|
+
* The walk on from where a checkpoint resumes, no further than `limit`
|
|
4141
|
+
* entries, outside any transaction: a batch a statement, each linked to
|
|
4142
|
+
* the hash it verified last, so that no connection is held while it
|
|
4143
|
+
* rehashes, seconds for a long walk. Entries are appended in order, under
|
|
4144
|
+
* the head, and never change, so no snapshot keeps them: what it read is
|
|
4145
|
+
* held to the log again under the head, before anything is signed over it
|
|
4146
|
+
* (`carries`).
|
|
4147
|
+
*/
|
|
4148
|
+
async #walkOn(from, limit) {
|
|
4149
|
+
const { logKeys } = this.#prepared;
|
|
4150
|
+
const walk = new Walk(from.tally, from.digest);
|
|
4151
|
+
const chain = await rehash(this.#db, logKeys, from.at, walk, { limit });
|
|
4152
|
+
if (!chain.ok) return chain;
|
|
4153
|
+
const unsigned = await this.#checkpointFault(this.#db, walk.checkpoints);
|
|
4154
|
+
if (unsigned !== null) return unsigned;
|
|
4155
|
+
return {
|
|
4156
|
+
ok: true,
|
|
4157
|
+
from,
|
|
4158
|
+
tally: walk.tally(),
|
|
4159
|
+
digest: walk.digest(),
|
|
4160
|
+
at: chain.at,
|
|
4161
|
+
done: chain.done
|
|
4162
|
+
};
|
|
4163
|
+
}
|
|
4164
|
+
/**
|
|
4123
4165
|
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
4124
4166
|
* runs from the first entry to the prefix the newest checkpoint signed
|
|
4125
4167
|
* when it began, which it keeps (`end`), `walks.scrub` entries a call, each
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.14.
|
|
40
|
-
"@coffre/db": "0.14.
|
|
39
|
+
"@coffre/core": "0.14.2",
|
|
40
|
+
"@coffre/db": "0.14.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/vault.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
checkpointVerifier,
|
|
62
62
|
describeAccessFault,
|
|
63
63
|
POLICY_REVISION,
|
|
64
|
+
accessRevision,
|
|
64
65
|
policyRevision,
|
|
65
66
|
type Access,
|
|
66
67
|
type AccessChange,
|
|
@@ -341,6 +342,7 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
341
342
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
342
343
|
invalid: 'not something the rules allow',
|
|
343
344
|
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
345
|
+
access_changed: 'what this member holds changed since the read was decided: try again',
|
|
344
346
|
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
345
347
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
346
348
|
tampered: "this member's record failed the vault's integrity check",
|
|
@@ -906,10 +908,11 @@ class VaultService implements Vault {
|
|
|
906
908
|
|
|
907
909
|
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
908
910
|
// cannot be wiped, so this is best-effort memory hygiene.
|
|
909
|
-
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
|
|
911
|
+
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[]; accessRevision?: string }>> {
|
|
910
912
|
const { principal } = input;
|
|
911
913
|
validateText(input.purpose);
|
|
912
914
|
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
915
|
+
if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
|
|
913
916
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
914
917
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
915
918
|
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
@@ -947,10 +950,10 @@ class VaultService implements Vault {
|
|
|
947
950
|
};
|
|
948
951
|
};
|
|
949
952
|
if (viaOk.includes(false)) {
|
|
950
|
-
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
953
|
+
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
951
954
|
}
|
|
952
955
|
if (throughOk.includes(false)) {
|
|
953
|
-
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
956
|
+
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
954
957
|
}
|
|
955
958
|
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
956
959
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
@@ -984,6 +987,8 @@ class VaultService implements Vault {
|
|
|
984
987
|
key.fill(0);
|
|
985
988
|
}
|
|
986
989
|
}),
|
|
990
|
+
// Named back: the read was decided on that answer, which this vault checked.
|
|
991
|
+
...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
|
|
987
992
|
}),
|
|
988
993
|
);
|
|
989
994
|
}
|
|
@@ -1042,7 +1047,7 @@ class VaultService implements Vault {
|
|
|
1042
1047
|
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) {
|
|
1043
1048
|
return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
|
|
1044
1049
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
|
|
1045
|
-
})), LOOSER);
|
|
1050
|
+
})), { message: LOOSER });
|
|
1046
1051
|
}
|
|
1047
1052
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1048
1053
|
const loosened = new Set(items.flatMap((item, i) => (loosens[i] ? [item.secret] : [])));
|
|
@@ -1094,7 +1099,7 @@ class VaultService implements Vault {
|
|
|
1094
1099
|
|
|
1095
1100
|
/** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
|
|
1096
1101
|
async #versions(
|
|
1097
|
-
input: Correlation & { principal: string; items: { secretVersionId: string }[] },
|
|
1102
|
+
input: Correlation & { principal: string; items: { secretVersionId: string }[]; accessRevision?: string },
|
|
1098
1103
|
action: KeyAction,
|
|
1099
1104
|
detail: Record<string, unknown> = {},
|
|
1100
1105
|
): Promise<Outcome<{ versions: store.SecretVersion[] }>> {
|
|
@@ -1110,7 +1115,7 @@ class VaultService implements Vault {
|
|
|
1110
1115
|
if ([...found.values()].some((version) => version.deleted)) {
|
|
1111
1116
|
return this.#refuseVersions(input.principal, 'deleted', [...found.values()].map((version) => ({
|
|
1112
1117
|
...keyEntry(action, input.principal, version.secret, 'deny', 'deleted', input, detail), secretVersionId: version.id,
|
|
1113
|
-
})));
|
|
1118
|
+
})), { asked: input.accessRevision });
|
|
1114
1119
|
}
|
|
1115
1120
|
if (ids.some((id) => !found.has(id))) {
|
|
1116
1121
|
return this.#refuseVersions(input.principal, 'bad_claim', ids.map((id) => {
|
|
@@ -1121,13 +1126,27 @@ class VaultService implements Vault {
|
|
|
1121
1126
|
metadata: JSON.stringify({ ...detail, secretVersionId: id, ...traced(input) }),
|
|
1122
1127
|
};
|
|
1123
1128
|
return { ...keyEntry(action, input.principal, version.secret, 'deny', 'bad_claim', input, detail), secretVersionId: id };
|
|
1124
|
-
}));
|
|
1129
|
+
}), { asked: input.accessRevision });
|
|
1125
1130
|
}
|
|
1126
1131
|
return { ok: true, versions: ids.map((id) => found.get(id)!) };
|
|
1127
1132
|
}
|
|
1128
1133
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1134
|
+
/**
|
|
1135
|
+
* Refuse a call for what its versions are, under the principal's row, as
|
|
1136
|
+
* any decision: and a read on a kept answer no longer theirs as that, not
|
|
1137
|
+
* for its versions, logging no read (`accessRevision`).
|
|
1138
|
+
*/
|
|
1139
|
+
async #refuseVersions(
|
|
1140
|
+
principal: string,
|
|
1141
|
+
code: 'bad_claim' | 'deleted' | 'invalid',
|
|
1142
|
+
entries: NewEntry[],
|
|
1143
|
+
{ message = MESSAGES[code], asked }: { message?: string; asked?: string } = {},
|
|
1144
|
+
): Promise<Outcome<never>> {
|
|
1145
|
+
return this.#decide([principal], async (d) => {
|
|
1146
|
+
if (asked !== undefined) {
|
|
1147
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1148
|
+
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
|
|
1149
|
+
}
|
|
1131
1150
|
throw new Refused(refusal(code, message), entries);
|
|
1132
1151
|
});
|
|
1133
1152
|
}
|
|
@@ -1185,7 +1204,7 @@ class VaultService implements Vault {
|
|
|
1185
1204
|
viaReference?: readonly boolean[];
|
|
1186
1205
|
remote: boolean;
|
|
1187
1206
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1188
|
-
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
|
|
1207
|
+
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
|
|
1189
1208
|
},
|
|
1190
1209
|
/**
|
|
1191
1210
|
* One per secret; null for a bad claim, an error for an outage or an
|
|
@@ -1206,7 +1225,11 @@ class VaultService implements Vault {
|
|
|
1206
1225
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1207
1226
|
const owners = new Map<string, string>();
|
|
1208
1227
|
const check = async (d: Decision) => {
|
|
1209
|
-
const
|
|
1228
|
+
const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
|
|
1229
|
+
// A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
|
|
1230
|
+
const asked = call.input.accessRevision;
|
|
1231
|
+
if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
|
|
1232
|
+
const reader = through(standing, call.input);
|
|
1210
1233
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1211
1234
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
1212
1235
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
@@ -1290,9 +1313,11 @@ class VaultService implements Vault {
|
|
|
1290
1313
|
try {
|
|
1291
1314
|
await check(d);
|
|
1292
1315
|
} catch (error) {
|
|
1293
|
-
|
|
1316
|
+
// A read on a kept answer no longer the vault's logs no read; but a key service's intent, logged already, is
|
|
1317
|
+
// accounted for all the same, each key refused, as after any other refusal.
|
|
1318
|
+
if (error instanceof Refused && !(error.refusal.code === 'access_changed' && intentSeq === null)) {
|
|
1294
1319
|
throw new Refused(error.refusal, secrets.map((secret, item) =>
|
|
1295
|
-
outcomeEntry(secret, item, 'deny', error.entries[item]
|
|
1320
|
+
outcomeEntry(secret, item, 'deny', error.entries[item]?.code ?? error.refusal.code)));
|
|
1296
1321
|
}
|
|
1297
1322
|
throw error;
|
|
1298
1323
|
}
|
|
@@ -1665,7 +1690,7 @@ class VaultService implements Vault {
|
|
|
1665
1690
|
|
|
1666
1691
|
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
|
|
1667
1692
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
1668
|
-
|
|
1693
|
+
const access: Access = {
|
|
1669
1694
|
principal,
|
|
1670
1695
|
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
1671
1696
|
generation: row?.generation ?? 0,
|
|
@@ -1675,6 +1700,14 @@ class VaultService implements Vault {
|
|
|
1675
1700
|
by: row?.statusChangedBy ?? null,
|
|
1676
1701
|
policy,
|
|
1677
1702
|
};
|
|
1703
|
+
return { ...access, revision: accessRevision(access) };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
|
|
1707
|
+
#revisionOf(standing: Standing, d: Decision): string | null {
|
|
1708
|
+
const { policy } = standing.live;
|
|
1709
|
+
if (standing.status !== 'active' || policy === undefined) return null;
|
|
1710
|
+
return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision!;
|
|
1678
1711
|
}
|
|
1679
1712
|
|
|
1680
1713
|
/**
|
|
@@ -2498,15 +2531,16 @@ class VaultService implements Vault {
|
|
|
2498
2531
|
/**
|
|
2499
2532
|
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
2500
2533
|
*
|
|
2501
|
-
*
|
|
2502
|
-
*
|
|
2503
|
-
* write nothing to the log; the prefix the newest checkpoint signed
|
|
2504
|
-
* there, its hash where it was, which a rewrite chained again
|
|
2505
|
-
* every entry of it, which a cut changes
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2534
|
+
* Without the log's lock, so that no append waits on it, and in one short
|
|
2535
|
+
* snapshot: every member's row checked, since rows changed around the
|
|
2536
|
+
* vault write nothing to the log; the prefix the newest checkpoint signed
|
|
2537
|
+
* still there, its hash where it was, which a rewrite chained again
|
|
2538
|
+
* changes, and every entry of it, which a cut changes. Then, in no
|
|
2539
|
+
* transaction, a batch a statement, the chain recomputed from there, every
|
|
2540
|
+
* hash from content and every vault entry by its MAC, no further than one
|
|
2541
|
+
* walk (`walks.checkpoint`); and a stretch of the prefixes signed before
|
|
2542
|
+
* rehashed (`#scrub`). Then, under the lock, the entries appended since
|
|
2543
|
+
* the walk, so that it signs the head.
|
|
2510
2544
|
*
|
|
2511
2545
|
* A walk cut short signs where it got to, behind the head: the log after
|
|
2512
2546
|
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
@@ -2522,7 +2556,7 @@ class VaultService implements Vault {
|
|
|
2522
2556
|
const found: NewEntry[] = [];
|
|
2523
2557
|
// The newest checkpoint entry, signed or refused, as this call's snapshot found it.
|
|
2524
2558
|
let seen: bigint | null = null;
|
|
2525
|
-
const
|
|
2559
|
+
const resumed = await this.#db.transaction(async (tx) => {
|
|
2526
2560
|
seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
|
|
2527
2561
|
await this.#sweep(tx, found);
|
|
2528
2562
|
const from = await this.#resume(tx, 'newest');
|
|
@@ -2533,13 +2567,9 @@ class VaultService implements Vault {
|
|
|
2533
2567
|
}
|
|
2534
2568
|
const gap = await missingEntry(tx, from.at.seq);
|
|
2535
2569
|
if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
|
|
2536
|
-
|
|
2537
|
-
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
2538
|
-
if (!chain.ok) return chain;
|
|
2539
|
-
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
2540
|
-
if (unsigned !== null) return unsigned;
|
|
2541
|
-
return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
|
|
2570
|
+
return from;
|
|
2542
2571
|
}, SNAPSHOT);
|
|
2572
|
+
const walked = resumed.ok ? await this.#walkOn(resumed, walks.checkpoint) : resumed;
|
|
2543
2573
|
// The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
|
|
2544
2574
|
// next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
|
|
2545
2575
|
// throws, as anywhere else.
|
|
@@ -2580,7 +2610,7 @@ class VaultService implements Vault {
|
|
|
2580
2610
|
// Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
|
|
2581
2611
|
if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
2582
2612
|
// Under the lock, the rest: the prefix signed last still where it was,
|
|
2583
|
-
// and every entry since the
|
|
2613
|
+
// and every entry since the walk, unless the walk is catching up,
|
|
2584
2614
|
// when what it walked must still be there. A rewrite is never signed over.
|
|
2585
2615
|
if (newest !== null && !(await carries(d.tx, newest))) {
|
|
2586
2616
|
throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
@@ -2588,7 +2618,7 @@ class VaultService implements Vault {
|
|
|
2588
2618
|
const walk = new Walk(walked.tally, walked.digest);
|
|
2589
2619
|
let { at } = walked;
|
|
2590
2620
|
if (walked.done) {
|
|
2591
|
-
// Where the
|
|
2621
|
+
// Where the walk ended still has the hash it found there: what follows links to it.
|
|
2592
2622
|
if (!(await carries(d.tx, headOf(at)))) {
|
|
2593
2623
|
throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
2594
2624
|
}
|
|
@@ -2625,6 +2655,25 @@ class VaultService implements Vault {
|
|
|
2625
2655
|
});
|
|
2626
2656
|
}
|
|
2627
2657
|
|
|
2658
|
+
/**
|
|
2659
|
+
* The walk on from where a checkpoint resumes, no further than `limit`
|
|
2660
|
+
* entries, outside any transaction: a batch a statement, each linked to
|
|
2661
|
+
* the hash it verified last, so that no connection is held while it
|
|
2662
|
+
* rehashes, seconds for a long walk. Entries are appended in order, under
|
|
2663
|
+
* the head, and never change, so no snapshot keeps them: what it read is
|
|
2664
|
+
* held to the log again under the head, before anything is signed over it
|
|
2665
|
+
* (`carries`).
|
|
2666
|
+
*/
|
|
2667
|
+
async #walkOn(from: Resumed, limit: number) {
|
|
2668
|
+
const { logKeys } = this.#prepared;
|
|
2669
|
+
const walk = new Walk(from.tally, from.digest);
|
|
2670
|
+
const chain = await rehash(this.#db, logKeys, from.at, walk, { limit });
|
|
2671
|
+
if (!chain.ok) return chain;
|
|
2672
|
+
const unsigned = await this.#checkpointFault(this.#db, walk.checkpoints);
|
|
2673
|
+
if (unsigned !== null) return unsigned;
|
|
2674
|
+
return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2628
2677
|
/**
|
|
2629
2678
|
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
2630
2679
|
* runs from the first entry to the prefix the newest checkpoint signed
|