@coffre/vault 0.14.2 → 0.14.3
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-B4IivgU8.js} +70 -20
- package/package.json +3 -3
- package/src/config.ts +2 -10
- package/src/store.ts +39 -8
- package/src/vault.ts +38 -15
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-B4IivgU8.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-B4IivgU8.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
|
*/
|
|
@@ -1782,6 +1811,14 @@ function openVault(db, prepared) {
|
|
|
1782
1811
|
const KEY_BUDGET_MS = 5e3;
|
|
1783
1812
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
1784
1813
|
const LOCK_TIMEOUT_MS = 15e3;
|
|
1814
|
+
/**
|
|
1815
|
+
* How long a statement of a read's decision runs, a wait for a lock
|
|
1816
|
+
* included: a read waits only for what changes its reader, and for the
|
|
1817
|
+
* log's head. Longer is a queue, which holds the read's connection while it
|
|
1818
|
+
* waits; refused, it fails as unavailable at once, and its caller tries
|
|
1819
|
+
* again later.
|
|
1820
|
+
*/
|
|
1821
|
+
const READ_LOCK_TIMEOUT_MS = 3e3;
|
|
1785
1822
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
1786
1823
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
1787
1824
|
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
@@ -1986,30 +2023,32 @@ var VaultService = class {
|
|
|
1986
2023
|
* commits: no decision acts on a policy read before it. A key operation
|
|
1987
2024
|
* alone takes the head later (`late`), once its key service has answered,
|
|
1988
2025
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
2026
|
+
* A read (`reads`) changes no member: it takes their rows shared, so
|
|
2027
|
+
* that one member's reads decide side by side (`lockMembers`).
|
|
1989
2028
|
*/
|
|
1990
|
-
async #decide(principals, decide,
|
|
2029
|
+
async #decide(principals, decide, how = {}) {
|
|
1991
2030
|
const superseded = await this.#settled();
|
|
1992
2031
|
if (superseded !== null) return {
|
|
1993
2032
|
ok: false,
|
|
1994
2033
|
refusal: refusal("wrong_kek", superseded)
|
|
1995
2034
|
};
|
|
1996
|
-
return this.#decideNow(principals, decide,
|
|
2035
|
+
return this.#decideNow(principals, decide, how);
|
|
1997
2036
|
}
|
|
1998
2037
|
/** `#decide`, once the vault is settled. */
|
|
1999
|
-
async #decideNow(principals, decide,
|
|
2038
|
+
async #decideNow(principals, decide, how = {}) {
|
|
2000
2039
|
const reports = [];
|
|
2001
2040
|
for (let attempt = 1;; attempt += 1) try {
|
|
2002
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
2041
|
+
return await this.#decideOnce(principals, decide, reports, how);
|
|
2003
2042
|
} catch (error) {
|
|
2004
2043
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
2005
2044
|
throw error;
|
|
2006
2045
|
}
|
|
2007
2046
|
}
|
|
2008
|
-
async #decideOnce(principals, decide, reports, late, asked) {
|
|
2047
|
+
async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
|
|
2009
2048
|
try {
|
|
2010
2049
|
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);
|
|
2050
|
+
await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
2051
|
+
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
|
|
2013
2052
|
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
2014
2053
|
const d = {
|
|
2015
2054
|
tx,
|
|
@@ -2039,7 +2078,9 @@ var VaultService = class {
|
|
|
2039
2078
|
for (const then of d.after) then((entry) => seqs.get(entry));
|
|
2040
2079
|
}
|
|
2041
2080
|
for (const write of d.writes) await write(at);
|
|
2042
|
-
|
|
2081
|
+
const sealing = /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()]);
|
|
2082
|
+
if (shared && sealing.size > 0) throw new Error("a read changes no member, and seals no row");
|
|
2083
|
+
for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
|
|
2043
2084
|
return result;
|
|
2044
2085
|
});
|
|
2045
2086
|
this.#reported(reports);
|
|
@@ -2615,7 +2656,7 @@ var VaultService = class {
|
|
|
2615
2656
|
if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
|
|
2616
2657
|
}
|
|
2617
2658
|
throw new Refused(refusal(code, message), entries);
|
|
2618
|
-
});
|
|
2659
|
+
}, { reads: true });
|
|
2619
2660
|
}
|
|
2620
2661
|
/**
|
|
2621
2662
|
* The data key, or null when it does not open as this secret's: a claim
|
|
@@ -2733,7 +2774,10 @@ var VaultService = class {
|
|
|
2733
2774
|
}))
|
|
2734
2775
|
})
|
|
2735
2776
|
}], [], d.reads)).seqStart };
|
|
2736
|
-
},
|
|
2777
|
+
}, {
|
|
2778
|
+
asked: reads,
|
|
2779
|
+
reads: true
|
|
2780
|
+
});
|
|
2737
2781
|
if (!intent.ok) return intent;
|
|
2738
2782
|
intentSeq = intent.seq;
|
|
2739
2783
|
}
|
|
@@ -2779,13 +2823,19 @@ var VaultService = class {
|
|
|
2779
2823
|
} finally {
|
|
2780
2824
|
for (const value of done) value.wipe();
|
|
2781
2825
|
}
|
|
2782
|
-
},
|
|
2826
|
+
}, {
|
|
2827
|
+
late,
|
|
2828
|
+
asked: reads,
|
|
2829
|
+
reads: true
|
|
2830
|
+
});
|
|
2783
2831
|
}
|
|
2784
2832
|
/**
|
|
2785
2833
|
* Whether this read's keys would take the reader past the bulk limit: the
|
|
2786
2834
|
* distinct keys released to them in the window, these among them. A key
|
|
2787
2835
|
* read again exposes nothing new, so it counts once, whatever its version.
|
|
2788
|
-
* Counted under
|
|
2836
|
+
* Counted under the log's head, which every release appends under, so
|
|
2837
|
+
* exactly: one reader's reads decide side by side under their row, and
|
|
2838
|
+
* each counts the others' releases again once it holds the head (`#keys`).
|
|
2789
2839
|
*/
|
|
2790
2840
|
#overBulkLimit(d) {
|
|
2791
2841
|
return d.reads.released > this.#config.bulkLimit.count;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
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.3",
|
|
40
|
+
"@coffre/db": "0.14.3"
|
|
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/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
|
|
|
@@ -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;
|