@coffre/vault 0.14.2 → 0.14.4
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 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-zkW6DEm9.d.ts → index-CIq4i_KT.d.ts} +1 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-B0d-W-SI.js → src-NM5NsYFs.js} +1 -8
- package/dist/{vault-hLffpMy5.js → vault-BBdXsny3.js} +120 -45
- package/package.json +3 -3
- package/src/config.ts +2 -10
- package/src/snapshot.postgres.ts +10 -11
- package/src/snapshot.sqlite.ts +7 -10
- package/src/snapshot.ts +7 -3
- package/src/store.ts +39 -8
- package/src/vault.ts +65 -19
package/dist/cloudflare.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-
|
|
1
|
+
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
|
|
2
2
|
import { AdmitInput, ChangePolicyInput, EndReferencesInput, ReferenceInput, RemoveInput, RewrapInput, SetAccessInput, SetSettingsInput, UnwrapInput, Vault, VerifyLogInput, WrapInput } from "@coffre/core/vault";
|
|
3
3
|
import { PostgresDatabase, PostgresDatabase as PostgresDatabase$1, postgres } from "@coffre/db/hyperdrive";
|
|
4
4
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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-
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
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-NM5NsYFs.js";
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkpointMessage as checkpointMessage$1, verifyCheckpoint } from "@coffre/core/vault";
|
|
2
|
-
import { AwsCredentials, AwsKmsOptions, KekBadClaimError as KekBadClaimError$1, KekProvider, KekProvider as KekProvider$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, WrappedDek, awsKms } from "@coffre/core/kek";
|
|
2
|
+
import { AwsCredentials, AwsKmsOptions, KekBadClaimError as KekBadClaimError$1, KekProvider, KekProvider as KekProvider$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, WrappedDek, awsKms, derivedSigningKey } from "@coffre/core/kek";
|
|
3
3
|
import { SecretContext } from "@coffre/core/envelope";
|
|
4
4
|
//#region src/config.d.ts
|
|
5
5
|
/**
|
|
@@ -72,7 +72,5 @@ type Owners = {
|
|
|
72
72
|
owners?: never;
|
|
73
73
|
bootstrapOwners?: never;
|
|
74
74
|
};
|
|
75
|
-
/** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
|
|
76
|
-
declare function derivedSigningKey(kek: Uint8Array): Buffer;
|
|
77
75
|
//#endregion
|
|
78
76
|
export { KekUnavailableError$1 as a, awsKms as c, Kek as d, VaultConfig as f, KekProvider$1 as i, checkpointMessage$1 as l, AwsKmsOptions as n, SecretContext as o, derivedSigningKey as p, KekBadClaimError$1 as r, WrappedDek as s, AwsCredentials as t, verifyCheckpoint as u };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-
|
|
1
|
+
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
|
|
2
2
|
export type * from "@coffre/core/vault";
|
|
3
3
|
export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type SecretContext, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-
|
|
1
|
+
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-NM5NsYFs.js";
|
|
2
2
|
export { KekBadClaimError, KekUnavailableError, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
|
package/dist/node.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-
|
|
1
|
+
import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
|
|
2
2
|
import { Vault, Vault as Vault$1 } from "@coffre/core/vault";
|
|
3
3
|
import { Database } from "@coffre/db";
|
|
4
4
|
import "@coffre/core/access";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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-
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
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-NM5NsYFs.js";
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.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,6 +1,5 @@
|
|
|
1
1
|
import { checkpointMessage as checkpointMessage$1, verifyCheckpoint } from "@coffre/core/vault";
|
|
2
|
-
import { KekBadClaimError as KekBadClaimError$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, LocalKekProvider, awsKms } from "@coffre/core/kek";
|
|
3
|
-
import { hkdfSync } from "node:crypto";
|
|
2
|
+
import { KekBadClaimError as KekBadClaimError$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, LocalKekProvider, awsKms, derivedSigningKey } from "@coffre/core/kek";
|
|
4
3
|
//#region src/config.ts
|
|
5
4
|
/**
|
|
6
5
|
* 1000 different keys in 15 minutes, which no person or deploy pipeline
|
|
@@ -21,12 +20,6 @@ const KEK_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
21
20
|
const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
22
21
|
/** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
|
|
23
22
|
const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
|
|
24
|
-
/** The label no other use of a KEK shares: a local KEK is otherwise only ever an AES-256-GCM key. */
|
|
25
|
-
const SIGNING_KEY_LABEL = "coffre.vault.signing-key.v1";
|
|
26
|
-
/** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
|
|
27
|
-
function derivedSigningKey(kek) {
|
|
28
|
-
return Buffer.from(hkdfSync("sha256", kek, /* @__PURE__ */ new Uint8Array(0), SIGNING_KEY_LABEL, 32));
|
|
29
|
-
}
|
|
30
23
|
/** A KEK as the registry takes it, and the signing key it stands for when it is one the vault holds. */
|
|
31
24
|
function kek(entry) {
|
|
32
25
|
if (!("wrap" in entry)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
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
|
-
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
3
|
import { prepared, tablesOf } from "@coffre/db";
|
|
4
|
+
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, ownsProject, readsSensitive, runsInstance, setsUpServices, unscoped, useOnly, withinCredential } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
@@ -178,10 +178,16 @@ function referenceRefusal(reader, holder, source, environments, nature) {
|
|
|
178
178
|
//#region src/store.ts
|
|
179
179
|
/**
|
|
180
180
|
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
181
|
-
* wait for ever behind a transaction that never ends
|
|
181
|
+
* wait for ever behind a transaction that never ends; with `statements`,
|
|
182
|
+
* any statement that takes longer, too. A queue for a row each holder
|
|
183
|
+
* updates, as the log's head, is no one wait: a waiter follows each update
|
|
184
|
+
* to the row's new version and waits again, its lock's clock started over,
|
|
185
|
+
* so only the statement's own bound ends it.
|
|
182
186
|
*/
|
|
183
|
-
async function boundLockWaits(tx, ms) {
|
|
184
|
-
if (engineOf(tx)
|
|
187
|
+
async function boundLockWaits(tx, ms, statements = false) {
|
|
188
|
+
if (engineOf(tx) !== "postgres") return;
|
|
189
|
+
const bound = String(Math.trunc(ms));
|
|
190
|
+
await tx.execute(statements ? sql`SELECT set_config('lock_timeout', ${bound}, true), set_config('statement_timeout', ${bound}, true)` : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
|
|
185
191
|
}
|
|
186
192
|
/** The database's clock, now. */
|
|
187
193
|
async function now(db) {
|
|
@@ -251,14 +257,37 @@ async function instanceOwners(db) {
|
|
|
251
257
|
* Lock these members' rows for the rest of the transaction, in one order
|
|
252
258
|
* everywhere, and read them: those that have none are not in the map. Every
|
|
253
259
|
* decision about a member takes its row first, so two of them, from any
|
|
254
|
-
* process, never interleave
|
|
260
|
+
* process, never interleave; but `shared`, for a decision that writes no
|
|
261
|
+
* member's row, as a read, takes them as other such decisions do, side by
|
|
262
|
+
* side, and only what changes the member waits for it, and it for that.
|
|
263
|
+
*
|
|
264
|
+
* Postgres lets a share lock join those already held even while a writer
|
|
265
|
+
* waits for them, so a steady stream of one member's reads would hold a
|
|
266
|
+
* removal off for ever. So each row comes with an advisory lock on its
|
|
267
|
+
* principal, which the lock manager queues in order: a read asked after a
|
|
268
|
+
* writer waits behind it. The same statement takes it, then the row, one
|
|
269
|
+
* principal after another.
|
|
255
270
|
*/
|
|
256
|
-
async function lockMembers(tx, principals) {
|
|
271
|
+
async function lockMembers(tx, principals, shared = false) {
|
|
257
272
|
const { vaultMembers } = tablesOf(tx);
|
|
258
|
-
const
|
|
259
|
-
|
|
273
|
+
const queued = engineOf(tx) === "postgres" ? { queued: sql`${sql.raw(shared ? "pg_advisory_xact_lock_shared" : "pg_advisory_xact_lock")}(${MEMBER_QUEUE}, hashtext(${vaultMembers.principal}))` } : {};
|
|
274
|
+
const rows = await forUpdate(tx, tx.select({
|
|
275
|
+
...memberColumns(tx),
|
|
276
|
+
...queued
|
|
277
|
+
}).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), shared ? "share" : "no key update");
|
|
278
|
+
return new Map(rows.map(({ queued: _, ...row }) => [row.principal, memberOf$1(row)]));
|
|
260
279
|
}
|
|
261
280
|
/**
|
|
281
|
+
* The first key of the members' queues, Postgres's advisory locks: the
|
|
282
|
+
* second is the principal's hash, and the pair is in a space of its own,
|
|
283
|
+
* apart from the migrator's single key. A hash two principals share queues
|
|
284
|
+
* one behind the other's writer; and two changes that each lock two
|
|
285
|
+
* members, where one's first and the other's second share it, can wait on
|
|
286
|
+
* each other, which Postgres ends after a second by failing one of them
|
|
287
|
+
* (40P01): about n² / 2³³ for an instance of n members.
|
|
288
|
+
*/
|
|
289
|
+
const MEMBER_QUEUE = 1668245101;
|
|
290
|
+
/**
|
|
262
291
|
* Add `row`. A principal has one row: when two decisions admit the same
|
|
263
292
|
* one at once, the second fails here, and rolls back with its entries.
|
|
264
293
|
*/
|
|
@@ -1187,17 +1216,16 @@ function snapshotQuery$1(asked) {
|
|
|
1187
1216
|
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
|
1188
1217
|
),
|
|
1189
1218
|
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
)`}
|
|
1219
|
+
-- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
|
|
1220
|
+
-- principal was ever released, however many reads of them the log holds (vault_releases, which the database
|
|
1221
|
+
-- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
|
|
1222
|
+
SELECT count(*) FROM (
|
|
1223
|
+
SELECT 1 FROM vault_releases
|
|
1224
|
+
WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs})
|
|
1225
|
+
AND key <> ALL(${sql.param([...released.keys])}::text[])
|
|
1226
|
+
LIMIT ${released.cap + 1}
|
|
1227
|
+
) AS before
|
|
1228
|
+
) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
|
|
1201
1229
|
) AS snapshot`;
|
|
1202
1230
|
}
|
|
1203
1231
|
//#endregion
|
|
@@ -1278,17 +1306,14 @@ function snapshotQuery(asked) {
|
|
|
1278
1306
|
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
|
1279
1307
|
)),
|
|
1280
1308
|
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
1288
|
-
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1289
|
-
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
1309
|
+
-- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
|
|
1310
|
+
SELECT count(*) FROM (
|
|
1311
|
+
SELECT 1 FROM vault_releases
|
|
1312
|
+
WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1313
|
+
AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
|
|
1314
|
+
LIMIT ${released.cap + 1}
|
|
1290
1315
|
)
|
|
1291
|
-
)`}
|
|
1316
|
+
) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
|
|
1292
1317
|
) AS snapshot`;
|
|
1293
1318
|
}
|
|
1294
1319
|
//#endregion
|
|
@@ -1782,6 +1807,14 @@ function openVault(db, prepared) {
|
|
|
1782
1807
|
const KEY_BUDGET_MS = 5e3;
|
|
1783
1808
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
1784
1809
|
const LOCK_TIMEOUT_MS = 15e3;
|
|
1810
|
+
/**
|
|
1811
|
+
* How long a statement of a read's decision runs, a wait for a lock
|
|
1812
|
+
* included: a read waits only for what changes its reader, and for the
|
|
1813
|
+
* log's head. Longer is a queue, which holds the read's connection while it
|
|
1814
|
+
* waits; refused, it fails as unavailable at once, and its caller tries
|
|
1815
|
+
* again later.
|
|
1816
|
+
*/
|
|
1817
|
+
const READ_LOCK_TIMEOUT_MS = 3e3;
|
|
1785
1818
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
1786
1819
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
1787
1820
|
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
@@ -1971,7 +2004,8 @@ var VaultService = class {
|
|
|
1971
2004
|
...released === void 0 ? {} : { released: {
|
|
1972
2005
|
...released,
|
|
1973
2006
|
windowMs: this.#config.bulkLimit.windowMs,
|
|
1974
|
-
clockOffsetMs: this.#prepared.options.clockOffset()
|
|
2007
|
+
clockOffsetMs: this.#prepared.options.clockOffset(),
|
|
2008
|
+
cap: this.#config.bulkLimit.count
|
|
1975
2009
|
} }
|
|
1976
2010
|
});
|
|
1977
2011
|
}
|
|
@@ -1986,30 +2020,32 @@ var VaultService = class {
|
|
|
1986
2020
|
* commits: no decision acts on a policy read before it. A key operation
|
|
1987
2021
|
* alone takes the head later (`late`), once its key service has answered,
|
|
1988
2022
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
2023
|
+
* A read (`reads`) changes no member: it takes their rows shared, so
|
|
2024
|
+
* that one member's reads decide side by side (`lockMembers`).
|
|
1989
2025
|
*/
|
|
1990
|
-
async #decide(principals, decide,
|
|
2026
|
+
async #decide(principals, decide, how = {}) {
|
|
1991
2027
|
const superseded = await this.#settled();
|
|
1992
2028
|
if (superseded !== null) return {
|
|
1993
2029
|
ok: false,
|
|
1994
2030
|
refusal: refusal("wrong_kek", superseded)
|
|
1995
2031
|
};
|
|
1996
|
-
return this.#decideNow(principals, decide,
|
|
2032
|
+
return this.#decideNow(principals, decide, how);
|
|
1997
2033
|
}
|
|
1998
2034
|
/** `#decide`, once the vault is settled. */
|
|
1999
|
-
async #decideNow(principals, decide,
|
|
2035
|
+
async #decideNow(principals, decide, how = {}) {
|
|
2000
2036
|
const reports = [];
|
|
2001
2037
|
for (let attempt = 1;; attempt += 1) try {
|
|
2002
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
2038
|
+
return await this.#decideOnce(principals, decide, reports, how);
|
|
2003
2039
|
} catch (error) {
|
|
2004
2040
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
2005
2041
|
throw error;
|
|
2006
2042
|
}
|
|
2007
2043
|
}
|
|
2008
|
-
async #decideOnce(principals, decide, reports, late, asked) {
|
|
2044
|
+
async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
|
|
2009
2045
|
try {
|
|
2010
2046
|
const result = await this.#db.transaction(async (tx) => {
|
|
2011
|
-
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
2012
|
-
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
|
|
2047
|
+
await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
2048
|
+
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
|
|
2013
2049
|
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
2014
2050
|
const d = {
|
|
2015
2051
|
tx,
|
|
@@ -2039,7 +2075,9 @@ var VaultService = class {
|
|
|
2039
2075
|
for (const then of d.after) then((entry) => seqs.get(entry));
|
|
2040
2076
|
}
|
|
2041
2077
|
for (const write of d.writes) await write(at);
|
|
2042
|
-
|
|
2078
|
+
const sealing = /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()]);
|
|
2079
|
+
if (shared && sealing.size > 0) throw new Error("a read changes no member, and seals no row");
|
|
2080
|
+
for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
|
|
2043
2081
|
return result;
|
|
2044
2082
|
});
|
|
2045
2083
|
this.#reported(reports);
|
|
@@ -2615,7 +2653,7 @@ var VaultService = class {
|
|
|
2615
2653
|
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
|
|
2616
2654
|
}
|
|
2617
2655
|
throw new Refused(refusal(code, message), entries);
|
|
2618
|
-
});
|
|
2656
|
+
}, { reads: true });
|
|
2619
2657
|
}
|
|
2620
2658
|
/**
|
|
2621
2659
|
* The data key, or null when it does not open as this secret's: a claim
|
|
@@ -2733,7 +2771,10 @@ var VaultService = class {
|
|
|
2733
2771
|
}))
|
|
2734
2772
|
})
|
|
2735
2773
|
}], [], d.reads)).seqStart };
|
|
2736
|
-
},
|
|
2774
|
+
}, {
|
|
2775
|
+
asked: reads,
|
|
2776
|
+
reads: true
|
|
2777
|
+
});
|
|
2737
2778
|
if (!intent.ok) return intent;
|
|
2738
2779
|
intentSeq = intent.seq;
|
|
2739
2780
|
}
|
|
@@ -2779,13 +2820,19 @@ var VaultService = class {
|
|
|
2779
2820
|
} finally {
|
|
2780
2821
|
for (const value of done) value.wipe();
|
|
2781
2822
|
}
|
|
2782
|
-
},
|
|
2823
|
+
}, {
|
|
2824
|
+
late,
|
|
2825
|
+
asked: reads,
|
|
2826
|
+
reads: true
|
|
2827
|
+
});
|
|
2783
2828
|
}
|
|
2784
2829
|
/**
|
|
2785
2830
|
* Whether this read's keys would take the reader past the bulk limit: the
|
|
2786
2831
|
* distinct keys released to them in the window, these among them. A key
|
|
2787
2832
|
* read again exposes nothing new, so it counts once, whatever its version.
|
|
2788
|
-
* Counted under
|
|
2833
|
+
* Counted under the log's head, which every release appends under, so
|
|
2834
|
+
* exactly: one reader's reads decide side by side under their row, and
|
|
2835
|
+
* each counts the others' releases again once it holds the head (`#keys`).
|
|
2789
2836
|
*/
|
|
2790
2837
|
#overBulkLimit(d) {
|
|
2791
2838
|
return d.reads.released > this.#config.bulkLimit.count;
|
|
@@ -4007,9 +4054,9 @@ var VaultService = class {
|
|
|
4007
4054
|
const { wrongKek, logKeys } = this.#prepared;
|
|
4008
4055
|
const { walks } = this.#prepared.options;
|
|
4009
4056
|
const found = [];
|
|
4010
|
-
let seen
|
|
4057
|
+
let seen;
|
|
4011
4058
|
const resumed = await this.#db.transaction(async (tx) => {
|
|
4012
|
-
seen =
|
|
4059
|
+
seen = await newestCheckpointEntry(tx);
|
|
4013
4060
|
await this.#sweep(tx, found);
|
|
4014
4061
|
const from = await this.#resume(tx, "newest");
|
|
4015
4062
|
if (!from.ok) return from;
|
|
@@ -4061,10 +4108,14 @@ var VaultService = class {
|
|
|
4061
4108
|
reason: scrub.reason
|
|
4062
4109
|
});
|
|
4063
4110
|
const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
|
|
4064
|
-
if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
4111
|
+
if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
|
|
4065
4112
|
const standing = await this.#standingRefusal(d.tx);
|
|
4066
4113
|
if (standing !== null) throw refused("log_broken", standing);
|
|
4067
4114
|
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
4115
|
+
} else if (seen?.decision === "deny" && walked.ok) {
|
|
4116
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
4117
|
+
const named = typeof standing?.failedAtSeq === "number" ? BigInt(standing.failedAtSeq) : null;
|
|
4118
|
+
if (named !== null && named <= walked.from.at.seq && !await this.#entryHolds(d.tx, named)) throw refused("log_broken", standing);
|
|
4068
4119
|
}
|
|
4069
4120
|
if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
4070
4121
|
if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
@@ -4202,6 +4253,30 @@ var VaultService = class {
|
|
|
4202
4253
|
};
|
|
4203
4254
|
}
|
|
4204
4255
|
/**
|
|
4256
|
+
* Whether entry `seq` is as the walk checks each: there, its hash its
|
|
4257
|
+
* own, linked to the entry before, and the vault's under its MAC; and the
|
|
4258
|
+
* entry after it, if any, linked to it, for a refusal can name either side
|
|
4259
|
+
* of a broken link (`forward`, at the end of a batch).
|
|
4260
|
+
*/
|
|
4261
|
+
async #entryHolds(db, seq) {
|
|
4262
|
+
const found = await entriesAt(db, seq === 0n ? [seq, seq + 1n] : [
|
|
4263
|
+
seq - 1n,
|
|
4264
|
+
seq,
|
|
4265
|
+
seq + 1n
|
|
4266
|
+
]);
|
|
4267
|
+
const entry = found.get(seq);
|
|
4268
|
+
const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
|
|
4269
|
+
if (entry === void 0 || before === void 0) return false;
|
|
4270
|
+
const after = found.get(seq + 1n);
|
|
4271
|
+
if (after !== void 0 && !after.prevHash.equals(entry.hash)) return false;
|
|
4272
|
+
return verifyEntries([entry], {
|
|
4273
|
+
keys: this.#keysFor(entry),
|
|
4274
|
+
chainOnly: ["app"],
|
|
4275
|
+
startSeq: seq,
|
|
4276
|
+
startPrevHash: before.hash
|
|
4277
|
+
}).ok;
|
|
4278
|
+
}
|
|
4279
|
+
/**
|
|
4205
4280
|
* The newest refusal for a broken log since the newest checkpoint was
|
|
4206
4281
|
* signed, by the vault's MAC, as it said: it stands, for every
|
|
4207
4282
|
* verification and checkpoint, until a checkpoint is signed, which takes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
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.4",
|
|
40
|
+
"@coffre/db": "0.14.4"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
|
|
1
|
+
import { derivedSigningKey, KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
|
|
4
2
|
|
|
5
3
|
/** At most `count` distinct data keys unwrapped per principal in any `windowMs`. */
|
|
6
4
|
export type BulkLimit = { count: number; windowMs: number };
|
|
@@ -108,13 +106,7 @@ const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
|
108
106
|
/** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
|
|
109
107
|
const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
|
|
110
108
|
|
|
111
|
-
|
|
112
|
-
const SIGNING_KEY_LABEL = 'coffre.vault.signing-key.v1';
|
|
113
|
-
|
|
114
|
-
/** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
|
|
115
|
-
export function derivedSigningKey(kek: Uint8Array): Buffer {
|
|
116
|
-
return Buffer.from(hkdfSync('sha256', kek, new Uint8Array(0), SIGNING_KEY_LABEL, 32));
|
|
117
|
-
}
|
|
109
|
+
export { derivedSigningKey };
|
|
118
110
|
|
|
119
111
|
/** A KEK as the registry takes it, and the signing key it stands for when it is one the vault holds. */
|
|
120
112
|
function kek(entry: Kek): { provider: KekProvider; signingKey: Buffer | null } {
|
package/src/snapshot.postgres.ts
CHANGED
|
@@ -82,16 +82,15 @@ export function snapshotQuery(asked: Asked): SQL {
|
|
|
82
82
|
'released', ${released === undefined
|
|
83
83
|
? sql`0`
|
|
84
84
|
: sql`(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)`}
|
|
85
|
+
-- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
|
|
86
|
+
-- principal was ever released, however many reads of them the log holds (vault_releases, which the database
|
|
87
|
+
-- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
|
|
88
|
+
SELECT count(*) FROM (
|
|
89
|
+
SELECT 1 FROM vault_releases
|
|
90
|
+
WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW} + ${released.clockOffsetMs} - ${released.windowMs})
|
|
91
|
+
AND key <> ALL(${sql.param([...released.keys])}::text[])
|
|
92
|
+
LIMIT ${released.cap + 1}
|
|
93
|
+
) AS before
|
|
94
|
+
) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
|
|
96
95
|
) AS snapshot`;
|
|
97
96
|
}
|
package/src/snapshot.sqlite.ts
CHANGED
|
@@ -88,16 +88,13 @@ export function snapshotQuery(asked: Asked): SQL {
|
|
|
88
88
|
'released', ${released === undefined
|
|
89
89
|
? sql`0`
|
|
90
90
|
: sql`(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
98
|
-
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
99
|
-
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
91
|
+
-- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
|
|
92
|
+
SELECT count(*) FROM (
|
|
93
|
+
SELECT 1 FROM vault_releases
|
|
94
|
+
WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
95
|
+
AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
|
|
96
|
+
LIMIT ${released.cap + 1}
|
|
100
97
|
)
|
|
101
|
-
)`}
|
|
98
|
+
) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
|
|
102
99
|
) AS snapshot`;
|
|
103
100
|
}
|
package/src/snapshot.ts
CHANGED
|
@@ -49,7 +49,9 @@ export type Snapshot = {
|
|
|
49
49
|
/**
|
|
50
50
|
* How many distinct keys `asked.released.principal` holds released within
|
|
51
51
|
* its window once it is given `asked.released.keys` too: a key released
|
|
52
|
-
* again, or asked for again, counts once.
|
|
52
|
+
* again, or asked for again, counts once. Those released before are
|
|
53
|
+
* counted up to one past `asked.released.cap`, which is enough to say
|
|
54
|
+
* whether the read goes past it.
|
|
53
55
|
*/
|
|
54
56
|
released: number;
|
|
55
57
|
/** The log's head, locked, with the last entry and the newest `key.rotate`; null unless asked, or held by another. */
|
|
@@ -74,9 +76,11 @@ export type Asked = {
|
|
|
74
76
|
references?: readonly bigint[];
|
|
75
77
|
/**
|
|
76
78
|
* One principal's releases within the last `windowMs` of the clock, moved
|
|
77
|
-
* on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides
|
|
79
|
+
* on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides;
|
|
80
|
+
* read from `vault_releases`, which the database keeps from the log, up to
|
|
81
|
+
* `cap` + 1 of them.
|
|
78
82
|
*/
|
|
79
|
-
released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number };
|
|
83
|
+
released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number; cap: number };
|
|
80
84
|
/** Lock the log's head first. */
|
|
81
85
|
head?: boolean;
|
|
82
86
|
};
|
package/src/store.ts
CHANGED
|
@@ -53,10 +53,18 @@ export { newestVaultEntries } from '@coffre/db/log';
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
56
|
-
* wait for ever behind a transaction that never ends
|
|
56
|
+
* wait for ever behind a transaction that never ends; with `statements`,
|
|
57
|
+
* any statement that takes longer, too. A queue for a row each holder
|
|
58
|
+
* updates, as the log's head, is no one wait: a waiter follows each update
|
|
59
|
+
* to the row's new version and waits again, its lock's clock started over,
|
|
60
|
+
* so only the statement's own bound ends it.
|
|
57
61
|
*/
|
|
58
|
-
export async function boundLockWaits(tx: Transaction, ms: number): Promise<void> {
|
|
59
|
-
if (engineOf(tx)
|
|
62
|
+
export async function boundLockWaits(tx: Transaction, ms: number, statements = false): Promise<void> {
|
|
63
|
+
if (engineOf(tx) !== 'postgres') return;
|
|
64
|
+
const bound = String(Math.trunc(ms));
|
|
65
|
+
await tx.execute(statements
|
|
66
|
+
? sql`SELECT set_config('lock_timeout', ${bound}, true), set_config('statement_timeout', ${bound}, true)`
|
|
67
|
+
: sql.raw(`SET LOCAL lock_timeout = ${bound}`));
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/** The database's clock, now. */
|
|
@@ -136,22 +144,45 @@ export async function instanceOwners(db: Queryable): Promise<Member[]> {
|
|
|
136
144
|
* Lock these members' rows for the rest of the transaction, in one order
|
|
137
145
|
* everywhere, and read them: those that have none are not in the map. Every
|
|
138
146
|
* decision about a member takes its row first, so two of them, from any
|
|
139
|
-
* process, never interleave
|
|
147
|
+
* process, never interleave; but `shared`, for a decision that writes no
|
|
148
|
+
* member's row, as a read, takes them as other such decisions do, side by
|
|
149
|
+
* side, and only what changes the member waits for it, and it for that.
|
|
150
|
+
*
|
|
151
|
+
* Postgres lets a share lock join those already held even while a writer
|
|
152
|
+
* waits for them, so a steady stream of one member's reads would hold a
|
|
153
|
+
* removal off for ever. So each row comes with an advisory lock on its
|
|
154
|
+
* principal, which the lock manager queues in order: a read asked after a
|
|
155
|
+
* writer waits behind it. The same statement takes it, then the row, one
|
|
156
|
+
* principal after another.
|
|
140
157
|
*/
|
|
141
|
-
export async function lockMembers(tx: Transaction, principals: readonly string[]): Promise<Map<string, Member>> {
|
|
158
|
+
export async function lockMembers(tx: Transaction, principals: readonly string[], shared = false): Promise<Map<string, Member>> {
|
|
142
159
|
const { vaultMembers } = tablesOf(tx);
|
|
160
|
+
const queued: Record<string, SQL> = engineOf(tx) === 'postgres'
|
|
161
|
+
? { queued: sql`${sql.raw(shared ? 'pg_advisory_xact_lock_shared' : 'pg_advisory_xact_lock')}(${MEMBER_QUEUE}, hashtext(${vaultMembers.principal}))` }
|
|
162
|
+
: {};
|
|
143
163
|
const rows = await forUpdate(
|
|
144
164
|
tx,
|
|
145
165
|
tx
|
|
146
|
-
.select(memberColumns(tx))
|
|
166
|
+
.select({ ...memberColumns(tx), ...queued })
|
|
147
167
|
.from(vaultMembers)
|
|
148
168
|
.where(inArray(vaultMembers.principal, [...new Set(principals)]))
|
|
149
169
|
.orderBy(asc(vaultMembers.principal)),
|
|
150
|
-
'no key update',
|
|
170
|
+
shared ? 'share' : 'no key update',
|
|
151
171
|
);
|
|
152
|
-
return new Map((rows as StoredMember[]).map((row) => [row.principal, memberOf(row)]));
|
|
172
|
+
return new Map((rows as (StoredMember & { queued?: unknown })[]).map(({ queued: _, ...row }) => [row.principal, memberOf(row)]));
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The first key of the members' queues, Postgres's advisory locks: the
|
|
177
|
+
* second is the principal's hash, and the pair is in a space of its own,
|
|
178
|
+
* apart from the migrator's single key. A hash two principals share queues
|
|
179
|
+
* one behind the other's writer; and two changes that each lock two
|
|
180
|
+
* members, where one's first and the other's second share it, can wait on
|
|
181
|
+
* each other, which Postgres ends after a second by failing one of them
|
|
182
|
+
* (40P01): about n² / 2³³ for an instance of n members.
|
|
183
|
+
*/
|
|
184
|
+
const MEMBER_QUEUE = 0x636f666d;
|
|
185
|
+
|
|
155
186
|
/**
|
|
156
187
|
* Add `row`. A principal has one row: when two decisions admit the same
|
|
157
188
|
* one at once, the second fails here, and rolls back with its entries.
|
package/src/vault.ts
CHANGED
|
@@ -203,6 +203,15 @@ const KEY_BUDGET_MS = 5_000;
|
|
|
203
203
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
204
204
|
const LOCK_TIMEOUT_MS = 15_000;
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* How long a statement of a read's decision runs, a wait for a lock
|
|
208
|
+
* included: a read waits only for what changes its reader, and for the
|
|
209
|
+
* log's head. Longer is a queue, which holds the read's connection while it
|
|
210
|
+
* waits; refused, it fails as unavailable at once, and its caller tries
|
|
211
|
+
* again later.
|
|
212
|
+
*/
|
|
213
|
+
const READ_LOCK_TIMEOUT_MS = 3_000;
|
|
214
|
+
|
|
206
215
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
207
216
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
208
217
|
|
|
@@ -392,6 +401,13 @@ type Decision = {
|
|
|
392
401
|
*/
|
|
393
402
|
type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
|
|
394
403
|
|
|
404
|
+
/**
|
|
405
|
+
* How a decision runs: `late` takes the log's head only to append, `asked`
|
|
406
|
+
* is what it reads besides its members, and `reads` says it changes none
|
|
407
|
+
* of them (`#decide`).
|
|
408
|
+
*/
|
|
409
|
+
type How = { late?: boolean; asked?: Reads; reads?: boolean };
|
|
410
|
+
|
|
395
411
|
/** The actions of the entries the vault writes about keys. */
|
|
396
412
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
397
413
|
|
|
@@ -482,7 +498,7 @@ class VaultService implements Vault {
|
|
|
482
498
|
...rest,
|
|
483
499
|
principals,
|
|
484
500
|
head,
|
|
485
|
-
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
|
|
501
|
+
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
|
|
486
502
|
});
|
|
487
503
|
}
|
|
488
504
|
|
|
@@ -497,19 +513,21 @@ class VaultService implements Vault {
|
|
|
497
513
|
* commits: no decision acts on a policy read before it. A key operation
|
|
498
514
|
* alone takes the head later (`late`), once its key service has answered,
|
|
499
515
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
516
|
+
* A read (`reads`) changes no member: it takes their rows shared, so
|
|
517
|
+
* that one member's reads decide side by side (`lockMembers`).
|
|
500
518
|
*/
|
|
501
|
-
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>,
|
|
519
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
|
|
502
520
|
const superseded = await this.#settled();
|
|
503
521
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
504
|
-
return this.#decideNow(principals, decide,
|
|
522
|
+
return this.#decideNow(principals, decide, how);
|
|
505
523
|
}
|
|
506
524
|
|
|
507
525
|
/** `#decide`, once the vault is settled. */
|
|
508
|
-
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>,
|
|
526
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
|
|
509
527
|
const reports: NewEntry[] = [];
|
|
510
528
|
for (let attempt = 1; ; attempt += 1) {
|
|
511
529
|
try {
|
|
512
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
530
|
+
return await this.#decideOnce(principals, decide, reports, how);
|
|
513
531
|
} catch (error) {
|
|
514
532
|
// A member who had no row when this decision locked theirs, and has
|
|
515
533
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -519,11 +537,11 @@ class VaultService implements Vault {
|
|
|
519
537
|
}
|
|
520
538
|
}
|
|
521
539
|
|
|
522
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late
|
|
540
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
|
|
523
541
|
try {
|
|
524
542
|
const result = await this.#db.transaction(async (tx) => {
|
|
525
|
-
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
526
|
-
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
543
|
+
await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
544
|
+
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
|
|
527
545
|
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
528
546
|
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
529
547
|
const d: Decision = {
|
|
@@ -547,9 +565,10 @@ class VaultService implements Vault {
|
|
|
547
565
|
for (const then of d.after) then((entry) => seqs.get(entry)!);
|
|
548
566
|
}
|
|
549
567
|
for (const write of d.writes) await write(at);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
568
|
+
const sealing = new Set([...d.touched, ...accessSeq.keys()]);
|
|
569
|
+
// Under a share lock, a write to the row would wait for every other read holding it, and they for it.
|
|
570
|
+
if (shared && sealing.size > 0) throw new Error('a read changes no member, and seals no row');
|
|
571
|
+
for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
|
|
553
572
|
return result;
|
|
554
573
|
});
|
|
555
574
|
this.#reported(reports);
|
|
@@ -558,6 +577,7 @@ class VaultService implements Vault {
|
|
|
558
577
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
559
578
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
560
579
|
if (entries.length > 0) {
|
|
580
|
+
// A refusal's entries are written whatever the decision's bound: it answers only once they are.
|
|
561
581
|
await this.#db.transaction(async (tx) => {
|
|
562
582
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
563
583
|
await this.#append(tx, entries);
|
|
@@ -1148,7 +1168,7 @@ class VaultService implements Vault {
|
|
|
1148
1168
|
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
|
|
1149
1169
|
}
|
|
1150
1170
|
throw new Refused(refusal(code, message), entries);
|
|
1151
|
-
});
|
|
1171
|
+
}, { reads: true });
|
|
1152
1172
|
}
|
|
1153
1173
|
|
|
1154
1174
|
/**
|
|
@@ -1299,12 +1319,13 @@ class VaultService implements Vault {
|
|
|
1299
1319
|
}),
|
|
1300
1320
|
}], [], d.reads);
|
|
1301
1321
|
return { seq: appended.seqStart };
|
|
1302
|
-
},
|
|
1322
|
+
}, { asked: reads, reads: true });
|
|
1303
1323
|
if (!intent.ok) return intent;
|
|
1304
1324
|
intentSeq = intent.seq;
|
|
1305
1325
|
}
|
|
1306
1326
|
|
|
1307
|
-
// Under the reader's row
|
|
1327
|
+
// Under the reader's row, which their other reads share and a change to them waits for: the check, the key
|
|
1328
|
+
// operations, and their outcome. With a key in memory, under the
|
|
1308
1329
|
// log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
|
|
1309
1330
|
// reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
|
|
1310
1331
|
// holding the head, the head is taken once the keys are open, and what the check read is read again under it.
|
|
@@ -1377,14 +1398,16 @@ class VaultService implements Vault {
|
|
|
1377
1398
|
} finally {
|
|
1378
1399
|
for (const value of done) value.wipe();
|
|
1379
1400
|
}
|
|
1380
|
-
}, late, reads);
|
|
1401
|
+
}, { late, asked: reads, reads: true });
|
|
1381
1402
|
}
|
|
1382
1403
|
|
|
1383
1404
|
/**
|
|
1384
1405
|
* Whether this read's keys would take the reader past the bulk limit: the
|
|
1385
1406
|
* distinct keys released to them in the window, these among them. A key
|
|
1386
1407
|
* read again exposes nothing new, so it counts once, whatever its version.
|
|
1387
|
-
* Counted under
|
|
1408
|
+
* Counted under the log's head, which every release appends under, so
|
|
1409
|
+
* exactly: one reader's reads decide side by side under their row, and
|
|
1410
|
+
* each counts the others' releases again once it holds the head (`#keys`).
|
|
1388
1411
|
*/
|
|
1389
1412
|
#overBulkLimit(d: Decision): boolean {
|
|
1390
1413
|
return d.reads.released > this.#config.bulkLimit.count;
|
|
@@ -2555,9 +2578,9 @@ class VaultService implements Vault {
|
|
|
2555
2578
|
const { walks } = this.#prepared.options;
|
|
2556
2579
|
const found: NewEntry[] = [];
|
|
2557
2580
|
// The newest checkpoint entry, signed or refused, as this call's snapshot found it.
|
|
2558
|
-
let seen:
|
|
2581
|
+
let seen: StoredEntry | undefined;
|
|
2559
2582
|
const resumed = await this.#db.transaction(async (tx) => {
|
|
2560
|
-
seen =
|
|
2583
|
+
seen = await store.newestCheckpointEntry(tx);
|
|
2561
2584
|
await this.#sweep(tx, found);
|
|
2562
2585
|
const from = await this.#resume(tx, 'newest');
|
|
2563
2586
|
if (!from.ok) return from;
|
|
@@ -2602,10 +2625,17 @@ class VaultService implements Vault {
|
|
|
2602
2625
|
// Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
|
|
2603
2626
|
// could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
|
|
2604
2627
|
// and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
|
|
2605
|
-
if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
2628
|
+
if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
|
|
2606
2629
|
const standing = await this.#standingRefusal(d.tx);
|
|
2607
2630
|
if (standing !== null) throw refused('log_broken', standing);
|
|
2608
2631
|
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
2632
|
+
} else if (seen?.decision === 'deny' && walked.ok) {
|
|
2633
|
+
// A refusal for a broken log stands since this call's snapshot, of an entry before where its walk began, which
|
|
2634
|
+
// it never read: that entry again, as the walk checks each, before anything is signed over it. Still broken,
|
|
2635
|
+
// the refusal stands; whole again, the signature clears it (T104).
|
|
2636
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
2637
|
+
const named = typeof standing?.failedAtSeq === 'number' ? BigInt(standing.failedAtSeq) : null;
|
|
2638
|
+
if (named !== null && named <= walked.from.at.seq && !(await this.#entryHolds(d.tx, named))) throw refused('log_broken', standing!);
|
|
2609
2639
|
}
|
|
2610
2640
|
// Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
|
|
2611
2641
|
if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
@@ -2699,6 +2729,22 @@ class VaultService implements Vault {
|
|
|
2699
2729
|
return { ok: true, next: null };
|
|
2700
2730
|
}
|
|
2701
2731
|
|
|
2732
|
+
/**
|
|
2733
|
+
* Whether entry `seq` is as the walk checks each: there, its hash its
|
|
2734
|
+
* own, linked to the entry before, and the vault's under its MAC; and the
|
|
2735
|
+
* entry after it, if any, linked to it, for a refusal can name either side
|
|
2736
|
+
* of a broken link (`forward`, at the end of a batch).
|
|
2737
|
+
*/
|
|
2738
|
+
async #entryHolds(db: Queryable, seq: bigint): Promise<boolean> {
|
|
2739
|
+
const found = await store.entriesAt(db, seq === 0n ? [seq, seq + 1n] : [seq - 1n, seq, seq + 1n]);
|
|
2740
|
+
const entry = found.get(seq);
|
|
2741
|
+
const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
|
|
2742
|
+
if (entry === undefined || before === undefined) return false;
|
|
2743
|
+
const after = found.get(seq + 1n);
|
|
2744
|
+
if (after !== undefined && !after.prevHash.equals(entry.hash)) return false;
|
|
2745
|
+
return verifyEntries([entry], { keys: this.#keysFor(entry), chainOnly: ['app'], startSeq: seq, startPrevHash: before.hash }).ok;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2702
2748
|
/**
|
|
2703
2749
|
* The newest refusal for a broken log since the newest checkpoint was
|
|
2704
2750
|
* signed, by the vault's MAC, as it said: it stands, for every
|