@coffre/vault 0.14.3 → 0.14.5
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 +2 -5
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- 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-NM5NsYFs.js → src-Bg0ZeMJW.js} +9 -2
- package/dist/{vault-B4IivgU8.js → vault-Cbi45kLB.js} +194 -49
- package/package.json +3 -3
- package/src/config.ts +14 -3
- package/src/snapshot.postgres.ts +10 -11
- package/src/snapshot.sqlite.ts +7 -10
- package/src/snapshot.ts +15 -5
- package/src/store.ts +30 -1
- package/src/vault.ts +176 -26
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-DQ-FKVF3.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";
|
|
@@ -16,10 +16,7 @@ export type WorkersVaultConfig = VaultConfig & {
|
|
|
16
16
|
*/
|
|
17
17
|
export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
18
18
|
#private;
|
|
19
|
-
unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<
|
|
20
|
-
keys: string[];
|
|
21
|
-
accessRevision?: string;
|
|
22
|
-
}>>;
|
|
19
|
+
unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Unwrapped>>;
|
|
23
20
|
wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
|
24
21
|
rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
|
|
25
22
|
reference(input: ReferenceInput): Promise<import("@coffre/core/vault").Outcome<{
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as verifyCheckpoint,
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
1
|
+
import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
@@ -36,7 +36,10 @@ type VaultConfig = {
|
|
|
36
36
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
37
37
|
*/
|
|
38
38
|
signingKey?: string;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
|
|
41
|
+
* window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
|
|
42
|
+
*/
|
|
40
43
|
bulkLimit?: {
|
|
41
44
|
count: number;
|
|
42
45
|
windowMinutes: number;
|
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-DQ-FKVF3.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,
|
|
1
|
+
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.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-DQ-FKVF3.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,
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
1
|
+
import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.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";
|
|
@@ -11,6 +11,13 @@ const DEFAULT_BULK_LIMIT = {
|
|
|
11
11
|
count: 1e3,
|
|
12
12
|
windowMs: 9e5
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* The longest bulk limit window: a day. The releases a window counts are
|
|
16
|
+
* kept a day and an hour (migration 0021's `prune_vault_releases`, and on
|
|
17
|
+
* SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
|
|
18
|
+
* since, counts exactly.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_BULK_WINDOW_MINUTES = 1440;
|
|
14
21
|
function key32(value, what) {
|
|
15
22
|
const raw = Buffer.from(value, "base64");
|
|
16
23
|
if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
|
|
@@ -76,7 +83,7 @@ function signingKeys(given, keks) {
|
|
|
76
83
|
return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
|
|
77
84
|
}
|
|
78
85
|
function checkBulkLimit({ count, windowMinutes }) {
|
|
79
|
-
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) throw new Error(
|
|
86
|
+
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > 1440) throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
|
|
80
87
|
return {
|
|
81
88
|
count,
|
|
82
89
|
windowMs: windowMinutes * 6e4
|
|
@@ -105,4 +112,4 @@ function checkOwners(emails) {
|
|
|
105
112
|
return [...new Set(owners.map((entry) => entry.toLowerCase()))];
|
|
106
113
|
}
|
|
107
114
|
//#endregion
|
|
108
|
-
export { verifyCheckpoint as a, checkpointMessage$1 as i, KekUnavailableError$1 as n,
|
|
115
|
+
export { verifyCheckpoint as a, resolveVaultConfig as c, checkpointMessage$1 as i, KekUnavailableError$1 as n, MAX_BULK_WINDOW_MINUTES as o, awsKms as r, derivedSigningKey as s, KekBadClaimError$1 as t };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { o as MAX_BULK_WINDOW_MINUTES } from "./src-Bg0ZeMJW.js";
|
|
2
|
+
import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, USE_METADATA_LENGTH, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
|
|
2
3
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
4
|
import { prepared, tablesOf } from "@coffre/db";
|
|
4
5
|
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -6,9 +7,9 @@ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_RO
|
|
|
6
7
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
8
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
8
9
|
import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
|
|
9
|
-
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
|
+
import { SNAPSHOT, changedRows, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
|
|
10
11
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
-
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
|
+
import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
|
|
12
13
|
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
13
14
|
//#region src/checkpoint.ts
|
|
14
15
|
const PKCS8_ED25519 = [
|
|
@@ -189,6 +190,33 @@ async function boundLockWaits(tx, ms, statements = false) {
|
|
|
189
190
|
const bound = String(Math.trunc(ms));
|
|
190
191
|
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}`));
|
|
191
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* How long a release is kept: the longest bulk limit window
|
|
195
|
+
* (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
|
|
196
|
+
* database's clock stepping back between a prune and a later count, which
|
|
197
|
+
* is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
|
|
198
|
+
* and an hour, in its own SQL: a longer window takes a migration too.
|
|
199
|
+
*/
|
|
200
|
+
const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 6e4 + 36e5;
|
|
201
|
+
/**
|
|
202
|
+
* Delete up to `atMost` rows of `vault_releases` released over
|
|
203
|
+
* `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
|
|
204
|
+
* read again. On Postgres the vault's login deletes no row itself, and asks
|
|
205
|
+
* `prune_vault_releases`, which deletes only those (migration 0021). How
|
|
206
|
+
* many went.
|
|
207
|
+
*/
|
|
208
|
+
async function pruneReleases(db, atMost) {
|
|
209
|
+
if (engineOf(db) === "postgres") {
|
|
210
|
+
const [row] = await rowsOf(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
|
|
211
|
+
return row.pruned;
|
|
212
|
+
}
|
|
213
|
+
const { vaultReleases } = tablesOf(db);
|
|
214
|
+
const old = db.select({
|
|
215
|
+
principal: vaultReleases.principal,
|
|
216
|
+
key: vaultReleases.key
|
|
217
|
+
}).from(vaultReleases).where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
|
|
218
|
+
return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
|
|
219
|
+
}
|
|
192
220
|
/** The database's clock, now. */
|
|
193
221
|
async function now(db) {
|
|
194
222
|
const [{ at }] = await clock(db);
|
|
@@ -1216,17 +1244,16 @@ function snapshotQuery$1(asked) {
|
|
|
1216
1244
|
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
|
|
1217
1245
|
),
|
|
1218
1246
|
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
)`}
|
|
1247
|
+
-- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
|
|
1248
|
+
-- principal was ever released, however many reads of them the log holds (vault_releases, which the database
|
|
1249
|
+
-- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
|
|
1250
|
+
SELECT count(*) FROM (
|
|
1251
|
+
SELECT 1 FROM vault_releases
|
|
1252
|
+
WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs})
|
|
1253
|
+
AND key <> ALL(${sql.param([...released.keys])}::text[])
|
|
1254
|
+
LIMIT ${released.cap + 1}
|
|
1255
|
+
) AS before
|
|
1256
|
+
) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
|
|
1230
1257
|
) AS snapshot`;
|
|
1231
1258
|
}
|
|
1232
1259
|
//#endregion
|
|
@@ -1307,17 +1334,14 @@ function snapshotQuery(asked) {
|
|
|
1307
1334
|
WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
|
|
1308
1335
|
)),
|
|
1309
1336
|
'released', ${released === void 0 ? sql`0` : sql`(
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
|
|
1317
|
-
AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1318
|
-
UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
|
|
1337
|
+
-- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
|
|
1338
|
+
SELECT count(*) FROM (
|
|
1339
|
+
SELECT 1 FROM vault_releases
|
|
1340
|
+
WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
|
|
1341
|
+
AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
|
|
1342
|
+
LIMIT ${released.cap + 1}
|
|
1319
1343
|
)
|
|
1320
|
-
)`}
|
|
1344
|
+
) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
|
|
1321
1345
|
) AS snapshot`;
|
|
1322
1346
|
}
|
|
1323
1347
|
//#endregion
|
|
@@ -1326,7 +1350,7 @@ async function snapshot(db, asked) {
|
|
|
1326
1350
|
const read = () => readOnce(db, asked);
|
|
1327
1351
|
const first = await read();
|
|
1328
1352
|
if (asked.head !== true || first.head !== null && current(first.head)) return first;
|
|
1329
|
-
if (first.head === null) await lockLogHead(db);
|
|
1353
|
+
if (first.head === null) await (asked.waiters === void 0 ? lockLogHead(db) : lockLogHeadQueued(db, asked.waiters));
|
|
1330
1354
|
const again = await read();
|
|
1331
1355
|
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
1332
1356
|
if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
|
|
@@ -1819,6 +1843,31 @@ const LOCK_TIMEOUT_MS = 15e3;
|
|
|
1819
1843
|
* again later.
|
|
1820
1844
|
*/
|
|
1821
1845
|
const READ_LOCK_TIMEOUT_MS = 3e3;
|
|
1846
|
+
/**
|
|
1847
|
+
* How long a whole read call may take, from its start to its answer, its
|
|
1848
|
+
* refusal's entries, the record of a vault key's check and, with a key
|
|
1849
|
+
* service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
|
|
1850
|
+
* past it the call fails as unavailable (`Overdue`) rather than start
|
|
1851
|
+
* another. So a caller hears within it, ok or 503, never tens of seconds later.
|
|
1852
|
+
*/
|
|
1853
|
+
const READ_DEADLINE_MS = 5e3;
|
|
1854
|
+
/** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
|
|
1855
|
+
const RELEASES_PRUNED = 1e4;
|
|
1856
|
+
/** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
|
|
1857
|
+
var Overdue = class extends Error {
|
|
1858
|
+
code = "57014";
|
|
1859
|
+
constructor() {
|
|
1860
|
+
super("this read took longer than it may: ask again shortly");
|
|
1861
|
+
this.name = "Overdue";
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
/** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
|
|
1865
|
+
function within(deadline, ms) {
|
|
1866
|
+
if (deadline === void 0) return ms;
|
|
1867
|
+
const left = deadline - Date.now();
|
|
1868
|
+
if (left <= 0) throw new Overdue();
|
|
1869
|
+
return Math.min(ms, left);
|
|
1870
|
+
}
|
|
1822
1871
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
1823
1872
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
1824
1873
|
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
@@ -1999,16 +2048,18 @@ var VaultService = class {
|
|
|
1999
2048
|
return reads.at + this.#prepared.options.clockOffset();
|
|
2000
2049
|
}
|
|
2001
2050
|
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
2002
|
-
#snapshot(tx, principals, asked, head) {
|
|
2051
|
+
#snapshot(tx, principals, asked, head, reading = false) {
|
|
2003
2052
|
const { released, ...rest } = asked;
|
|
2004
2053
|
return snapshot(tx, {
|
|
2005
2054
|
...rest,
|
|
2006
2055
|
principals,
|
|
2007
2056
|
head,
|
|
2057
|
+
...reading ? { waiters: 16 } : {},
|
|
2008
2058
|
...released === void 0 ? {} : { released: {
|
|
2009
2059
|
...released,
|
|
2010
2060
|
windowMs: this.#config.bulkLimit.windowMs,
|
|
2011
|
-
clockOffsetMs: this.#prepared.options.clockOffset()
|
|
2061
|
+
clockOffsetMs: this.#prepared.options.clockOffset(),
|
|
2062
|
+
cap: this.#config.bulkLimit.count
|
|
2012
2063
|
} }
|
|
2013
2064
|
});
|
|
2014
2065
|
}
|
|
@@ -2037,19 +2088,23 @@ var VaultService = class {
|
|
|
2037
2088
|
/** `#decide`, once the vault is settled. */
|
|
2038
2089
|
async #decideNow(principals, decide, how = {}) {
|
|
2039
2090
|
const reports = [];
|
|
2091
|
+
const due = how.reads === true ? {
|
|
2092
|
+
...how,
|
|
2093
|
+
deadline: how.deadline ?? Date.now() + 5e3
|
|
2094
|
+
} : how;
|
|
2040
2095
|
for (let attempt = 1;; attempt += 1) try {
|
|
2041
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
2096
|
+
return await this.#decideOnce(principals, decide, reports, due);
|
|
2042
2097
|
} catch (error) {
|
|
2043
2098
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
2044
2099
|
throw error;
|
|
2045
2100
|
}
|
|
2046
2101
|
}
|
|
2047
|
-
async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
|
|
2102
|
+
async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false, deadline }) {
|
|
2048
2103
|
try {
|
|
2049
2104
|
const result = await this.#db.transaction(async (tx) => {
|
|
2050
|
-
await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
2105
|
+
await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
2051
2106
|
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
|
|
2052
|
-
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
2107
|
+
const reads = await this.#snapshot(tx, principals, asked, !late, shared);
|
|
2053
2108
|
const d = {
|
|
2054
2109
|
tx,
|
|
2055
2110
|
members,
|
|
@@ -2092,7 +2147,8 @@ var VaultService = class {
|
|
|
2092
2147
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
2093
2148
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
2094
2149
|
if (entries.length > 0) await this.#db.transaction(async (tx) => {
|
|
2095
|
-
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
2150
|
+
await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
2151
|
+
if (shared) await lockLogHeadQueued(tx, 16);
|
|
2096
2152
|
await this.#append(tx, entries);
|
|
2097
2153
|
});
|
|
2098
2154
|
this.#reported(reports);
|
|
@@ -2430,6 +2486,7 @@ var VaultService = class {
|
|
|
2430
2486
|
}
|
|
2431
2487
|
async unwrap(input) {
|
|
2432
2488
|
const { principal } = input;
|
|
2489
|
+
const use = input.use === void 0 ? null : useEntry(principal, input, input.use);
|
|
2433
2490
|
validateText(input.purpose);
|
|
2434
2491
|
if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
|
|
2435
2492
|
if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
|
|
@@ -2476,6 +2533,16 @@ var VaultService = class {
|
|
|
2476
2533
|
};
|
|
2477
2534
|
if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2478
2535
|
if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2536
|
+
if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
|
|
2537
|
+
if (!items.some((item, i) => {
|
|
2538
|
+
const fork = throughs[i] === null ? void 0 : forks.get(throughs[i]);
|
|
2539
|
+
const place = fork === void 0 ? sealed[i]?.holder ?? item.secret : {
|
|
2540
|
+
projectId: fork.projectId,
|
|
2541
|
+
environmentId: throughs[i]
|
|
2542
|
+
};
|
|
2543
|
+
return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
|
|
2544
|
+
})) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
|
|
2545
|
+
}
|
|
2479
2546
|
const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
|
|
2480
2547
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
2481
2548
|
return this.#keys({
|
|
@@ -2489,7 +2556,8 @@ var VaultService = class {
|
|
|
2489
2556
|
vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
|
|
2490
2557
|
remote,
|
|
2491
2558
|
entry,
|
|
2492
|
-
input
|
|
2559
|
+
input,
|
|
2560
|
+
...use === null ? {} : { also: [use] }
|
|
2493
2561
|
}, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
|
|
2494
2562
|
const key = await this.#open(wrapped, contextOf(secret), operation);
|
|
2495
2563
|
return key && {
|
|
@@ -2504,7 +2572,8 @@ var VaultService = class {
|
|
|
2504
2572
|
key.fill(0);
|
|
2505
2573
|
}
|
|
2506
2574
|
}),
|
|
2507
|
-
...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
|
|
2575
|
+
...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision },
|
|
2576
|
+
...use === null ? {} : { used: true }
|
|
2508
2577
|
}));
|
|
2509
2578
|
}
|
|
2510
2579
|
async wrap(input) {
|
|
@@ -2685,9 +2754,13 @@ var VaultService = class {
|
|
|
2685
2754
|
async #keys(call, operations, result) {
|
|
2686
2755
|
const { action, principal, secrets, entry } = call;
|
|
2687
2756
|
for (const secret of secrets) entry(secret, "allow", null);
|
|
2688
|
-
const
|
|
2757
|
+
const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
|
|
2758
|
+
const wrongKek = await this.#kekMismatch(deadline);
|
|
2689
2759
|
if (wrongKek !== null) return this.#decide([], async () => {
|
|
2690
2760
|
throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
|
|
2761
|
+
}, {
|
|
2762
|
+
reads: true,
|
|
2763
|
+
deadline
|
|
2691
2764
|
});
|
|
2692
2765
|
const owners = /* @__PURE__ */ new Map();
|
|
2693
2766
|
const check = async (d) => {
|
|
@@ -2776,7 +2849,8 @@ var VaultService = class {
|
|
|
2776
2849
|
}], [], d.reads)).seqStart };
|
|
2777
2850
|
}, {
|
|
2778
2851
|
asked: reads,
|
|
2779
|
-
reads: true
|
|
2852
|
+
reads: true,
|
|
2853
|
+
deadline
|
|
2780
2854
|
});
|
|
2781
2855
|
if (!intent.ok) return intent;
|
|
2782
2856
|
intentSeq = intent.seq;
|
|
@@ -2799,7 +2873,7 @@ var VaultService = class {
|
|
|
2799
2873
|
if (first === void 0 || first === null) return;
|
|
2800
2874
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
|
|
2801
2875
|
});
|
|
2802
|
-
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
|
|
2876
|
+
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
|
|
2803
2877
|
const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
|
|
2804
2878
|
try {
|
|
2805
2879
|
const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
|
|
@@ -2810,13 +2884,13 @@ var VaultService = class {
|
|
|
2810
2884
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
2811
2885
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
2812
2886
|
if (late) {
|
|
2813
|
-
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
2887
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
|
|
2814
2888
|
d.at = this.#at(d.reads);
|
|
2815
2889
|
await decided(d);
|
|
2816
2890
|
if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
2817
2891
|
}
|
|
2818
2892
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
2819
|
-
d.log.push(...released);
|
|
2893
|
+
d.log.push(...released, ...call.also ?? []);
|
|
2820
2894
|
const answer = result(done);
|
|
2821
2895
|
if (action !== "secret.read") d.after.push((seqOf) => Object.assign(answer, { seqs: released.map(seqOf) }));
|
|
2822
2896
|
return answer;
|
|
@@ -2826,7 +2900,8 @@ var VaultService = class {
|
|
|
2826
2900
|
}, {
|
|
2827
2901
|
late,
|
|
2828
2902
|
asked: reads,
|
|
2829
|
-
reads: true
|
|
2903
|
+
reads: true,
|
|
2904
|
+
deadline
|
|
2830
2905
|
});
|
|
2831
2906
|
}
|
|
2832
2907
|
/**
|
|
@@ -3854,10 +3929,10 @@ var VaultService = class {
|
|
|
3854
3929
|
* service that cannot answer leaves it undecided: the error is thrown,
|
|
3855
3930
|
* and the next call asks again.
|
|
3856
3931
|
*/
|
|
3857
|
-
async #kekMismatch() {
|
|
3932
|
+
async #kekMismatch(deadline) {
|
|
3858
3933
|
const prepared = this.#prepared;
|
|
3859
3934
|
if (!prepared.kekChecked) {
|
|
3860
|
-
const wrong = await this.#checkKeks();
|
|
3935
|
+
const wrong = await this.#checkKeks(deadline);
|
|
3861
3936
|
if (!prepared.kekChecked) {
|
|
3862
3937
|
prepared.wrongKek = wrong;
|
|
3863
3938
|
prepared.kekChecked = true;
|
|
@@ -3871,15 +3946,16 @@ var VaultService = class {
|
|
|
3871
3946
|
* keys it wrapped, if there are any; then its check value is recorded.
|
|
3872
3947
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
3873
3948
|
*/
|
|
3874
|
-
async #checkKeks() {
|
|
3949
|
+
async #checkKeks(deadline) {
|
|
3875
3950
|
const superseded = await this.#settled();
|
|
3876
3951
|
if (superseded !== null) return superseded;
|
|
3877
3952
|
const checks = await this.#checkValues(this.#db);
|
|
3878
3953
|
const budget = this.#prepared.options.keyBudgetMs;
|
|
3879
3954
|
for (const kek of this.#config.keks.all) {
|
|
3955
|
+
const spend = within(deadline, budget);
|
|
3880
3956
|
const operation = {
|
|
3881
|
-
deadline: Date.now() +
|
|
3882
|
-
signal: AbortSignal.timeout(
|
|
3957
|
+
deadline: Date.now() + spend,
|
|
3958
|
+
signal: AbortSignal.timeout(spend)
|
|
3883
3959
|
};
|
|
3884
3960
|
const opens = async (wrapped, context, expected) => {
|
|
3885
3961
|
try {
|
|
@@ -3914,6 +3990,7 @@ var VaultService = class {
|
|
|
3914
3990
|
if (samples.length > 0 && proof === null) return mismatch;
|
|
3915
3991
|
const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
|
|
3916
3992
|
await this.#db.transaction(async (tx) => {
|
|
3993
|
+
await boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
|
|
3917
3994
|
await lockLogHead(tx);
|
|
3918
3995
|
if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
|
|
3919
3996
|
await this.#append(tx, [{
|
|
@@ -4057,9 +4134,9 @@ var VaultService = class {
|
|
|
4057
4134
|
const { wrongKek, logKeys } = this.#prepared;
|
|
4058
4135
|
const { walks } = this.#prepared.options;
|
|
4059
4136
|
const found = [];
|
|
4060
|
-
let seen
|
|
4137
|
+
let seen;
|
|
4061
4138
|
const resumed = await this.#db.transaction(async (tx) => {
|
|
4062
|
-
seen =
|
|
4139
|
+
seen = await newestCheckpointEntry(tx);
|
|
4063
4140
|
await this.#sweep(tx, found);
|
|
4064
4141
|
const from = await this.#resume(tx, "newest");
|
|
4065
4142
|
if (!from.ok) return from;
|
|
@@ -4089,7 +4166,7 @@ var VaultService = class {
|
|
|
4089
4166
|
if (!isUnavailable(error)) throw error;
|
|
4090
4167
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
|
|
4091
4168
|
}
|
|
4092
|
-
|
|
4169
|
+
const decided = await this.#decide([], async (d) => {
|
|
4093
4170
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
4094
4171
|
const head = await lockLogHead(d.tx);
|
|
4095
4172
|
const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
@@ -4111,10 +4188,14 @@ var VaultService = class {
|
|
|
4111
4188
|
reason: scrub.reason
|
|
4112
4189
|
});
|
|
4113
4190
|
const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
|
|
4114
|
-
if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
4191
|
+
if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
|
|
4115
4192
|
const standing = await this.#standingRefusal(d.tx);
|
|
4116
4193
|
if (standing !== null) throw refused("log_broken", standing);
|
|
4117
4194
|
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
4195
|
+
} else if (seen?.decision === "deny" && walked.ok) {
|
|
4196
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
4197
|
+
const named = typeof standing?.failedAtSeq === "number" ? BigInt(standing.failedAtSeq) : null;
|
|
4198
|
+
if (named !== null && named <= walked.from.at.seq && !await this.#entryHolds(d.tx, named)) throw refused("log_broken", standing);
|
|
4118
4199
|
}
|
|
4119
4200
|
if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
4120
4201
|
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` });
|
|
@@ -4185,6 +4266,13 @@ var VaultService = class {
|
|
|
4185
4266
|
warning: stalled
|
|
4186
4267
|
};
|
|
4187
4268
|
});
|
|
4269
|
+
if (!decided.ok) return decided;
|
|
4270
|
+
const pruned = await pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
|
|
4271
|
+
if (pruned === null) return decided;
|
|
4272
|
+
return {
|
|
4273
|
+
...decided,
|
|
4274
|
+
warning: decided.warning === void 0 ? pruned : `${decided.warning}; ${pruned}`
|
|
4275
|
+
};
|
|
4188
4276
|
}
|
|
4189
4277
|
/**
|
|
4190
4278
|
* The walk on from where a checkpoint resumes, no further than `limit`
|
|
@@ -4252,6 +4340,30 @@ var VaultService = class {
|
|
|
4252
4340
|
};
|
|
4253
4341
|
}
|
|
4254
4342
|
/**
|
|
4343
|
+
* Whether entry `seq` is as the walk checks each: there, its hash its
|
|
4344
|
+
* own, linked to the entry before, and the vault's under its MAC; and the
|
|
4345
|
+
* entry after it, if any, linked to it, for a refusal can name either side
|
|
4346
|
+
* of a broken link (`forward`, at the end of a batch).
|
|
4347
|
+
*/
|
|
4348
|
+
async #entryHolds(db, seq) {
|
|
4349
|
+
const found = await entriesAt(db, seq === 0n ? [seq, seq + 1n] : [
|
|
4350
|
+
seq - 1n,
|
|
4351
|
+
seq,
|
|
4352
|
+
seq + 1n
|
|
4353
|
+
]);
|
|
4354
|
+
const entry = found.get(seq);
|
|
4355
|
+
const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
|
|
4356
|
+
if (entry === void 0 || before === void 0) return false;
|
|
4357
|
+
const after = found.get(seq + 1n);
|
|
4358
|
+
if (after !== void 0 && !after.prevHash.equals(entry.hash)) return false;
|
|
4359
|
+
return verifyEntries([entry], {
|
|
4360
|
+
keys: this.#keysFor(entry),
|
|
4361
|
+
chainOnly: ["app"],
|
|
4362
|
+
startSeq: seq,
|
|
4363
|
+
startPrevHash: before.hash
|
|
4364
|
+
}).ok;
|
|
4365
|
+
}
|
|
4366
|
+
/**
|
|
4255
4367
|
* The newest refusal for a broken log since the newest checkpoint was
|
|
4256
4368
|
* signed, by the vault's MAC, as it said: it stands, for every
|
|
4257
4369
|
* verification and checkpoint, until a checkpoint is signed, which takes
|
|
@@ -4634,6 +4746,39 @@ function validateText(value) {
|
|
|
4634
4746
|
if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new Error("key request strings must be well-formed Unicode");
|
|
4635
4747
|
}
|
|
4636
4748
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
4749
|
+
/**
|
|
4750
|
+
* The app's `credential.use` entry, as the vault appends it beside a read
|
|
4751
|
+
* (`UnwrapInput.use`): what the app wrote, checked to be what an entry
|
|
4752
|
+
* holds, in its reader's name. The app wrote it under the head itself
|
|
4753
|
+
* before 0.14.5; it describes the read, and decides nothing.
|
|
4754
|
+
*/
|
|
4755
|
+
function useEntry(principal, correlation, use) {
|
|
4756
|
+
const id = (value, name) => {
|
|
4757
|
+
if (value !== null && (typeof value !== "string" || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
|
|
4758
|
+
return value;
|
|
4759
|
+
};
|
|
4760
|
+
if (typeof use.metadata !== "string" || use.metadata.length > USE_METADATA_LENGTH) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
4761
|
+
if (use.sourceIp !== null) validateText(use.sourceIp);
|
|
4762
|
+
validateText(use.metadata);
|
|
4763
|
+
let metadata;
|
|
4764
|
+
try {
|
|
4765
|
+
metadata = JSON.parse(use.metadata);
|
|
4766
|
+
} catch {
|
|
4767
|
+
metadata = void 0;
|
|
4768
|
+
}
|
|
4769
|
+
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
4770
|
+
return {
|
|
4771
|
+
actor: principal,
|
|
4772
|
+
action: "credential.use",
|
|
4773
|
+
decision: "allow",
|
|
4774
|
+
projectId: id(use.projectId, "projectId"),
|
|
4775
|
+
environmentId: id(use.environmentId, "environmentId"),
|
|
4776
|
+
operationId: correlation.operationId ?? null,
|
|
4777
|
+
requestId: correlation.requestId ?? null,
|
|
4778
|
+
sourceIp: use.sourceIp,
|
|
4779
|
+
metadata: use.metadata
|
|
4780
|
+
};
|
|
4781
|
+
}
|
|
4637
4782
|
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
4638
4783
|
function validateCorrelation(input) {
|
|
4639
4784
|
if (input.requestId != null) validateText(input.requestId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
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.5",
|
|
40
|
+
"@coffre/db": "0.14.5"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,14 @@ export type BulkLimit = { count: number; windowMs: number };
|
|
|
11
11
|
*/
|
|
12
12
|
export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The longest bulk limit window: a day. The releases a window counts are
|
|
16
|
+
* kept a day and an hour (migration 0021's `prune_vault_releases`, and on
|
|
17
|
+
* SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
|
|
18
|
+
* since, counts exactly.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_BULK_WINDOW_MINUTES = 24 * 60;
|
|
21
|
+
|
|
14
22
|
/** The vault as it runs: keys decoded, emails checked. */
|
|
15
23
|
export type ResolvedVaultConfig = {
|
|
16
24
|
keks: KekRegistry;
|
|
@@ -61,7 +69,10 @@ export type VaultConfig = {
|
|
|
61
69
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
62
70
|
*/
|
|
63
71
|
signingKey?: string;
|
|
64
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
|
|
74
|
+
* window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
|
|
75
|
+
*/
|
|
65
76
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
66
77
|
/**
|
|
67
78
|
* @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
|
|
@@ -176,8 +187,8 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
|
|
|
176
187
|
|
|
177
188
|
|
|
178
189
|
function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
|
|
179
|
-
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
|
|
180
|
-
throw new Error(
|
|
190
|
+
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > MAX_BULK_WINDOW_MINUTES) {
|
|
191
|
+
throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
|
|
181
192
|
}
|
|
182
193
|
return { count, windowMs: windowMinutes * 60_000 };
|
|
183
194
|
}
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
2
|
import type { Queryable, Transaction } from '@coffre/db';
|
|
3
|
-
import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
|
|
3
|
+
import { LogHeadMismatch, lockLogHead, lockLogHeadQueued } from '@coffre/db/log';
|
|
4
4
|
import { engineOf, rowsOf } from '@coffre/db/dialect';
|
|
5
5
|
import type { GrantRow } from '@coffre/db/grants';
|
|
6
6
|
|
|
@@ -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,11 +76,19 @@ 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;
|
|
86
|
+
/**
|
|
87
|
+
* Held by another, wait for the head only in one of this many places in
|
|
88
|
+
* line, and refuse at once when none is free (`lockLogHeadQueued`); with
|
|
89
|
+
* none, wait as long as the transaction's bound lets.
|
|
90
|
+
*/
|
|
91
|
+
waiters?: number;
|
|
82
92
|
};
|
|
83
93
|
|
|
84
94
|
/** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
|
|
@@ -90,7 +100,7 @@ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
|
|
|
90
100
|
if (asked.head !== true || (first.head !== null && current(first.head))) return first;
|
|
91
101
|
// Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
|
|
92
102
|
// Asked for the head, `db` is a transaction (the overloads above).
|
|
93
|
-
if (first.head === null) await lockLogHead(db as Transaction);
|
|
103
|
+
if (first.head === null) await (asked.waiters === undefined ? lockLogHead(db as Transaction) : lockLogHeadQueued(db as Transaction, asked.waiters));
|
|
94
104
|
const again = await read();
|
|
95
105
|
if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
|
|
96
106
|
// Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
|
package/src/store.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre
|
|
|
2
2
|
import type { Author, StoredEntry } from '@coffre/core/audit';
|
|
3
3
|
import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
|
|
4
4
|
import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
5
|
-
import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
|
|
5
|
+
import { changedRows, clockMillis, engineOf, forUpdate, rowsOf, tombstone, truth } from '@coffre/db/dialect';
|
|
6
6
|
import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
|
|
7
7
|
import { entryColumns, withinBytes } from '@coffre/db/log';
|
|
8
|
+
|
|
9
|
+
import { MAX_BULK_WINDOW_MINUTES } from './config.ts';
|
|
8
10
|
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -67,6 +69,33 @@ export async function boundLockWaits(tx: Transaction, ms: number, statements = f
|
|
|
67
69
|
: sql.raw(`SET LOCAL lock_timeout = ${bound}`));
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* How long a release is kept: the longest bulk limit window
|
|
74
|
+
* (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
|
|
75
|
+
* database's clock stepping back between a prune and a later count, which
|
|
76
|
+
* is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
|
|
77
|
+
* and an hour, in its own SQL: a longer window takes a migration too.
|
|
78
|
+
*/
|
|
79
|
+
export const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 60_000 + 3_600_000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Delete up to `atMost` rows of `vault_releases` released over
|
|
83
|
+
* `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
|
|
84
|
+
* read again. On Postgres the vault's login deletes no row itself, and asks
|
|
85
|
+
* `prune_vault_releases`, which deletes only those (migration 0021). How
|
|
86
|
+
* many went.
|
|
87
|
+
*/
|
|
88
|
+
export async function pruneReleases(db: Queryable, atMost: number): Promise<number> {
|
|
89
|
+
if (engineOf(db) === 'postgres') {
|
|
90
|
+
const [row] = await rowsOf<{ pruned: number }>(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
|
|
91
|
+
return row!.pruned;
|
|
92
|
+
}
|
|
93
|
+
const { vaultReleases } = tablesOf(db);
|
|
94
|
+
const old = db.select({ principal: vaultReleases.principal, key: vaultReleases.key }).from(vaultReleases)
|
|
95
|
+
.where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
|
|
96
|
+
return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
/** The database's clock, now. */
|
|
71
100
|
export async function now(db: Queryable): Promise<number> {
|
|
72
101
|
const [{ at }] = await clock(db);
|
package/src/vault.ts
CHANGED
|
@@ -91,6 +91,9 @@ import {
|
|
|
91
91
|
type Settings,
|
|
92
92
|
type Through,
|
|
93
93
|
type UnwrapInput,
|
|
94
|
+
USE_METADATA_LENGTH,
|
|
95
|
+
type Unwrapped,
|
|
96
|
+
type UseRecord,
|
|
94
97
|
type Wrapped,
|
|
95
98
|
type Vault,
|
|
96
99
|
type Via,
|
|
@@ -101,7 +104,7 @@ import {
|
|
|
101
104
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
102
105
|
import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
103
106
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
104
|
-
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
107
|
+
import { appendEntries, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
108
|
|
|
106
109
|
import { signer, type Signer } from './checkpoint.ts';
|
|
107
110
|
import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
|
|
@@ -212,6 +215,46 @@ const LOCK_TIMEOUT_MS = 15_000;
|
|
|
212
215
|
*/
|
|
213
216
|
const READ_LOCK_TIMEOUT_MS = 3_000;
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* How long a whole read call may take, from its start to its answer, its
|
|
220
|
+
* refusal's entries, the record of a vault key's check and, with a key
|
|
221
|
+
* service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
|
|
222
|
+
* past it the call fails as unavailable (`Overdue`) rather than start
|
|
223
|
+
* another. So a caller hears within it, ok or 503, never tens of seconds later.
|
|
224
|
+
*/
|
|
225
|
+
export const READ_DEADLINE_MS = 5_000;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* How many reads may wait for the log's head at once, through all of the
|
|
229
|
+
* vault's connections; the next is refused at once, as unavailable, holding
|
|
230
|
+
* no connection while it waits (`lockLogHeadQueued`). A read holds the head
|
|
231
|
+
* for a few round trips, about 150 ms through Hyperdrive, so this many
|
|
232
|
+
* clear within a read's bound (2.4 s of its 3), and no more than this many
|
|
233
|
+
* of the pool's connections wait. A burst of 50 reads at a 2-vCPU database
|
|
234
|
+
* a 30 ms round trip away: 17 read within 2.2 s, 33 refused at once.
|
|
235
|
+
*/
|
|
236
|
+
export const READ_HEAD_WAITERS = 16;
|
|
237
|
+
|
|
238
|
+
/** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
|
|
239
|
+
const RELEASES_PRUNED = 10_000;
|
|
240
|
+
|
|
241
|
+
/** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
|
|
242
|
+
class Overdue extends Error {
|
|
243
|
+
readonly code = '57014';
|
|
244
|
+
constructor() {
|
|
245
|
+
super('this read took longer than it may: ask again shortly');
|
|
246
|
+
this.name = 'Overdue';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
|
|
251
|
+
function within(deadline: number | undefined, ms: number): number {
|
|
252
|
+
if (deadline === undefined) return ms;
|
|
253
|
+
const left = deadline - Date.now();
|
|
254
|
+
if (left <= 0) throw new Overdue();
|
|
255
|
+
return Math.min(ms, left);
|
|
256
|
+
}
|
|
257
|
+
|
|
215
258
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
216
259
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
217
260
|
|
|
@@ -406,7 +449,12 @@ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { princip
|
|
|
406
449
|
* is what it reads besides its members, and `reads` says it changes none
|
|
407
450
|
* of them (`#decide`).
|
|
408
451
|
*/
|
|
409
|
-
|
|
452
|
+
/**
|
|
453
|
+
* How a decision is made. `deadline`, a read's (`reads`): when its whole
|
|
454
|
+
* call must have answered, every wait in every transaction it takes
|
|
455
|
+
* included (`READ_DEADLINE_MS`).
|
|
456
|
+
*/
|
|
457
|
+
type How = { late?: boolean; asked?: Reads; reads?: boolean; deadline?: number };
|
|
410
458
|
|
|
411
459
|
/** The actions of the entries the vault writes about keys. */
|
|
412
460
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
@@ -492,13 +540,15 @@ class VaultService implements Vault {
|
|
|
492
540
|
}
|
|
493
541
|
|
|
494
542
|
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
495
|
-
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
|
|
543
|
+
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean, reading = false): Promise<Snapshot> {
|
|
496
544
|
const { released, ...rest } = asked;
|
|
497
545
|
return snapshot(tx, {
|
|
498
546
|
...rest,
|
|
499
547
|
principals,
|
|
500
548
|
head,
|
|
501
|
-
|
|
549
|
+
// A read waits for the head in one of a few places in line, or is refused at once.
|
|
550
|
+
...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
|
|
551
|
+
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
|
|
502
552
|
});
|
|
503
553
|
}
|
|
504
554
|
|
|
@@ -525,9 +575,11 @@ class VaultService implements Vault {
|
|
|
525
575
|
/** `#decide`, once the vault is settled. */
|
|
526
576
|
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
|
|
527
577
|
const reports: NewEntry[] = [];
|
|
578
|
+
// A read's call is due from its first decision, unless its caller set when.
|
|
579
|
+
const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
|
|
528
580
|
for (let attempt = 1; ; attempt += 1) {
|
|
529
581
|
try {
|
|
530
|
-
return await this.#decideOnce(principals, decide, reports,
|
|
582
|
+
return await this.#decideOnce(principals, decide, reports, due);
|
|
531
583
|
} catch (error) {
|
|
532
584
|
// A member who had no row when this decision locked theirs, and has
|
|
533
585
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -537,13 +589,13 @@ class VaultService implements Vault {
|
|
|
537
589
|
}
|
|
538
590
|
}
|
|
539
591
|
|
|
540
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
|
|
592
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false, deadline }: How): Promise<Outcome<T>> {
|
|
541
593
|
try {
|
|
542
594
|
const result = await this.#db.transaction(async (tx) => {
|
|
543
|
-
await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
|
|
595
|
+
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
544
596
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
|
|
545
597
|
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
546
|
-
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
598
|
+
const reads = await this.#snapshot(tx, principals, asked, !late, shared);
|
|
547
599
|
const d: Decision = {
|
|
548
600
|
tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
549
601
|
};
|
|
@@ -577,9 +629,12 @@ class VaultService implements Vault {
|
|
|
577
629
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
578
630
|
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
579
631
|
if (entries.length > 0) {
|
|
580
|
-
// A refusal's entries are written whatever the decision's bound: it answers only once they are.
|
|
632
|
+
// A refusal's entries are written whatever the decision's bound: it answers only once they are. A read's, within
|
|
633
|
+
// its call's deadline: past it, the call fails as unavailable, having released nothing.
|
|
581
634
|
await this.#db.transaction(async (tx) => {
|
|
582
|
-
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
635
|
+
await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
|
|
636
|
+
// In one of the head's places in line, as the read's decision waited, or refused at once.
|
|
637
|
+
if (shared) await lockLogHeadQueued(tx, READ_HEAD_WAITERS);
|
|
583
638
|
await this.#append(tx, entries);
|
|
584
639
|
});
|
|
585
640
|
}
|
|
@@ -928,8 +983,9 @@ class VaultService implements Vault {
|
|
|
928
983
|
|
|
929
984
|
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
930
985
|
// cannot be wiped, so this is best-effort memory hygiene.
|
|
931
|
-
async unwrap(input: UnwrapInput): Promise<Outcome<
|
|
986
|
+
async unwrap(input: UnwrapInput): Promise<Outcome<Unwrapped>> {
|
|
932
987
|
const { principal } = input;
|
|
988
|
+
const use = input.use === undefined ? null : useEntry(principal, input, input.use);
|
|
933
989
|
validateText(input.purpose);
|
|
934
990
|
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
935
991
|
if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
|
|
@@ -975,6 +1031,17 @@ class VaultService implements Vault {
|
|
|
975
1031
|
if (throughOk.includes(false)) {
|
|
976
1032
|
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
977
1033
|
}
|
|
1034
|
+
// The app's record of a use names where the read was, which the vault vouches for once it appends it: the place an
|
|
1035
|
+
// item was read at, the fork it is read in, else a reference's holder, else its own, with its project. Another is
|
|
1036
|
+
// a claim the read does not bear out, refused and logged as one; no use is appended.
|
|
1037
|
+
if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
|
|
1038
|
+
const named = items.some((item, i) => {
|
|
1039
|
+
const fork = throughs[i] === null ? undefined : forks.get(throughs[i]!);
|
|
1040
|
+
const place = fork === undefined ? sealed[i]?.holder ?? item.secret : { projectId: fork.projectId, environmentId: throughs[i]! };
|
|
1041
|
+
return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
|
|
1042
|
+
});
|
|
1043
|
+
if (!named) return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
|
|
1044
|
+
}
|
|
978
1045
|
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
979
1046
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
980
1047
|
return this.#keys(
|
|
@@ -993,6 +1060,7 @@ class VaultService implements Vault {
|
|
|
993
1060
|
remote,
|
|
994
1061
|
entry,
|
|
995
1062
|
input,
|
|
1063
|
+
...(use === null ? {} : { also: [use] }),
|
|
996
1064
|
},
|
|
997
1065
|
(contextOf) =>
|
|
998
1066
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
@@ -1009,6 +1077,8 @@ class VaultService implements Vault {
|
|
|
1009
1077
|
}),
|
|
1010
1078
|
// Named back: the read was decided on that answer, which this vault checked.
|
|
1011
1079
|
...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
|
|
1080
|
+
// And the use, appended with the read: the app has nothing left to append.
|
|
1081
|
+
...(use === null ? {} : { used: true as const }),
|
|
1012
1082
|
}),
|
|
1013
1083
|
);
|
|
1014
1084
|
}
|
|
@@ -1224,6 +1294,8 @@ class VaultService implements Vault {
|
|
|
1224
1294
|
viaReference?: readonly boolean[];
|
|
1225
1295
|
remote: boolean;
|
|
1226
1296
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1297
|
+
/** Entries appended with the release, after its own, only once the keys are released: the app's record of a use. */
|
|
1298
|
+
also?: readonly NewEntry[];
|
|
1227
1299
|
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
|
|
1228
1300
|
},
|
|
1229
1301
|
/**
|
|
@@ -1236,11 +1308,14 @@ class VaultService implements Vault {
|
|
|
1236
1308
|
): Promise<Outcome<R>> {
|
|
1237
1309
|
const { action, principal, secrets, entry } = call;
|
|
1238
1310
|
for (const secret of secrets) entry(secret, 'allow', null);
|
|
1239
|
-
|
|
1311
|
+
// One deadline for the call: the record of the KEK's check, the decisions, the intent's and the keys', and, with a
|
|
1312
|
+
// key service, its own budget besides, spent from it too.
|
|
1313
|
+
const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
|
|
1314
|
+
const wrongKek = await this.#kekMismatch(deadline);
|
|
1240
1315
|
if (wrongKek !== null) {
|
|
1241
1316
|
return this.#decide([], async () => {
|
|
1242
1317
|
throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
|
|
1243
|
-
});
|
|
1318
|
+
}, { reads: true, deadline });
|
|
1244
1319
|
}
|
|
1245
1320
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1246
1321
|
const owners = new Map<string, string>();
|
|
@@ -1319,7 +1394,7 @@ class VaultService implements Vault {
|
|
|
1319
1394
|
}),
|
|
1320
1395
|
}], [], d.reads);
|
|
1321
1396
|
return { seq: appended.seqStart };
|
|
1322
|
-
}, { asked: reads, reads: true });
|
|
1397
|
+
}, { asked: reads, reads: true, deadline });
|
|
1323
1398
|
if (!intent.ok) return intent;
|
|
1324
1399
|
intentSeq = intent.seq;
|
|
1325
1400
|
}
|
|
@@ -1355,7 +1430,7 @@ class VaultService implements Vault {
|
|
|
1355
1430
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
1356
1431
|
});
|
|
1357
1432
|
}
|
|
1358
|
-
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
|
|
1433
|
+
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
|
|
1359
1434
|
const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
|
|
1360
1435
|
try {
|
|
1361
1436
|
const entries = () => secrets.map((secret, i) =>
|
|
@@ -1381,7 +1456,7 @@ class VaultService implements Vault {
|
|
|
1381
1456
|
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1382
1457
|
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1383
1458
|
// since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
|
|
1384
|
-
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
1459
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
|
|
1385
1460
|
d.at = this.#at(d.reads);
|
|
1386
1461
|
await decided(d);
|
|
1387
1462
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
@@ -1390,7 +1465,7 @@ class VaultService implements Vault {
|
|
|
1390
1465
|
}
|
|
1391
1466
|
}
|
|
1392
1467
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
1393
|
-
d.log.push(...released);
|
|
1468
|
+
d.log.push(...released, ...(call.also ?? []));
|
|
1394
1469
|
const answer = result(done);
|
|
1395
1470
|
// A wrap's answer names its entries, which the app's writes refer to.
|
|
1396
1471
|
if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
|
|
@@ -1398,7 +1473,7 @@ class VaultService implements Vault {
|
|
|
1398
1473
|
} finally {
|
|
1399
1474
|
for (const value of done) value.wipe();
|
|
1400
1475
|
}
|
|
1401
|
-
}, { late, asked: reads, reads: true });
|
|
1476
|
+
}, { late, asked: reads, reads: true, deadline });
|
|
1402
1477
|
}
|
|
1403
1478
|
|
|
1404
1479
|
/**
|
|
@@ -2404,10 +2479,10 @@ class VaultService implements Vault {
|
|
|
2404
2479
|
* service that cannot answer leaves it undecided: the error is thrown,
|
|
2405
2480
|
* and the next call asks again.
|
|
2406
2481
|
*/
|
|
2407
|
-
async #kekMismatch(): Promise<string | null> {
|
|
2482
|
+
async #kekMismatch(deadline?: number): Promise<string | null> {
|
|
2408
2483
|
const prepared = this.#prepared;
|
|
2409
2484
|
if (!prepared.kekChecked) {
|
|
2410
|
-
const wrong = await this.#checkKeks();
|
|
2485
|
+
const wrong = await this.#checkKeks(deadline);
|
|
2411
2486
|
if (!prepared.kekChecked) {
|
|
2412
2487
|
prepared.wrongKek = wrong;
|
|
2413
2488
|
prepared.kekChecked = true;
|
|
@@ -2422,14 +2497,16 @@ class VaultService implements Vault {
|
|
|
2422
2497
|
* keys it wrapped, if there are any; then its check value is recorded.
|
|
2423
2498
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
2424
2499
|
*/
|
|
2425
|
-
async #checkKeks(): Promise<string | null> {
|
|
2500
|
+
async #checkKeks(deadline?: number): Promise<string | null> {
|
|
2426
2501
|
// A vault that may not write cannot record a check either; why it may not is the answer.
|
|
2427
2502
|
const superseded = await this.#settled();
|
|
2428
2503
|
if (superseded !== null) return superseded;
|
|
2429
2504
|
const checks = await this.#checkValues(this.#db);
|
|
2430
2505
|
const budget = this.#prepared.options.keyBudgetMs;
|
|
2431
2506
|
for (const kek of this.#config.keks.all) {
|
|
2432
|
-
|
|
2507
|
+
// A read's, within what is left of its deadline.
|
|
2508
|
+
const spend = within(deadline, budget);
|
|
2509
|
+
const operation = { deadline: Date.now() + spend, signal: AbortSignal.timeout(spend) };
|
|
2433
2510
|
const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
|
|
2434
2511
|
try {
|
|
2435
2512
|
const key = await kek.unwrap(wrapped, context, operation);
|
|
@@ -2460,6 +2537,8 @@ class VaultService implements Vault {
|
|
|
2460
2537
|
// The key it opened, if any, is in the log, as every key the vault opens is. Another call that
|
|
2461
2538
|
// checked at the same time may have recorded one first: under the log's lock, the first stays.
|
|
2462
2539
|
await this.#db.transaction(async (tx) => {
|
|
2540
|
+
// Once a process, by its first key operation: a read's waits as the read's, within its deadline.
|
|
2541
|
+
await store.boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
|
|
2463
2542
|
await lockLogHead(tx);
|
|
2464
2543
|
if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
|
|
2465
2544
|
await this.#append(tx, [
|
|
@@ -2578,9 +2657,9 @@ class VaultService implements Vault {
|
|
|
2578
2657
|
const { walks } = this.#prepared.options;
|
|
2579
2658
|
const found: NewEntry[] = [];
|
|
2580
2659
|
// The newest checkpoint entry, signed or refused, as this call's snapshot found it.
|
|
2581
|
-
let seen:
|
|
2660
|
+
let seen: StoredEntry | undefined;
|
|
2582
2661
|
const resumed = await this.#db.transaction(async (tx) => {
|
|
2583
|
-
seen =
|
|
2662
|
+
seen = await store.newestCheckpointEntry(tx);
|
|
2584
2663
|
await this.#sweep(tx, found);
|
|
2585
2664
|
const from = await this.#resume(tx, 'newest');
|
|
2586
2665
|
if (!from.ok) return from;
|
|
@@ -2607,7 +2686,7 @@ class VaultService implements Vault {
|
|
|
2607
2686
|
stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
|
|
2608
2687
|
}
|
|
2609
2688
|
}
|
|
2610
|
-
|
|
2689
|
+
const decided = await this.#decide([], async (d) => {
|
|
2611
2690
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2612
2691
|
// Checkpoints one at a time, each against the one before.
|
|
2613
2692
|
const head = await lockLogHead(d.tx);
|
|
@@ -2625,10 +2704,17 @@ class VaultService implements Vault {
|
|
|
2625
2704
|
// Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
|
|
2626
2705
|
// could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
|
|
2627
2706
|
// and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
|
|
2628
|
-
if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
2707
|
+
if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
|
|
2629
2708
|
const standing = await this.#standingRefusal(d.tx);
|
|
2630
2709
|
if (standing !== null) throw refused('log_broken', standing);
|
|
2631
2710
|
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
2711
|
+
} else if (seen?.decision === 'deny' && walked.ok) {
|
|
2712
|
+
// A refusal for a broken log stands since this call's snapshot, of an entry before where its walk began, which
|
|
2713
|
+
// it never read: that entry again, as the walk checks each, before anything is signed over it. Still broken,
|
|
2714
|
+
// the refusal stands; whole again, the signature clears it (T104).
|
|
2715
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
2716
|
+
const named = typeof standing?.failedAtSeq === 'number' ? BigInt(standing.failedAtSeq) : null;
|
|
2717
|
+
if (named !== null && named <= walked.from.at.seq && !(await this.#entryHolds(d.tx, named))) throw refused('log_broken', standing!);
|
|
2632
2718
|
}
|
|
2633
2719
|
// Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
|
|
2634
2720
|
if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
@@ -2676,6 +2762,15 @@ class VaultService implements Vault {
|
|
|
2676
2762
|
d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
|
|
2677
2763
|
return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
|
|
2678
2764
|
});
|
|
2765
|
+
// Then the bulk count's upkeep, which no read waits on, after a checkpoint signed, never instead of a refusal: the
|
|
2766
|
+
// releases no window can count again, kept a day and an hour (`RELEASES_KEPT_MS`), longer than any window, up to
|
|
2767
|
+
// `RELEASES_PRUNED` of them, in one statement of its own, holding no lock any read takes but the rows it deletes,
|
|
2768
|
+
// and none an append holds. What is kept is the database's to say, not the caller's: no login forgets a release a
|
|
2769
|
+
// window counts. A failure leaves it to the next checkpoint, and says so.
|
|
2770
|
+
if (!decided.ok) return decided;
|
|
2771
|
+
const pruned = await store.pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error: Error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
|
|
2772
|
+
if (pruned === null) return decided;
|
|
2773
|
+
return { ...decided, warning: decided.warning === undefined ? pruned : `${decided.warning}; ${pruned}` };
|
|
2679
2774
|
}
|
|
2680
2775
|
|
|
2681
2776
|
/**
|
|
@@ -2722,6 +2817,22 @@ class VaultService implements Vault {
|
|
|
2722
2817
|
return { ok: true, next: null };
|
|
2723
2818
|
}
|
|
2724
2819
|
|
|
2820
|
+
/**
|
|
2821
|
+
* Whether entry `seq` is as the walk checks each: there, its hash its
|
|
2822
|
+
* own, linked to the entry before, and the vault's under its MAC; and the
|
|
2823
|
+
* entry after it, if any, linked to it, for a refusal can name either side
|
|
2824
|
+
* of a broken link (`forward`, at the end of a batch).
|
|
2825
|
+
*/
|
|
2826
|
+
async #entryHolds(db: Queryable, seq: bigint): Promise<boolean> {
|
|
2827
|
+
const found = await store.entriesAt(db, seq === 0n ? [seq, seq + 1n] : [seq - 1n, seq, seq + 1n]);
|
|
2828
|
+
const entry = found.get(seq);
|
|
2829
|
+
const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
|
|
2830
|
+
if (entry === undefined || before === undefined) return false;
|
|
2831
|
+
const after = found.get(seq + 1n);
|
|
2832
|
+
if (after !== undefined && !after.prevHash.equals(entry.hash)) return false;
|
|
2833
|
+
return verifyEntries([entry], { keys: this.#keysFor(entry), chainOnly: ['app'], startSeq: seq, startPrevHash: before.hash }).ok;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2725
2836
|
/**
|
|
2726
2837
|
* The newest refusal for a broken log since the newest checkpoint was
|
|
2727
2838
|
* signed, by the vault's MAC, as it said: it stands, for every
|
|
@@ -3065,6 +3176,45 @@ function validateText(value: string): void {
|
|
|
3065
3176
|
|
|
3066
3177
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
3067
3178
|
|
|
3179
|
+
/**
|
|
3180
|
+
* The app's `credential.use` entry, as the vault appends it beside a read
|
|
3181
|
+
* (`UnwrapInput.use`): what the app wrote, checked to be what an entry
|
|
3182
|
+
* holds, in its reader's name. The app wrote it under the head itself
|
|
3183
|
+
* before 0.14.5; it describes the read, and decides nothing.
|
|
3184
|
+
*/
|
|
3185
|
+
function useEntry(principal: string, correlation: Correlation, use: UseRecord): NewEntry {
|
|
3186
|
+
const id = (value: unknown, name: string) => {
|
|
3187
|
+
if (value !== null && (typeof value !== 'string' || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
|
|
3188
|
+
return value;
|
|
3189
|
+
};
|
|
3190
|
+
// The bound first: nothing past it is read.
|
|
3191
|
+
if (typeof use.metadata !== 'string' || use.metadata.length > USE_METADATA_LENGTH) {
|
|
3192
|
+
throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
3193
|
+
}
|
|
3194
|
+
if (use.sourceIp !== null) validateText(use.sourceIp);
|
|
3195
|
+
validateText(use.metadata);
|
|
3196
|
+
let metadata: unknown;
|
|
3197
|
+
try {
|
|
3198
|
+
metadata = JSON.parse(use.metadata);
|
|
3199
|
+
} catch {
|
|
3200
|
+
metadata = undefined;
|
|
3201
|
+
}
|
|
3202
|
+
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
3203
|
+
throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
|
|
3204
|
+
}
|
|
3205
|
+
return {
|
|
3206
|
+
actor: principal,
|
|
3207
|
+
action: 'credential.use',
|
|
3208
|
+
decision: 'allow',
|
|
3209
|
+
projectId: id(use.projectId, 'projectId'),
|
|
3210
|
+
environmentId: id(use.environmentId, 'environmentId'),
|
|
3211
|
+
operationId: correlation.operationId ?? null,
|
|
3212
|
+
requestId: correlation.requestId ?? null,
|
|
3213
|
+
sourceIp: use.sourceIp,
|
|
3214
|
+
metadata: use.metadata,
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3068
3218
|
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
3069
3219
|
function validateCorrelation(input: Correlation): void {
|
|
3070
3220
|
if (input.requestId != null) validateText(input.requestId);
|