@coffre/vault 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-DPxyTJNN.d.ts → index-CGVAd1mz.d.ts} +2 -2
- 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-DHuqw-IB.js → src-1yVV2L0_.js} +2 -2
- package/dist/{vault-DfviNOts.js → vault-3O-ynZPg.js} +360 -259
- package/package.json +3 -3
- package/src/accounting.ts +52 -49
- package/src/log.ts +8 -4
- package/src/replay.ts +6 -19
- package/src/store.ts +29 -0
- package/src/vault.ts +146 -88
package/src/vault.ts
CHANGED
|
@@ -4,13 +4,12 @@ import {
|
|
|
4
4
|
allows,
|
|
5
5
|
assignableToEnvironment,
|
|
6
6
|
isRole,
|
|
7
|
-
isSyncPrincipal,
|
|
8
7
|
mayManageAccess,
|
|
9
8
|
type Holdings,
|
|
10
9
|
type Permission,
|
|
11
10
|
type Role,
|
|
12
11
|
} from '@coffre/core/access';
|
|
13
|
-
import { verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
12
|
+
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
14
13
|
import { checkContext, type SecretContext } from '@coffre/core/envelope';
|
|
15
14
|
import {
|
|
16
15
|
DEK_BYTES,
|
|
@@ -27,7 +26,7 @@ import {
|
|
|
27
26
|
} from '@coffre/core/kek';
|
|
28
27
|
import {
|
|
29
28
|
checkpointMessage,
|
|
30
|
-
|
|
29
|
+
checkpointVerifier,
|
|
31
30
|
describeAccessFault,
|
|
32
31
|
type Access,
|
|
33
32
|
type AccessChange,
|
|
@@ -57,11 +56,11 @@ import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
|
57
56
|
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
58
57
|
import { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
|
|
59
58
|
|
|
60
|
-
import {
|
|
59
|
+
import { KeyAccounting } from './accounting.ts';
|
|
61
60
|
import { signer, type Signer } from './checkpoint.ts';
|
|
62
61
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
63
|
-
import { carries, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, type Anchor } from './log.ts';
|
|
64
|
-
import { apply,
|
|
62
|
+
import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
|
|
63
|
+
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
65
64
|
import { memberMac, rowKey, sameGrants, sealed } from './rows.ts';
|
|
66
65
|
import * as store from './store.ts';
|
|
67
66
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
@@ -149,7 +148,9 @@ const KEY_BUDGET_MS = 5_000;
|
|
|
149
148
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
150
149
|
const LOCK_TIMEOUT_MS = 15_000;
|
|
151
150
|
|
|
151
|
+
// Historical principals remain valid audit subjects and sealed rows.
|
|
152
152
|
const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
|
|
153
|
+
const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
153
154
|
|
|
154
155
|
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
155
156
|
const VAULT_ACTOR = 'system:vault';
|
|
@@ -937,6 +938,7 @@ class VaultService implements Vault {
|
|
|
937
938
|
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
938
939
|
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
939
940
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
941
|
+
if (!LIVE_PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
940
942
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
941
943
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
942
944
|
const isOwner = row.owner && principal.startsWith('user:');
|
|
@@ -954,10 +956,10 @@ class VaultService implements Vault {
|
|
|
954
956
|
if (this.#isRootAdmin(principal)) {
|
|
955
957
|
return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, isOwner: true, grants: [], since: null, by: null };
|
|
956
958
|
}
|
|
957
|
-
const active = !tampered && row?.status === 'active';
|
|
959
|
+
const active = LIVE_PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
958
960
|
return {
|
|
959
961
|
principal,
|
|
960
|
-
status: tampered ? 'tampered' : (row?.status ?? 'unknown'),
|
|
962
|
+
status: tampered ? 'tampered' : !LIVE_PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
961
963
|
generation: row?.generation ?? 0,
|
|
962
964
|
isRootAdmin: false,
|
|
963
965
|
isOwner: active && row.owner && principal.startsWith('user:'),
|
|
@@ -1033,7 +1035,7 @@ class VaultService implements Vault {
|
|
|
1033
1035
|
}]);
|
|
1034
1036
|
return this.#decide([actor, principal], async (d) => {
|
|
1035
1037
|
validateCorrelation(input);
|
|
1036
|
-
if (!
|
|
1038
|
+
if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
|
|
1037
1039
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1038
1040
|
const places = new Set<string>();
|
|
1039
1041
|
for (const change of input.changes) {
|
|
@@ -1061,32 +1063,13 @@ class VaultService implements Vault {
|
|
|
1061
1063
|
}
|
|
1062
1064
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1063
1065
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1064
|
-
if (!input.changes.every((change) => mayManageAccess(acting.live,
|
|
1066
|
+
if (!input.changes.every((change) => mayManageAccess(acting.live, change))) throw refused('not_allowed');
|
|
1065
1067
|
|
|
1066
1068
|
const row = d.members.get(principal);
|
|
1067
1069
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1068
1070
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1069
1071
|
if (row?.status === 'removed') throw refused('removed');
|
|
1070
|
-
if (row === undefined)
|
|
1071
|
-
// A sync is a member from its first grant; anyone else is admitted first.
|
|
1072
|
-
if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) {
|
|
1073
|
-
throw refused('not_a_member');
|
|
1074
|
-
}
|
|
1075
|
-
d.log.push(accessEntry(actor, 'member.add', principal, 'allow', input, { owner: false }));
|
|
1076
|
-
d.writes.push(async (at) => {
|
|
1077
|
-
await store.insertMember(d.tx, {
|
|
1078
|
-
principal,
|
|
1079
|
-
status: 'active',
|
|
1080
|
-
owner: false,
|
|
1081
|
-
generation: 0,
|
|
1082
|
-
createdAt: at,
|
|
1083
|
-
createdBy: actor,
|
|
1084
|
-
statusChangedAt: at,
|
|
1085
|
-
statusChangedBy: actor,
|
|
1086
|
-
...UNSEALED,
|
|
1087
|
-
});
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1072
|
+
if (row === undefined) throw refused('not_a_member');
|
|
1090
1073
|
// The grants the check verified, not a second read: what the decision changes, and seals.
|
|
1091
1074
|
const held = subject.stored;
|
|
1092
1075
|
d.grants.set(principal, [...held]);
|
|
@@ -1156,7 +1139,7 @@ class VaultService implements Vault {
|
|
|
1156
1139
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1157
1140
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1158
1141
|
if (!acting.live.isOwner) throw refused('not_allowed', 'only owners may add or restore members');
|
|
1159
|
-
if (!
|
|
1142
|
+
if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${principal}`);
|
|
1160
1143
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1161
1144
|
if (input.owner === true && !principal.startsWith('user:')) {
|
|
1162
1145
|
throw refused('invalid', 'service accounts cannot be owners');
|
|
@@ -1208,6 +1191,7 @@ class VaultService implements Vault {
|
|
|
1208
1191
|
new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
|
|
1209
1192
|
return this.#decide([actor, principal], async (d) => {
|
|
1210
1193
|
validateCorrelation(input);
|
|
1194
|
+
if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${principal}`);
|
|
1211
1195
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1212
1196
|
const row = d.members.get(principal);
|
|
1213
1197
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
@@ -1219,15 +1203,7 @@ class VaultService implements Vault {
|
|
|
1219
1203
|
if (!holder.isOwner) throw refused('not_allowed', 'only owners may remove a member whose record failed its check');
|
|
1220
1204
|
return this.#startOver(d, actor, principal, row, subject.fault!, input, refused);
|
|
1221
1205
|
}
|
|
1222
|
-
|
|
1223
|
-
// whoever may take away one of its grants, or manage it at its
|
|
1224
|
-
// source, may remove it, and anyone may remove one that holds nothing.
|
|
1225
|
-
const places = input.source === undefined ? held : [...held, input.source];
|
|
1226
|
-
const may =
|
|
1227
|
-
holder.isOwner ||
|
|
1228
|
-
(isSyncPrincipal(principal) &&
|
|
1229
|
-
(held.length === 0 || places.some((place) => mayManageAccess(holder, principal, { ...place, role: null }))));
|
|
1230
|
-
if (!may) throw refused('not_allowed', 'only owners may remove members');
|
|
1206
|
+
if (!holder.isOwner) throw refused('not_allowed', 'only owners may remove members');
|
|
1231
1207
|
if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
|
|
1232
1208
|
|
|
1233
1209
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
@@ -1480,94 +1456,176 @@ class VaultService implements Vault {
|
|
|
1480
1456
|
}
|
|
1481
1457
|
|
|
1482
1458
|
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
1483
|
-
return this.#verifyAll(
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
1487
|
-
async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
|
|
1488
|
-
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
|
|
1489
|
-
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
1490
|
-
return verification;
|
|
1459
|
+
return this.#verifyAll(input.upTo ?? null);
|
|
1491
1460
|
}
|
|
1492
1461
|
|
|
1493
1462
|
/**
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1463
|
+
* The whole log, in one snapshot and one pass over the vault's entries:
|
|
1464
|
+
* each by its MAC, the keys it moved through, and what the other checks
|
|
1465
|
+
* need of it, gathered as it goes (`Findings`); then every checkpoint, the
|
|
1466
|
+
* heads that must still be there, the key batches accounted for, and the
|
|
1467
|
+
* members and grants replayed.
|
|
1468
|
+
*
|
|
1469
|
+
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
1470
|
+
* chain are the app's to recompute, as it just has, over its own snapshot,
|
|
1471
|
+
* through that head; `carries(upTo)` ties this snapshot to that one. So
|
|
1472
|
+
* the vault reads only its own entries, each checked against its own hash
|
|
1473
|
+
* and MAC over the link it names. Without `upTo`, nobody else recomputed
|
|
1474
|
+
* the chain: the vault reads it all, and rehashes every link.
|
|
1500
1475
|
*/
|
|
1501
|
-
async #verifyAll(
|
|
1476
|
+
async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
|
|
1502
1477
|
await this.#settled();
|
|
1503
1478
|
return this.#db.transaction(async (tx) => {
|
|
1504
1479
|
const remembered = this.#prepared.verified;
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
if (
|
|
1480
|
+
const findings = new Findings();
|
|
1481
|
+
const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
|
|
1482
|
+
if (!read.ok) return read;
|
|
1483
|
+
// Each head the log must still hold, in one read: the app's, every checkpoint's, and the last this vault rehashed.
|
|
1484
|
+
const heads: LogHead[] = [...(upTo === null ? [] : [upTo]), ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
|
|
1485
|
+
const rememberedHead = remembered.nextSeq > 0n ? { seq: Number(remembered.nextSeq - 1n), hash: remembered.hash.toString('hex') } : null;
|
|
1486
|
+
if (rememberedHead !== null) heads.push(rememberedHead);
|
|
1487
|
+
const hashes = await store.hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
|
|
1488
|
+
const holds = (head: LogHead) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
|
|
1489
|
+
if (upTo !== null && !holds(upTo)) {
|
|
1508
1490
|
return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
|
|
1509
1491
|
}
|
|
1510
|
-
const unsigned = await this.#checkpointFault(
|
|
1492
|
+
const unsigned = await this.#checkpointFault(findings, holds);
|
|
1511
1493
|
if (unsigned !== null) return unsigned;
|
|
1512
1494
|
// What this vault last verified must still be there: whoever holds
|
|
1513
1495
|
// its key can seal a rewrite, but cannot put back the head it saw. A
|
|
1514
1496
|
// checkpoint names an earlier break, so it is reported first.
|
|
1515
|
-
if (
|
|
1516
|
-
|
|
1517
|
-
if (!(await carries(tx, { seq: Number(seq), hash: remembered.hash.toString('hex') }))) {
|
|
1518
|
-
return { ok: false, failedAtSeq: Number(seq), reason: 'changed since the vault last verified it' };
|
|
1519
|
-
}
|
|
1497
|
+
if (rememberedHead !== null && !holds(rememberedHead)) {
|
|
1498
|
+
return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
|
|
1520
1499
|
}
|
|
1521
1500
|
const at = await this.#now(tx);
|
|
1522
|
-
const accounting =
|
|
1501
|
+
const accounting = findings.accounting.result(at);
|
|
1523
1502
|
if (!accounting.ok) return accounting;
|
|
1524
|
-
const
|
|
1503
|
+
const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
|
|
1504
|
+
const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
|
|
1525
1505
|
if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
|
|
1526
|
-
|
|
1506
|
+
const verified = { ok: true as const, entries: findings.entries };
|
|
1507
|
+
return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
|
|
1527
1508
|
}, SNAPSHOT);
|
|
1528
1509
|
}
|
|
1529
1510
|
|
|
1530
1511
|
/**
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
1534
|
-
* and one under a key it replaced comes before the rotation.
|
|
1512
|
+
* The vault's own entries, oldest first: each against its own hash, and by
|
|
1513
|
+
* its MAC, over the link it names, which the app checked; and the vault's
|
|
1514
|
+
* keys moving only forward through them.
|
|
1535
1515
|
*/
|
|
1536
|
-
async #
|
|
1516
|
+
async #ownEntries(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
|
|
1517
|
+
const { logKeys } = this.#prepared;
|
|
1518
|
+
let keys: readonly string[] = [];
|
|
1537
1519
|
for (let after = -1n; ; ) {
|
|
1538
|
-
const batch = await store.
|
|
1520
|
+
const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
1539
1521
|
for (const entry of batch) {
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
1522
|
+
const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
|
|
1523
|
+
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
1524
|
+
}
|
|
1525
|
+
const moved = forward(batch, keys, logKeys[0]!.keyId);
|
|
1526
|
+
if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
1527
|
+
keys = moved.keys;
|
|
1528
|
+
for (const entry of batch) findings.add(entry);
|
|
1529
|
+
if (batch.length < VERIFY_BATCH) return { ok: true };
|
|
1530
|
+
after = batch[batch.length - 1]!.seq;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
1535
|
+
async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
|
|
1536
|
+
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
1537
|
+
for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
|
|
1538
|
+
});
|
|
1539
|
+
if (!verification.ok) return verification;
|
|
1540
|
+
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
1541
|
+
return { ok: true };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
1546
|
+
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
1547
|
+
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
1548
|
+
* vault replaced comes before the rotation.
|
|
1549
|
+
*/
|
|
1550
|
+
async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
|
|
1551
|
+
const { logKeys, signers } = this.#prepared;
|
|
1552
|
+
// Each signer's key imported once, for this check only.
|
|
1553
|
+
const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
|
|
1554
|
+
const signed = (checkpoint: Checkpoint, by: Signer) => {
|
|
1555
|
+
let verifier = verifiers.get(by.keyId);
|
|
1556
|
+
if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
|
|
1557
|
+
return verifier.then((verify) => verify(checkpoint));
|
|
1558
|
+
};
|
|
1559
|
+
const { checkpoints } = findings;
|
|
1560
|
+
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
1561
|
+
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
1562
|
+
// The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
|
|
1563
|
+
const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
|
|
1543
1564
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
1544
|
-
if (by?.keyId !== checkpoint.keyId) return
|
|
1545
|
-
if (!(await
|
|
1546
|
-
if (BigInt(checkpoint.seq) >= entry.seq || !(
|
|
1547
|
-
return
|
|
1565
|
+
if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
|
|
1566
|
+
if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
|
|
1567
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
|
|
1568
|
+
return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
1548
1569
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1570
|
+
return null;
|
|
1571
|
+
}));
|
|
1572
|
+
const at = faults.findIndex((fault) => fault !== null);
|
|
1573
|
+
if (at !== -1) return { ok: false, failedAtSeq: Number(chunk[at]!.entry.seq), reason: faults[at]! };
|
|
1552
1574
|
}
|
|
1575
|
+
return null;
|
|
1553
1576
|
}
|
|
1554
1577
|
|
|
1555
1578
|
/**
|
|
1556
1579
|
* The first member whose row fails its MAC, or names an older access
|
|
1557
|
-
* entry than the log's newest about them
|
|
1558
|
-
*
|
|
1580
|
+
* entry than the log's newest about them (`newest`, from the full check's
|
|
1581
|
+
* pass, where each entry's MAC was checked).
|
|
1559
1582
|
*/
|
|
1560
|
-
|
|
1561
|
-
const [rows, held, newest] = await Promise.all([store.allMembers(db), store.grants(db), store.newestAccessEntries(db)]);
|
|
1583
|
+
#unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
|
|
1562
1584
|
for (const row of rows) {
|
|
1563
1585
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1564
1586
|
if (!this.#sealed(row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
|
|
1565
|
-
if (newest.get(row.principal)
|
|
1587
|
+
if (newest.get(row.principal) !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
|
|
1566
1588
|
}
|
|
1567
1589
|
return null;
|
|
1568
1590
|
}
|
|
1569
1591
|
}
|
|
1570
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* What one pass over the vault's entries gathers for the other checks of a
|
|
1595
|
+
* full verification, so that the log is read once: the key batches, the
|
|
1596
|
+
* access entries replayed, each member's newest, and the checkpoints. Each
|
|
1597
|
+
* entry comes after its MAC was checked.
|
|
1598
|
+
*/
|
|
1599
|
+
class Findings {
|
|
1600
|
+
readonly accounting = new KeyAccounting();
|
|
1601
|
+
readonly replayed: Replayed = { members: new Map(), held: new Map() };
|
|
1602
|
+
/** The first access entry the replay could not apply. */
|
|
1603
|
+
replayFault: AccessFault | null = null;
|
|
1604
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
1605
|
+
readonly newestAccess = new Map<string, bigint>();
|
|
1606
|
+
/** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
|
|
1607
|
+
readonly checkpoints: { entry: { seq: bigint; keyId: string }; checkpoint: Checkpoint }[] = [];
|
|
1608
|
+
/** The vault's entries read. */
|
|
1609
|
+
entries = 0;
|
|
1610
|
+
|
|
1611
|
+
add(entry: StoredEntry): void {
|
|
1612
|
+
this.entries += 1;
|
|
1613
|
+
this.accounting.add(entry);
|
|
1614
|
+
if (entry.decision !== 'allow') return;
|
|
1615
|
+
if (ACCESS.has(entry.action)) {
|
|
1616
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
1617
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
1618
|
+
} else if (entry.action === CHECKPOINT) {
|
|
1619
|
+
this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const ACCESS = new Set<string>(ACCESS_ACTIONS);
|
|
1625
|
+
|
|
1626
|
+
/** Checkpoint signatures checked at once, in a full verification. */
|
|
1627
|
+
const SIGNATURES_AT_ONCE = 256;
|
|
1628
|
+
|
|
1571
1629
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
1572
1630
|
async function settle<T extends { wipe: () => void }>(
|
|
1573
1631
|
operations: ((operation: KeyOperation) => Promise<T | null>)[],
|