@coffre/vault 0.1.14 → 0.1.15
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.js +1 -1
- package/dist/node.js +1 -1
- package/dist/{vault-3O-ynZPg.js → vault-BmEa1SIQ.js} +56 -3
- package/package.json +3 -3
- package/src/store.ts +12 -1
- package/src/vault.ts +54 -7
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-1yVV2L0_.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-BmEa1SIQ.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-1yVV2L0_.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-BmEa1SIQ.js";
|
|
3
3
|
import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
|
|
4
4
|
import { Agent, createServer, request } from "node:http";
|
|
5
5
|
import { openDatabase } from "@coffre/db/connect";
|
|
@@ -7,7 +7,7 @@ import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
|
7
7
|
import { checkContext } from "@coffre/core/envelope";
|
|
8
8
|
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
|
|
9
9
|
import { appendEntries, lockLogHead } from "@coffre/db/log";
|
|
10
|
-
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from "drizzle-orm";
|
|
10
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, sql } from "drizzle-orm";
|
|
11
11
|
//#region src/accounting.ts
|
|
12
12
|
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
13
13
|
"secret.read",
|
|
@@ -356,6 +356,16 @@ async function hashesAt(db, seqs) {
|
|
|
356
356
|
}
|
|
357
357
|
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
358
358
|
const HASHES_AT_ONCE = 5e3;
|
|
359
|
+
/**
|
|
360
|
+
* Whether every entry from the first through `seq` is there. Seqs are the
|
|
361
|
+
* primary key, so the count is `seq + 1` exactly when none is missing: one
|
|
362
|
+
* pass over the key's index, never the entries.
|
|
363
|
+
*/
|
|
364
|
+
async function complete(db, seq) {
|
|
365
|
+
const { auditLog } = tablesOf(db);
|
|
366
|
+
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
|
|
367
|
+
return BigInt(n) === seq + 1n;
|
|
368
|
+
}
|
|
359
369
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
360
370
|
async function hashAt(db, seq) {
|
|
361
371
|
const { auditLog } = tablesOf(db);
|
|
@@ -856,6 +866,12 @@ const TAMPERED_SUBJECT = "this member's record failed the vault's integrity chec
|
|
|
856
866
|
const CHECKPOINT = "audit.checkpoint";
|
|
857
867
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
858
868
|
const SCHEDULER = "system:coffre-scheduler";
|
|
869
|
+
/**
|
|
870
|
+
* How often a checkpoint recomputes the chain from its first entry: the
|
|
871
|
+
* first of each hour, by the vault's clock. The others resume from the
|
|
872
|
+
* prefix the last one signed (`#recompute`).
|
|
873
|
+
*/
|
|
874
|
+
const FULL_RECOMPUTE_MS = 36e5;
|
|
859
875
|
/** A refusal and the entries that record it. */
|
|
860
876
|
var Refused = class {
|
|
861
877
|
refusal;
|
|
@@ -2008,16 +2024,53 @@ var VaultService = class {
|
|
|
2008
2024
|
const row = await latestVaultEntry(db, [CHECKPOINT]);
|
|
2009
2025
|
return row === void 0 ? null : {
|
|
2010
2026
|
checkpoint: JSON.parse(row.metadata),
|
|
2011
|
-
seq: row.seq
|
|
2027
|
+
seq: row.seq,
|
|
2028
|
+
keyId: row.keyId
|
|
2012
2029
|
};
|
|
2013
2030
|
}
|
|
2031
|
+
/**
|
|
2032
|
+
* The chain recomputed to its last entry, for a checkpoint to sign: from
|
|
2033
|
+
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
2034
|
+
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
2035
|
+
*
|
|
2036
|
+
* Resuming never signs what the vault did not recompute. The vault
|
|
2037
|
+
* recomputed the last checkpoint's prefix itself, from entry 0 at the
|
|
2038
|
+
* hour's first checkpoint and onward at each one since, and the entry
|
|
2039
|
+
* that holds it is the first one checked again, by its MAC. The next
|
|
2040
|
+
* signature extends that same chain.
|
|
2041
|
+
*
|
|
2042
|
+
* What resuming does not do is recompute the rows of that prefix again.
|
|
2043
|
+
* They can change while the hash stored at its end stays, so it first
|
|
2044
|
+
* checks the two things that are cheap: that hash is still there, which a
|
|
2045
|
+
* prefix rewritten and chained again changes, and every entry of the
|
|
2046
|
+
* prefix is, which a cut changes. Either failing, it recomputes from entry
|
|
2047
|
+
* 0, which names what broke. An entry edited in place, its hash left as
|
|
2048
|
+
* it was, is the one change left to the next full pass, within the hour.
|
|
2049
|
+
*
|
|
2050
|
+
* It resumes only from a checkpoint under the key the vault writes with
|
|
2051
|
+
* now: once the log reaches that key, no other writes again (`forward`).
|
|
2052
|
+
* After a rotation, the next pass is full.
|
|
2053
|
+
*/
|
|
2054
|
+
async #recompute(tx) {
|
|
2055
|
+
const { logKeys } = this.#prepared;
|
|
2056
|
+
const latest = await this.#latest(tx);
|
|
2057
|
+
const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
2058
|
+
if (!(latest !== null && latest.keyId === logKeys[0].keyId && latest.seq === BigInt(latest.checkpoint.seq) + 1n && hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) && await carries(tx, latest.checkpoint) && await complete(tx, BigInt(latest.checkpoint.seq)))) return verifyChain(tx, logKeys, [], UNVERIFIED);
|
|
2059
|
+
const { seq, hash } = latest.checkpoint;
|
|
2060
|
+
return verifyChain(tx, logKeys, [], {
|
|
2061
|
+
nextSeq: BigInt(seq) + 1n,
|
|
2062
|
+
hash: Buffer.from(hash, "hex"),
|
|
2063
|
+
vaultEntries: 0,
|
|
2064
|
+
vaultKeys: [logKeys[0].keyId]
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2014
2067
|
async checkpoint() {
|
|
2015
2068
|
await this.#settled();
|
|
2016
2069
|
const { wrongKek } = this.#prepared;
|
|
2017
2070
|
const found = [];
|
|
2018
2071
|
const whole = await this.#db.transaction(async (tx) => {
|
|
2019
2072
|
await this.#sweep(tx, found);
|
|
2020
|
-
return
|
|
2073
|
+
return this.#recompute(tx);
|
|
2021
2074
|
}, SNAPSHOT);
|
|
2022
2075
|
return this.#decide([], async (d) => {
|
|
2023
2076
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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.1.
|
|
40
|
-
"@coffre/db": "0.1.
|
|
39
|
+
"@coffre/core": "0.1.15",
|
|
40
|
+
"@coffre/db": "0.1.15"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/store.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Author, StoredEntry } from '@coffre/core/audit';
|
|
|
2
2
|
import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
|
|
3
3
|
import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
4
4
|
import { clockMillis, engineOf, forUpdate } from '@coffre/db/dialect';
|
|
5
|
-
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
|
|
5
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, sql } from 'drizzle-orm';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Every query the vault makes, and the only code in it that holds them.
|
|
@@ -316,6 +316,17 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
|
|
|
316
316
|
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
317
317
|
const HASHES_AT_ONCE = 5000;
|
|
318
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Whether every entry from the first through `seq` is there. Seqs are the
|
|
321
|
+
* primary key, so the count is `seq + 1` exactly when none is missing: one
|
|
322
|
+
* pass over the key's index, never the entries.
|
|
323
|
+
*/
|
|
324
|
+
export async function complete(db: Queryable, seq: bigint): Promise<boolean> {
|
|
325
|
+
const { auditLog } = tablesOf(db);
|
|
326
|
+
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
|
|
327
|
+
return BigInt(n) === seq + 1n;
|
|
328
|
+
}
|
|
329
|
+
|
|
319
330
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
320
331
|
export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
|
|
321
332
|
const { auditLog } = tablesOf(db);
|
package/src/vault.ts
CHANGED
|
@@ -179,6 +179,13 @@ const CHECKPOINT = 'audit.checkpoint';
|
|
|
179
179
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
180
180
|
const SCHEDULER = 'system:coffre-scheduler';
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* How often a checkpoint recomputes the chain from its first entry: the
|
|
184
|
+
* first of each hour, by the vault's clock. The others resume from the
|
|
185
|
+
* prefix the last one signed (`#recompute`).
|
|
186
|
+
*/
|
|
187
|
+
const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
|
|
188
|
+
|
|
182
189
|
/** A refusal and the entries that record it. */
|
|
183
190
|
class Refused {
|
|
184
191
|
readonly refusal: Refusal;
|
|
@@ -1374,9 +1381,49 @@ class VaultService implements Vault {
|
|
|
1374
1381
|
// --- checkpoints and the log --------------------------------------------------
|
|
1375
1382
|
|
|
1376
1383
|
/** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
|
|
1377
|
-
async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint } | null> {
|
|
1384
|
+
async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint; keyId: string } | null> {
|
|
1378
1385
|
const row = await store.latestVaultEntry(db, [CHECKPOINT]);
|
|
1379
|
-
return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq };
|
|
1386
|
+
return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq, keyId: row.keyId };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* The chain recomputed to its last entry, for a checkpoint to sign: from
|
|
1391
|
+
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
1392
|
+
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
1393
|
+
*
|
|
1394
|
+
* Resuming never signs what the vault did not recompute. The vault
|
|
1395
|
+
* recomputed the last checkpoint's prefix itself, from entry 0 at the
|
|
1396
|
+
* hour's first checkpoint and onward at each one since, and the entry
|
|
1397
|
+
* that holds it is the first one checked again, by its MAC. The next
|
|
1398
|
+
* signature extends that same chain.
|
|
1399
|
+
*
|
|
1400
|
+
* What resuming does not do is recompute the rows of that prefix again.
|
|
1401
|
+
* They can change while the hash stored at its end stays, so it first
|
|
1402
|
+
* checks the two things that are cheap: that hash is still there, which a
|
|
1403
|
+
* prefix rewritten and chained again changes, and every entry of the
|
|
1404
|
+
* prefix is, which a cut changes. Either failing, it recomputes from entry
|
|
1405
|
+
* 0, which names what broke. An entry edited in place, its hash left as
|
|
1406
|
+
* it was, is the one change left to the next full pass, within the hour.
|
|
1407
|
+
*
|
|
1408
|
+
* It resumes only from a checkpoint under the key the vault writes with
|
|
1409
|
+
* now: once the log reaches that key, no other writes again (`forward`).
|
|
1410
|
+
* After a rotation, the next pass is full.
|
|
1411
|
+
*/
|
|
1412
|
+
async #recompute(tx: Queryable): Promise<{ verification: LogVerification; anchor: Anchor }> {
|
|
1413
|
+
const { logKeys } = this.#prepared;
|
|
1414
|
+
const latest = await this.#latest(tx);
|
|
1415
|
+
const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
1416
|
+
const resumable =
|
|
1417
|
+
latest !== null &&
|
|
1418
|
+
latest.keyId === logKeys[0].keyId &&
|
|
1419
|
+
latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
|
|
1420
|
+
hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
|
|
1421
|
+
(await carries(tx, latest.checkpoint)) &&
|
|
1422
|
+
(await store.complete(tx, BigInt(latest.checkpoint.seq)));
|
|
1423
|
+
if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
|
|
1424
|
+
const { seq, hash } = latest.checkpoint;
|
|
1425
|
+
const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
|
|
1426
|
+
return verifyChain(tx, logKeys, [], signed);
|
|
1380
1427
|
}
|
|
1381
1428
|
|
|
1382
1429
|
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
|
|
@@ -1386,14 +1433,14 @@ class VaultService implements Vault {
|
|
|
1386
1433
|
const { wrongKek } = this.#prepared;
|
|
1387
1434
|
// Then, in one snapshot and without the log's lock, so that no append
|
|
1388
1435
|
// waits on it: every member's row checked, since rows changed around the
|
|
1389
|
-
// vault write nothing to the log; and the
|
|
1390
|
-
//
|
|
1391
|
-
// since an entry cut from the middle leaves the hashes
|
|
1392
|
-
// checkpoint as they were.
|
|
1436
|
+
// vault write nothing to the log; and the chain recomputed, every hash
|
|
1437
|
+
// from content and every vault entry by its MAC, from the first entry
|
|
1438
|
+
// once an hour, since an entry cut from the middle leaves the hashes
|
|
1439
|
+
// around a later checkpoint as they were (`#recompute`).
|
|
1393
1440
|
const found: NewEntry[] = [];
|
|
1394
1441
|
const whole = await this.#db.transaction(async (tx) => {
|
|
1395
1442
|
await this.#sweep(tx, found);
|
|
1396
|
-
return
|
|
1443
|
+
return this.#recompute(tx);
|
|
1397
1444
|
}, SNAPSHOT);
|
|
1398
1445
|
return this.#decide([], async (d) => {
|
|
1399
1446
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|