@coffre/vault 0.9.0 → 0.10.1
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 +11 -11
- package/dist/cloudflare.js +8 -2
- package/dist/{index-BkHJR9Gt.d.ts → index-CByXzyi-.d.ts} +7 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +13 -2
- package/dist/node.js +4 -2
- package/dist/{src-BcttALFQ.js → src-D82jyprb.js} +2 -6
- package/dist/{vault-lBbyZ3kQ.js → vault-B0YiPBgi.js} +1460 -471
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/cloudflare.ts +3 -0
- package/src/config.ts +12 -19
- package/src/index.ts +1 -1
- package/src/local.ts +2 -0
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/store.ts +179 -22
- package/src/tally.ts +241 -0
- package/src/vault.ts +831 -254
package/src/store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre/core/access';
|
|
2
2
|
import type { Author, StoredEntry } from '@coffre/core/audit';
|
|
3
3
|
import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
|
|
4
|
-
import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
4
|
+
import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
5
5
|
import { clockMillis, engineOf, forUpdate, 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';
|
|
@@ -61,11 +61,12 @@ export async function boundLockWaits(tx: Transaction, ms: number): Promise<void>
|
|
|
61
61
|
|
|
62
62
|
/** The database's clock, now. */
|
|
63
63
|
export async function now(db: Queryable): Promise<number> {
|
|
64
|
-
const {
|
|
65
|
-
const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
|
|
64
|
+
const [{ at }] = await clock(db);
|
|
66
65
|
return at;
|
|
67
66
|
}
|
|
68
67
|
|
|
68
|
+
const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
69
|
+
|
|
69
70
|
// --- members --------------------------------------------------------------------
|
|
70
71
|
|
|
71
72
|
function memberColumns(db: Queryable) {
|
|
@@ -101,11 +102,15 @@ function storedChange(change: Partial<Omit<Member, 'principal'>>): Record<string
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export async function member(db: Queryable, principal: string): Promise<Member | undefined> {
|
|
104
|
-
const
|
|
105
|
-
const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
|
|
105
|
+
const [row] = await memberRow(db, { principal });
|
|
106
106
|
return row === undefined ? undefined : memberOf(row as StoredMember);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const memberRow = prepared((db) => {
|
|
110
|
+
const { vaultMembers } = tablesOf(db);
|
|
111
|
+
return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder('principal')));
|
|
112
|
+
});
|
|
113
|
+
|
|
109
114
|
export async function allMembers(db: Queryable): Promise<Member[]> {
|
|
110
115
|
const { vaultMembers } = tablesOf(db);
|
|
111
116
|
return ((await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as StoredMember[]).map(memberOf);
|
|
@@ -312,24 +317,28 @@ export const RELEASE = 'secret.read';
|
|
|
312
317
|
* several (`secrets`), a read logged once per place.
|
|
313
318
|
*/
|
|
314
319
|
export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
|
|
320
|
+
const [{ n }] = await released(db, { principal, after });
|
|
321
|
+
return Number(n ?? 0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const released = prepared((db) => {
|
|
315
325
|
const { auditLog } = tablesOf(db);
|
|
316
326
|
const listed = engineOf(db) === 'postgres'
|
|
317
327
|
? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
|
|
318
328
|
: sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
319
|
-
|
|
329
|
+
return db
|
|
320
330
|
.select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
|
|
321
331
|
.from(auditLog)
|
|
322
332
|
.where(
|
|
323
333
|
and(
|
|
324
334
|
eq(auditLog.author, 'vault'),
|
|
325
|
-
eq(auditLog.actor, principal),
|
|
335
|
+
eq(auditLog.actor, sql.placeholder('principal')),
|
|
326
336
|
eq(auditLog.action, RELEASE),
|
|
327
337
|
eq(auditLog.decision, 'allow'),
|
|
328
|
-
gt(auditLog.occurredAt, after),
|
|
338
|
+
gt(auditLog.occurredAt, sql.placeholder('after')),
|
|
329
339
|
),
|
|
330
340
|
);
|
|
331
|
-
|
|
332
|
-
}
|
|
341
|
+
});
|
|
333
342
|
|
|
334
343
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
335
344
|
function stored(rows: object[]): StoredEntry[] {
|
|
@@ -380,17 +389,6 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
|
|
|
380
389
|
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
381
390
|
const HASHES_AT_ONCE = 5000;
|
|
382
391
|
|
|
383
|
-
/**
|
|
384
|
-
* Whether every entry from the first through `seq` is there. Seqs are the
|
|
385
|
-
* primary key, so the count is `seq + 1` exactly when none is missing: one
|
|
386
|
-
* pass over the key's index, never the entries.
|
|
387
|
-
*/
|
|
388
|
-
export async function complete(db: Queryable, seq: bigint): Promise<boolean> {
|
|
389
|
-
const { auditLog } = tablesOf(db);
|
|
390
|
-
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
|
|
391
|
-
return BigInt(n) === seq + 1n;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
392
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
395
393
|
export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
|
|
396
394
|
const { auditLog } = tablesOf(db);
|
|
@@ -451,6 +449,13 @@ export async function endedReferences(db: Queryable, seqs: readonly bigint[]): P
|
|
|
451
449
|
}
|
|
452
450
|
|
|
453
451
|
/** The project each environment is in: a holder's ids, checked against each other. */
|
|
452
|
+
/** Whether a project of this id is there, and not deleted. */
|
|
453
|
+
export async function projectLive(db: Queryable, id: string): Promise<boolean> {
|
|
454
|
+
const { projects } = tablesOf(db);
|
|
455
|
+
const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
|
|
456
|
+
return rows.length > 0 && !rows[0]!.deleted;
|
|
457
|
+
}
|
|
458
|
+
|
|
454
459
|
export async function projectsOfEnvironments(db: Queryable, ids: readonly string[]): Promise<Map<string, string>> {
|
|
455
460
|
if (ids.length === 0) return new Map();
|
|
456
461
|
const { environments } = tablesOf(db);
|
|
@@ -481,6 +486,43 @@ export async function secretsByIds(db: Queryable, ids: readonly string[]): Promi
|
|
|
481
486
|
return new Map(rows.map(({ project, environment, key, ...row }) => [row.secretId, { ...row, path: `${project}/${environment}/${key}` }]));
|
|
482
487
|
}
|
|
483
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Each secret's nature, as its current version has it: plain where its
|
|
491
|
+
* value is stored as it is, sensitive where it is bound so, and otherwise
|
|
492
|
+
* secret, as is one with no value of its own.
|
|
493
|
+
*/
|
|
494
|
+
export async function natures(db: Queryable, secretIds: readonly string[]): Promise<Map<string, 'plain' | 'secret' | 'sensitive'>> {
|
|
495
|
+
if (secretIds.length === 0) return new Map();
|
|
496
|
+
const { secrets, secretVersions } = tablesOf(db);
|
|
497
|
+
const rows = await db
|
|
498
|
+
.select({ id: secrets.id, sensitive: secretVersions.sensitive, plain: truth(isNotNull(secretVersions.plaintext)) })
|
|
499
|
+
.from(secrets)
|
|
500
|
+
.innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId))
|
|
501
|
+
.where(inArray(secrets.id, [...new Set(secretIds)]));
|
|
502
|
+
return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? 'sensitive' : Boolean(row.plain) ? 'plain' : 'secret']));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* For each version a rewrap would bring back into its secret, its nature
|
|
507
|
+
* as stored when it is an old value, not the secret's current one; null
|
|
508
|
+
* for the current one, which a mark wraps again with no value changing.
|
|
509
|
+
*/
|
|
510
|
+
export async function restoredNatures(
|
|
511
|
+
db: Queryable,
|
|
512
|
+
items: readonly { secretId: string; from: string }[],
|
|
513
|
+
): Promise<('plain' | 'secret' | 'sensitive' | null)[]> {
|
|
514
|
+
if (items.length === 0) return [];
|
|
515
|
+
const { secrets, secretVersions } = tablesOf(db);
|
|
516
|
+
const [current, versions] = await Promise.all([
|
|
517
|
+
db.select({ id: secrets.id, version: secrets.currentVersionId }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])),
|
|
518
|
+
db.select({ id: secretVersions.id, sensitive: secretVersions.sensitive, plain: truth(isNotNull(secretVersions.plaintext)) }).from(secretVersions)
|
|
519
|
+
.where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))])),
|
|
520
|
+
]);
|
|
521
|
+
const currentOf = new Map(current.map((row) => [row.id, row.version]));
|
|
522
|
+
const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? 'sensitive' as const : Boolean(row.plain) ? 'plain' as const : 'secret' as const]));
|
|
523
|
+
return items.map(({ secretId, from }) => (currentOf.get(secretId) === from ? null : natureOf.get(from) ?? 'secret'));
|
|
524
|
+
}
|
|
525
|
+
|
|
484
526
|
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
485
527
|
export async function latestVaultEntry(db: Queryable, actions: readonly string[]): Promise<StoredEntry | undefined> {
|
|
486
528
|
const { auditLog } = tablesOf(db);
|
|
@@ -493,6 +535,82 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
|
|
|
493
535
|
return row === undefined ? undefined : stored([row])[0];
|
|
494
536
|
}
|
|
495
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The vault's newest checkpoint that keeps a tally, allowed, or undefined:
|
|
540
|
+
* one written since 0.10.1, which names the last entry of the prefix it
|
|
541
|
+
* signs as related (`related_seq`); those before name none.
|
|
542
|
+
*/
|
|
543
|
+
export async function latestTallied(db: Queryable): Promise<StoredEntry | undefined> {
|
|
544
|
+
const { auditLog } = tablesOf(db);
|
|
545
|
+
const [row] = await db
|
|
546
|
+
.select(entryColumns(db))
|
|
547
|
+
.from(auditLog)
|
|
548
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq)))
|
|
549
|
+
.orderBy(desc(auditLog.seq))
|
|
550
|
+
.limit(1);
|
|
551
|
+
return row === undefined ? undefined : stored([row])[0];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
|
|
555
|
+
export async function newestCheckpointEntry(db: Queryable): Promise<StoredEntry | undefined> {
|
|
556
|
+
const { auditLog } = tablesOf(db);
|
|
557
|
+
const [row] = await db
|
|
558
|
+
.select(entryColumns(db))
|
|
559
|
+
.from(auditLog)
|
|
560
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint')))
|
|
561
|
+
.orderBy(desc(auditLog.seq))
|
|
562
|
+
.limit(1);
|
|
563
|
+
return row === undefined ? undefined : stored([row])[0];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The vault's checkpoints that keep a tally after entry `after` and through
|
|
568
|
+
* `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
|
|
569
|
+
* holds, but always one. A reader asks again after the last.
|
|
570
|
+
*/
|
|
571
|
+
export async function talliedWithin(db: Queryable, after: bigint, through: bigint, limit: number): Promise<StoredEntry[]> {
|
|
572
|
+
const { auditLog } = tablesOf(db);
|
|
573
|
+
const tallied = and(
|
|
574
|
+
eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq),
|
|
575
|
+
gt(auditLog.seq, after), lte(auditLog.seq, through),
|
|
576
|
+
);
|
|
577
|
+
const batch = await withinBytes(db, tallied, limit);
|
|
578
|
+
if (batch === null) return [];
|
|
579
|
+
const rows = await db.select(entryColumns(db)).from(auditLog)
|
|
580
|
+
.where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq));
|
|
581
|
+
return stored(rows);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
|
|
585
|
+
export async function newestRefusal(db: Queryable, action: string, code: string, after: bigint): Promise<StoredEntry | undefined> {
|
|
586
|
+
const { auditLog } = tablesOf(db);
|
|
587
|
+
const [row] = await db
|
|
588
|
+
.select(entryColumns(db))
|
|
589
|
+
.from(auditLog)
|
|
590
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, 'deny'), eq(auditLog.code, code)))
|
|
591
|
+
.orderBy(desc(auditLog.seq))
|
|
592
|
+
.limit(1);
|
|
593
|
+
return row === undefined ? undefined : stored([row])[0];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
|
|
597
|
+
export async function talliedAfter(db: Queryable, after: bigint, limit: number): Promise<{ seq: bigint; signs: bigint }[]> {
|
|
598
|
+
const { auditLog } = tablesOf(db);
|
|
599
|
+
const rows = await db
|
|
600
|
+
.select({ seq: auditLog.seq, signs: auditLog.relatedSeq })
|
|
601
|
+
.from(auditLog)
|
|
602
|
+
.where(and(
|
|
603
|
+
eq(auditLog.author, 'vault'),
|
|
604
|
+
eq(auditLog.action, 'audit.checkpoint'),
|
|
605
|
+
eq(auditLog.decision, 'allow'),
|
|
606
|
+
isNotNull(auditLog.relatedSeq),
|
|
607
|
+
gt(auditLog.seq, after),
|
|
608
|
+
))
|
|
609
|
+
.orderBy(asc(auditLog.seq))
|
|
610
|
+
.limit(limit);
|
|
611
|
+
return rows.map(({ seq, signs }) => ({ seq, signs: signs! }));
|
|
612
|
+
}
|
|
613
|
+
|
|
496
614
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
497
615
|
export async function newestAccessEntries(db: Queryable): Promise<Map<string, StoredEntry>> {
|
|
498
616
|
const { auditLog } = tablesOf(db);
|
|
@@ -531,6 +649,31 @@ export async function vaultEntriesOf(
|
|
|
531
649
|
return stored(rows);
|
|
532
650
|
}
|
|
533
651
|
|
|
652
|
+
/** Which of these secrets' newest versions are sensitive, by the versions' own order. */
|
|
653
|
+
export async function newestSensitive(db: Queryable, secretIds: readonly string[]): Promise<Set<string>> {
|
|
654
|
+
if (secretIds.length === 0) return new Set();
|
|
655
|
+
const { secretVersions } = tablesOf(db);
|
|
656
|
+
const rows = await db
|
|
657
|
+
.select({ secretId: secretVersions.secretId })
|
|
658
|
+
.from(secretVersions)
|
|
659
|
+
.where(and(
|
|
660
|
+
inArray(secretVersions.secretId, [...new Set(secretIds)]),
|
|
661
|
+
eq(secretVersions.sensitive, true),
|
|
662
|
+
sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
|
|
663
|
+
));
|
|
664
|
+
return new Set(rows.map((row) => row.secretId));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** How many grants name `role`: all of them, or those on environments. */
|
|
668
|
+
export async function grantsNaming(db: Queryable, role: string, filter: { onEnvironments?: boolean } = {}): Promise<number> {
|
|
669
|
+
const { vaultGrants } = tablesOf(db);
|
|
670
|
+
const rows = await db
|
|
671
|
+
.select({ count: sql<number | string>`count(*)` })
|
|
672
|
+
.from(vaultGrants)
|
|
673
|
+
.where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : undefined));
|
|
674
|
+
return Number(rows[0]?.count ?? 0);
|
|
675
|
+
}
|
|
676
|
+
|
|
534
677
|
/**
|
|
535
678
|
* A version that still holds its wrapped key. A deleted place's versions are
|
|
536
679
|
* erased, their keys emptied: there is nothing left to open, or to try a
|
|
@@ -546,7 +689,16 @@ function sealed(wrappedDek: SQLWrapper): SQL {
|
|
|
546
689
|
* one, as a read through a reference needs. An erased version outside a
|
|
547
690
|
* deleted place is no version.
|
|
548
691
|
*/
|
|
549
|
-
export type SecretVersion = {
|
|
692
|
+
export type SecretVersion = {
|
|
693
|
+
id: string;
|
|
694
|
+
/** Its secret, `sensitive` as the version was written. */
|
|
695
|
+
secret: SecretRef;
|
|
696
|
+
wrapped: WrappedKey;
|
|
697
|
+
deleted: boolean;
|
|
698
|
+
current: boolean;
|
|
699
|
+
/** Whether its secret's newest version is sensitive: what a rewrap of it may not loosen. */
|
|
700
|
+
newestSensitive: boolean;
|
|
701
|
+
};
|
|
550
702
|
|
|
551
703
|
export async function versions(db: Queryable, ids: readonly string[]): Promise<SecretVersion[]> {
|
|
552
704
|
if (ids.length === 0) return [];
|
|
@@ -567,8 +719,10 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
|
|
|
567
719
|
wrappedDek: secretVersions.wrappedDek,
|
|
568
720
|
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
|
|
569
721
|
currentVersionId: secrets.currentVersionId,
|
|
722
|
+
sensitive: secretVersions.sensitive,
|
|
570
723
|
// The newest by the versions' own order: the app may write `current_version_id`, never a version.
|
|
571
724
|
newest: sql<number | string>`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
|
|
725
|
+
newestSensitive: sql<boolean | number>`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`,
|
|
572
726
|
})
|
|
573
727
|
.from(secretVersions)
|
|
574
728
|
.innerJoin(secrets, eq(secrets.id, secretVersions.secretId))
|
|
@@ -584,6 +738,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
|
|
|
584
738
|
secretId: row.secretId,
|
|
585
739
|
version: row.version,
|
|
586
740
|
path: `${row.project}/${row.environment}/${row.key}`,
|
|
741
|
+
sensitive: row.sensitive,
|
|
587
742
|
},
|
|
588
743
|
wrapped: {
|
|
589
744
|
kekProvider: row.kekProvider,
|
|
@@ -592,5 +747,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
|
|
|
592
747
|
bytes: row.wrappedDek.toString('base64'),
|
|
593
748
|
},
|
|
594
749
|
current: row.currentVersionId === row.id && Number(row.newest) === row.version,
|
|
750
|
+
// SQLite answers a subquery's boolean as a number.
|
|
751
|
+
newestSensitive: Boolean(row.newestSensitive),
|
|
595
752
|
}));
|
|
596
753
|
}
|
package/src/tally.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { StoredEntry } from '@coffre/core/audit';
|
|
4
|
+
import type { AccessFault, Checkpoint, LogHead } from '@coffre/core/vault';
|
|
5
|
+
|
|
6
|
+
import { KeyAccounting, type AccountingState } from './accounting.ts';
|
|
7
|
+
import { forward } from './log.ts';
|
|
8
|
+
import { apply, placeKey, type LoggedMember, type Replayed } from './replay.ts';
|
|
9
|
+
import { ACCESS_ACTIONS, type GrantRow } from './store.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a walk of the vault's entries from the first has gathered by some
|
|
13
|
+
* entry: all a walk from there needs to go on as if it had started at the
|
|
14
|
+
* first. A checkpoint keeps the tally of the prefix it signs, so that the
|
|
15
|
+
* next checkpoint, and every verification, start from it and read only the
|
|
16
|
+
* entries since (docs/architecture.md, "The tally").
|
|
17
|
+
*
|
|
18
|
+
* - `entries`: the vault's entries so far;
|
|
19
|
+
* - `keys`: the vault's log keys, in the order its entries moved through
|
|
20
|
+
* them, which only moves forward (`forward` in log.ts);
|
|
21
|
+
* - `accounting`: the key batches still under way, and the first fault in
|
|
22
|
+
* their entries;
|
|
23
|
+
* - `access`: who the log says is a member, what each holds, and each one's
|
|
24
|
+
* newest access entry, which their row must name;
|
|
25
|
+
* - `replayFault`: the first access entry about someone never admitted.
|
|
26
|
+
*
|
|
27
|
+
* The faults are kept, once found, as a walk from the first entry would
|
|
28
|
+
* find them again each time.
|
|
29
|
+
*/
|
|
30
|
+
export type Tally = {
|
|
31
|
+
entries: number;
|
|
32
|
+
keys: string[];
|
|
33
|
+
accounting: AccountingState;
|
|
34
|
+
access: AccessTally;
|
|
35
|
+
replayFault: AccessFault | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Members and grants as the log says they are, and each member's newest access entry, as plain JSON. */
|
|
39
|
+
export type AccessTally = {
|
|
40
|
+
members: (Omit<LoggedMember, 'accessSeq'> & { accessSeq: number })[];
|
|
41
|
+
grants: GrantRow[];
|
|
42
|
+
newest: [principal: string, seq: number][];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A tally as a checkpoint's entry holds it. Members and grants change far
|
|
47
|
+
* less often than every five minutes, so `access` is written whole only
|
|
48
|
+
* when it changed since the tally resumed from; otherwise the entry names
|
|
49
|
+
* the checkpoint entry that holds it (`at`). Either way with its digest.
|
|
50
|
+
*/
|
|
51
|
+
export type StoredTally = Omit<Tally, 'access'> & { v: typeof TALLY_VERSION; access: { digest: string } & ({ at: number } | AccessTally) };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a tally keeps, as 0.10.1 wrote it. A release that keeps more, or
|
|
55
|
+
* less, writes another version, and compares a tally of this one on what
|
|
56
|
+
* this one kept: a walk from the first entry passes every tally ever
|
|
57
|
+
* written, and an honest one must still match. A release rolled back to
|
|
58
|
+
* reads no tally newer than its own, and refuses the checkpoints that
|
|
59
|
+
* keep one: going back across a version is not a way back.
|
|
60
|
+
*/
|
|
61
|
+
export const TALLY_VERSION = 1;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A checkpoint as the vault's entry holds it: the signed prefix, its
|
|
65
|
+
* tally, and the lap of the rehash of the past under way (`scrub`): the
|
|
66
|
+
* prefix it rehashes to, signed when the lap began, and the last entry it
|
|
67
|
+
* rehashed. Null between laps.
|
|
68
|
+
*/
|
|
69
|
+
export type TalliedCheckpoint = Checkpoint & { tally: StoredTally; scrub: { at: LogHead; end: LogHead } | null };
|
|
70
|
+
|
|
71
|
+
/** Before the first entry. */
|
|
72
|
+
export const EMPTY_TALLY: Tally = {
|
|
73
|
+
entries: 0,
|
|
74
|
+
keys: [],
|
|
75
|
+
accounting: { intents: [], fault: null },
|
|
76
|
+
access: { members: [], grants: [], newest: [] },
|
|
77
|
+
replayFault: null,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The digest of what `access` says, over its canonical form. */
|
|
81
|
+
export function accessDigest(access: AccessTally): string {
|
|
82
|
+
return createHash('sha256').update(canonical(access)).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What two tallies are compared by, so that they match whatever order
|
|
87
|
+
* their maps were built in: the members and grants by their digest, which
|
|
88
|
+
* a stretch passing many checkpoints computes once a change; and a fault by
|
|
89
|
+
* where it was found, since the words for it may change from one release
|
|
90
|
+
* to the next.
|
|
91
|
+
*/
|
|
92
|
+
function tallyKey({ accounting, entries, keys, replayFault }: Omit<Tally, 'access'>, digest: string): string {
|
|
93
|
+
return canonical({ entries, keys, replayFault, accounting: { ...accounting, fault: accounting.fault?.failedAtSeq ?? null }, access: digest });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A tally a walk must come to: the one a checkpoint keeps, `entry`, of the
|
|
98
|
+
* prefix through `through`; or why that checkpoint's tally cannot be read.
|
|
99
|
+
*/
|
|
100
|
+
export type Expected = { entry: bigint; through: bigint; key: string } | { entry: bigint; through: bigint; unreadable: string };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a walk must come to at a checkpoint, from the tally it keeps: its
|
|
104
|
+
* members and grants by the digest it keeps of them, never read.
|
|
105
|
+
*/
|
|
106
|
+
export function expectedKey(stored: StoredTally): string {
|
|
107
|
+
return tallyKey(stored, stored.access.digest);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The vault's entries walked, oldest first, each after its MAC was checked,
|
|
112
|
+
* gathered into what the checks after the walk need: the tally it started
|
|
113
|
+
* from, carried on.
|
|
114
|
+
*/
|
|
115
|
+
export class Walk {
|
|
116
|
+
readonly accounting: KeyAccounting;
|
|
117
|
+
readonly replayed: Replayed;
|
|
118
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
119
|
+
readonly newestAccess: Map<string, bigint>;
|
|
120
|
+
/** The first access entry the replay could not apply. */
|
|
121
|
+
replayFault: AccessFault | null;
|
|
122
|
+
/** The vault's entries so far, those before the walk included. */
|
|
123
|
+
entries: number;
|
|
124
|
+
/** The vault's log keys, as the log moved through them. */
|
|
125
|
+
keys: readonly string[];
|
|
126
|
+
/** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
|
|
127
|
+
readonly checkpoints: { entry: { seq: bigint; keyId: string; relatedSeq: bigint | null }; checkpoint: Checkpoint }[] = [];
|
|
128
|
+
|
|
129
|
+
/** The tallies the walk must come to as it passes them, oldest first (`expect`). */
|
|
130
|
+
#expected: Expected[] = [];
|
|
131
|
+
/** The first it did not come to, and why. */
|
|
132
|
+
mismatch: { entry: bigint; reason: string } | null = null;
|
|
133
|
+
/** The digest of the members and grants as the walk has them, until an access entry changes them. */
|
|
134
|
+
#digest: string | null = null;
|
|
135
|
+
|
|
136
|
+
/** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
|
|
137
|
+
digest(): string {
|
|
138
|
+
return (this.#digest ??= accessDigest(this.tally().access));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The tally but for its members and grants, which `digest` stands for. */
|
|
142
|
+
#unaccessed(): Omit<Tally, 'access'> {
|
|
143
|
+
return { entries: this.entries, keys: [...this.keys], accounting: this.accounting.state(), replayFault: this.replayFault };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
|
|
147
|
+
constructor(from: Tally, digest?: string) {
|
|
148
|
+
this.#digest = digest ?? null;
|
|
149
|
+
this.accounting = new KeyAccounting(from.accounting);
|
|
150
|
+
this.replayed = {
|
|
151
|
+
members: new Map(from.access.members.map((member) => [member.principal, { ...member, accessSeq: BigInt(member.accessSeq) }])),
|
|
152
|
+
held: new Map(),
|
|
153
|
+
};
|
|
154
|
+
for (const grant of from.access.grants) {
|
|
155
|
+
const held = this.replayed.held.get(grant.principal) ?? new Map<string, GrantRow>();
|
|
156
|
+
this.replayed.held.set(grant.principal, held);
|
|
157
|
+
held.set(placeKey(grant), { ...grant });
|
|
158
|
+
}
|
|
159
|
+
this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
|
|
160
|
+
this.replayFault = from.replayFault;
|
|
161
|
+
this.entries = from.entries;
|
|
162
|
+
this.keys = from.keys;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hold the walk to these tallies, each as the walk stands once it has
|
|
167
|
+
* read every entry through the prefix's end: before the vault's next
|
|
168
|
+
* entry, since only the vault's change a tally, or at `finish`.
|
|
169
|
+
*/
|
|
170
|
+
expect(expected: readonly Expected[]): void {
|
|
171
|
+
this.#expected = [...expected].sort((a, b) => (a.through < b.through ? -1 : 1));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The tallies expected through `through`, held to the walk. */
|
|
175
|
+
finish(through: bigint): void {
|
|
176
|
+
this.#reach(through + 1n);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#reach(before: bigint): void {
|
|
180
|
+
while (this.#expected.length > 0 && this.#expected[0]!.through < before) {
|
|
181
|
+
const expected = this.#expected.shift()!;
|
|
182
|
+
if (this.mismatch !== null) continue;
|
|
183
|
+
if ('unreadable' in expected) this.mismatch = { entry: expected.entry, reason: expected.unreadable };
|
|
184
|
+
else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) {
|
|
185
|
+
this.mismatch = { entry: expected.entry, reason: 'the tally this checkpoint keeps does not follow from the log before it' };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* One of the vault's entries, its MAC checked, in the log's order: a
|
|
192
|
+
* fault when it is under a key the vault had moved past (`forward`).
|
|
193
|
+
*/
|
|
194
|
+
add(entry: StoredEntry, current: string): { failedAtSeq: bigint; reason: string } | null {
|
|
195
|
+
this.#reach(entry.seq);
|
|
196
|
+
const moved = forward([entry], this.keys, current);
|
|
197
|
+
if ('failedAtSeq' in moved) return moved;
|
|
198
|
+
this.keys = moved.keys;
|
|
199
|
+
this.entries += 1;
|
|
200
|
+
this.accounting.add(entry);
|
|
201
|
+
if (entry.decision !== 'allow') return null;
|
|
202
|
+
if (ACCESS.has(entry.action)) {
|
|
203
|
+
this.#digest = null;
|
|
204
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
205
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
206
|
+
} else if (entry.action === CHECKPOINT) {
|
|
207
|
+
const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata) as Checkpoint;
|
|
208
|
+
this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId, relatedSeq: entry.relatedSeq }, checkpoint: { seq, hash, signedAt, keyId, signature } });
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Where the walk stands, to resume from. */
|
|
214
|
+
tally(): Tally {
|
|
215
|
+
const byPrincipal = <T>(entries: Iterable<[string, T]>) => [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
216
|
+
return {
|
|
217
|
+
entries: this.entries,
|
|
218
|
+
keys: [...this.keys],
|
|
219
|
+
accounting: this.accounting.state(),
|
|
220
|
+
access: {
|
|
221
|
+
members: byPrincipal(this.replayed.members).map(([, member]) => ({ ...member, accessSeq: Number(member.accessSeq) })),
|
|
222
|
+
grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
|
|
223
|
+
newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)]),
|
|
224
|
+
},
|
|
225
|
+
replayFault: this.replayFault,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The action of the vault's entry that signs a prefix of the log. */
|
|
231
|
+
export const CHECKPOINT = 'audit.checkpoint';
|
|
232
|
+
|
|
233
|
+
const ACCESS = new Set<string>(ACCESS_ACTIONS);
|
|
234
|
+
|
|
235
|
+
/** JSON with every object's keys sorted: one text for one value, however it was built. */
|
|
236
|
+
function canonical(value: unknown): string {
|
|
237
|
+
return JSON.stringify(value, (_, inner: unknown) =>
|
|
238
|
+
inner !== null && typeof inner === 'object' && !Array.isArray(inner)
|
|
239
|
+
? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
240
|
+
: inner);
|
|
241
|
+
}
|