@coffre/vault 0.14.4 → 0.15.0
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/README.md +6 -4
- package/dist/cloudflare.d.ts +343 -9
- package/dist/cloudflare.js +802 -6
- 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/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +182 -7
- package/dist/node.js +358 -16
- package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +15 -3
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +72 -12
- package/src/store.ts +862 -37
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1513 -240
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-BBdXsny3.js +0 -5077
package/src/node.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The vault on Node, one of two ways, each over the database the server
|
|
3
|
-
* uses, through the vault's own login
|
|
3
|
+
* uses, through the vault's own login and its uses' (`hotDatabase`), with
|
|
4
|
+
* the checkpointer that seals its log in the same process (`checkpointer`):
|
|
4
5
|
*
|
|
5
|
-
* - in the server's own process: `vault: await localVault({ database, ...keys })`;
|
|
6
|
+
* - in the server's own process: `vault: await localVault({ database, hotDatabase, checkpointer, ...keys })`;
|
|
6
7
|
* - as a process of its own, which the server reaches over a Unix socket:
|
|
7
|
-
* `serveVault({ socket, database, ...keys })` there, and
|
|
8
|
+
* `serveVault({ socket, database, hotDatabase, checkpointer, ...keys })` there, and
|
|
8
9
|
* `vault: connectVault(socket)` in the server.
|
|
9
10
|
*
|
|
10
11
|
* The second keeps every key out of the process that faces the network. The
|
|
@@ -19,8 +20,9 @@ import { Agent, createServer, request as httpRequest, type IncomingMessage } fro
|
|
|
19
20
|
|
|
20
21
|
import type { Vault } from '@coffre/core/vault';
|
|
21
22
|
import type { Database } from '@coffre/db';
|
|
22
|
-
import { openDatabase } from '@coffre/db/connect';
|
|
23
|
+
import { engineOfUrl, openDatabase } from '@coffre/db/connect';
|
|
23
24
|
|
|
25
|
+
import { keepCheckpointer } from './checkpointer-node.ts';
|
|
24
26
|
import { resolveVaultConfig, type VaultConfig } from './config.ts';
|
|
25
27
|
import { failureOf } from './failure.ts';
|
|
26
28
|
import { METHODS, openLocalVault, type LocalVault } from './local.ts';
|
|
@@ -29,6 +31,13 @@ import type { VaultOptions } from './vault.ts';
|
|
|
29
31
|
export type { Vault, VaultConfig };
|
|
30
32
|
export * from './index.ts';
|
|
31
33
|
export type { LocalVault, VaultOptions };
|
|
34
|
+
/** 0.15's checkpointer on Node (docs/design/audit-tree.md), which `localVault` runs in the vault's process. */
|
|
35
|
+
export { fileMemory, keepCheckpointer, runCheckpointer, type KeptCheckpointer, type RunningCheckpointer } from './checkpointer-node.ts';
|
|
36
|
+
/**
|
|
37
|
+
* 0.15's log replayed as stored: what each use decides on the log, which every live answer must be (R53). For the
|
|
38
|
+
* suites only: it reads the whole log into memory, every use ever made. Plan PR 8's verifier walks it a stretch at a time.
|
|
39
|
+
*/
|
|
40
|
+
export { replayStored } from './log-replay.ts';
|
|
32
41
|
|
|
33
42
|
export type NodeVaultConfig = VaultConfig & {
|
|
34
43
|
/**
|
|
@@ -37,17 +46,72 @@ export type NodeVaultConfig = VaultConfig & {
|
|
|
37
46
|
* development. Or one already open, which the vault leaves open.
|
|
38
47
|
*/
|
|
39
48
|
database: string | Database;
|
|
49
|
+
/**
|
|
50
|
+
* 0.15's uses (docs/design/0.15-plan.md, PR 5): the same database as
|
|
51
|
+
* `coffre_vault_hot`, whose lock and statement timeouts bound a use, or
|
|
52
|
+
* one already open; on SQLite, the same `file:` URL. Required.
|
|
53
|
+
*/
|
|
54
|
+
hotDatabase?: string | Database;
|
|
55
|
+
/**
|
|
56
|
+
* 0.15's checkpointer, which this process runs: `memory`, the file it
|
|
57
|
+
* keeps the newest pair it signed in, where no database writer reaches,
|
|
58
|
+
* such as `/var/lib/coffre/checkpointer-memory`, writable by the vault's
|
|
59
|
+
* user alone (its alarms go beside it, in `<memory>.alarms`). Required,
|
|
60
|
+
* unless the caller seals through a checkpointer of its own
|
|
61
|
+
* (`VaultOptions.sealThrough` and `heldOutside`).
|
|
62
|
+
*/
|
|
63
|
+
checkpointer?: { memory: string };
|
|
40
64
|
};
|
|
41
65
|
|
|
42
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The vault in this process, and under 0.15's log, the default, its
|
|
68
|
+
* checkpointer, opened before the vault serves, so that no call meets a log
|
|
69
|
+
* with no checkpoint yet. A configuration without what 0.15 needs is refused.
|
|
70
|
+
*/
|
|
43
71
|
export async function localVault(config: NodeVaultConfig, options: VaultOptions = {}): Promise<LocalVault> {
|
|
44
72
|
const resolved = resolveVaultConfig(config);
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
73
|
+
const log015 = (options.log ?? '0.15') === '0.15';
|
|
74
|
+
const own = log015 && options.sealThrough === undefined;
|
|
75
|
+
if (log015 && config.hotDatabase === undefined) {
|
|
76
|
+
throw new Error("the vault's uses run as a login of their own: set hotDatabase in the vault's configuration, the database as coffre_vault_hot (HOT_DATABASE_URL)");
|
|
77
|
+
}
|
|
78
|
+
if (own && config.checkpointer?.memory === undefined) {
|
|
79
|
+
throw new Error("the vault answers a change once its checkpointer has sealed it: set checkpointer: { memory } in the vault's configuration, the file its signer remembers in (CHECKPOINTER_MEMORY)");
|
|
80
|
+
}
|
|
81
|
+
const opened: (() => Promise<void>)[] = [];
|
|
82
|
+
const open = async (database: string | Database) => {
|
|
83
|
+
if (typeof database !== 'string') return database;
|
|
84
|
+
const { db, close } = await openDatabase(database);
|
|
85
|
+
opened.push(close);
|
|
86
|
+
return db;
|
|
87
|
+
};
|
|
88
|
+
let checkpointer: { stop(): Promise<void> } | null = null;
|
|
89
|
+
// The checkpointer stopped first, its tick in flight ended, then the databases closed.
|
|
90
|
+
const close = async () => {
|
|
91
|
+
await checkpointer?.stop();
|
|
92
|
+
await Promise.all(opened.map((done) => done()));
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
const db = await open(config.database);
|
|
96
|
+
// The same URL is the same connection: on SQLite, where it is the same file, two would wait on each other's
|
|
97
|
+
// writes, a use's and the checkpointer's, past the busy timeout.
|
|
98
|
+
const hot = config.hotDatabase === undefined ? undefined : config.hotDatabase === config.database ? db : await open(config.hotDatabase);
|
|
99
|
+
// A use holds one of the hot pool's connections from its call to its answer: at most that many in flight.
|
|
100
|
+
const connections = (hot as { $client?: { options?: { max?: number } } } | undefined)?.$client?.options?.max;
|
|
101
|
+
const uses = { ...(connections === undefined ? {} : { connections }), ...options.uses };
|
|
102
|
+
if (own) {
|
|
103
|
+
// On Postgres, a connection of its own, which it cuts once it stalls (T169): what a stalled tick awaits holds none of
|
|
104
|
+
// the vault's. On SQLite, the vault's one: two to one file wait on each other's writes.
|
|
105
|
+
const url = typeof config.database === 'string' && engineOfUrl(config.database) === 'postgres' ? config.database : null;
|
|
106
|
+
const where = url === null ? { database: db } : { connect: () => openDatabase(url, { max: 2 }) };
|
|
107
|
+
const kept = (checkpointer = await keepCheckpointer({ ...where, seed: resolved.signingKeys[0]!, memory: config.checkpointer!.memory }));
|
|
108
|
+
options = { ...options, sealThrough: kept.sealThrough, heldOutside: kept.heldOutside, sealing: kept.sealing };
|
|
109
|
+
}
|
|
110
|
+
return await openLocalVault(db, resolved, { ...options, uses }, close, hot);
|
|
111
|
+
} catch (error) {
|
|
48
112
|
await close();
|
|
49
113
|
throw error;
|
|
50
|
-
}
|
|
114
|
+
}
|
|
51
115
|
}
|
|
52
116
|
|
|
53
117
|
const METHOD_NAMES = new Set<string>(METHODS);
|
package/src/objects.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { isCredentialScope, type CallerKind, type CredentialScope, type InstanceRole, type Scope } from '@coffre/core/access';
|
|
4
|
+
import { TOMBSTONE } from '@coffre/core/schemas';
|
|
5
|
+
import type { BindingColumns, ConnectionColumns, CredentialColumns, EnvironmentColumns, SecretColumns, VersionColumns } from '@coffre/core/vault';
|
|
6
|
+
import { storedRole } from '@coffre/db/grants';
|
|
7
|
+
|
|
8
|
+
import type { BindingInput, ConnectionInput, CredentialInput, EnvironmentInput, Input, InputGrant, Nature, PrincipalInput, SecretInput } from './inputs.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tables' rows as the objects of the 0.15 log (inputs.ts): what a seal
|
|
12
|
+
* covers, made from a row's columns alone. One mapping for every reader and
|
|
13
|
+
* writer of a row, the use path's statement (use.ts) and the change path
|
|
14
|
+
* (plan, PR 4) alike, so that what one seals the other checks. A row's other
|
|
15
|
+
* columns, bookkeeping the app updates outside any change (a credential's
|
|
16
|
+
* `last_used_at`), are no part of its object (docs/design/sealed-inputs.md).
|
|
17
|
+
*
|
|
18
|
+
* Each function takes the columns as plain values, whichever query read
|
|
19
|
+
* them: times in ms since the epoch, uuids as text. Each object is made
|
|
20
|
+
* from its own rows: an environment is deleted when its own slug is a
|
|
21
|
+
* tombstone, not its project's (a project's deletion writes its
|
|
22
|
+
* environments' changes, plan PR 4b). The one column of another row an
|
|
23
|
+
* object holds is an environment's project's archival, which 0.14 decides
|
|
24
|
+
* on, kept apart from its own; a project's archive changes each of its
|
|
25
|
+
* environments. A key's source is its newest reference row's while that
|
|
26
|
+
* row has not ended.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A credential's columns: `credentials` (a session, a token, a workload's session), as the app sends them too (`prepare`). */
|
|
30
|
+
export type { CredentialColumns };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A credential: a person's on a `user:` principal, a workload's session, or
|
|
34
|
+
* a bearer's token. A scope that does not parse as one reaches nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function credentialObject(row: CredentialColumns): CredentialInput {
|
|
37
|
+
const caller: CallerKind = row.principal.startsWith('user:') ? 'person' : row.kind === 'workload' ? 'workload' : 'bearer';
|
|
38
|
+
return { kind: 'credential', id: row.id, principal: row.principal, caller, scope: scopeOf(row.scope), expiresAt: row.expiresAt, revoked: row.revoked, generation: row.generation };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An MCP connection's columns (`mcp_connections`), as the app sends them too. */
|
|
42
|
+
export type { ConnectionColumns };
|
|
43
|
+
|
|
44
|
+
/** An MCP connection (T147): its columns as the row keeps them. */
|
|
45
|
+
export function connectionObject(row: ConnectionColumns): ConnectionInput {
|
|
46
|
+
return { kind: 'connection', id: row.id, principal: row.principal, generation: row.generation, expiresAt: row.expiresAt, revoked: row.revoked };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A trust binding (PR 3a): its columns as the row keeps them, its scope read as a credential's is. */
|
|
50
|
+
export function bindingObject(row: BindingColumns): BindingInput {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'binding', id: row.id, principal: row.principal, generation: row.generation, profile: row.profile, issuer: row.issuer,
|
|
53
|
+
origin: row.origin, jwksUri: row.jwksUri, claims: row.claims, scope: scopeOf(row.scope), policy: row.policy, revoked: row.revoked,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scopeOf(stored: string | null): CredentialScope | null {
|
|
58
|
+
if (stored === null) return null;
|
|
59
|
+
let parsed: unknown = null;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(stored);
|
|
62
|
+
} catch {}
|
|
63
|
+
return isCredentialScope(parsed) ? parsed : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A member's grant as its object holds it, from its row (`vault_grants`): an environment's with that environment's project. */
|
|
67
|
+
type GrantColumns = Pick<InputGrant, 'projectId' | 'environmentId' | 'environmentSlug' | 'role' | 'expiresAt'>;
|
|
68
|
+
|
|
69
|
+
/** A member's object, from their row (its role and scope as `storedRole` reads them) and their grants, lapsed ones too. */
|
|
70
|
+
export function principalObject(member: { principal: string; status: string; generation: number; role: InstanceRole; scope: Scope }, grants: readonly GrantColumns[]): PrincipalInput {
|
|
71
|
+
return {
|
|
72
|
+
kind: 'principal',
|
|
73
|
+
principal: member.principal,
|
|
74
|
+
status: member.status === 'active' ? 'active' : 'removed',
|
|
75
|
+
generation: member.generation,
|
|
76
|
+
role: member.role,
|
|
77
|
+
scope: member.scope,
|
|
78
|
+
grants: grants.map(({ projectId, environmentId, environmentSlug, role, expiresAt }) => ({ projectId, environmentId, environmentSlug, role, expiresAt })),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A member's role and scope as stored (`owner`, `role`, `scope`), as their object holds them. */
|
|
83
|
+
export function memberStanding(row: { owner: boolean; role: string | null; scope: string | null }): { role: InstanceRole; scope: Scope } {
|
|
84
|
+
return storedRole(row);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A key's columns (`secrets`, its version's in `secret_versions`),
|
|
89
|
+
* unarchived: an archived key is no object (`VersionColumns`,
|
|
90
|
+
* `SecretColumns`: @coffre/core/vault, the app sends them too).
|
|
91
|
+
*/
|
|
92
|
+
export type { SecretColumns, VersionColumns };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A key: its current version, nature and wrapped data key, or a plain
|
|
96
|
+
* one's digest; or, for a reference, its source, whose value and nature
|
|
97
|
+
* are its own (R23): a reference holds no version, and is a secret by
|
|
98
|
+
* itself. A reference that has ended holds neither (`ended`).
|
|
99
|
+
*/
|
|
100
|
+
export function secretObject(row: SecretColumns): SecretInput {
|
|
101
|
+
const { id, projectId, environmentId, source, version } = row;
|
|
102
|
+
if (source !== null || version === null) {
|
|
103
|
+
return { kind: 'secret', id, projectId, environmentId, version: '', nature: 'secret', key: null, digest: null, source, reference: source === null ? null : row.reference };
|
|
104
|
+
}
|
|
105
|
+
const nature: Nature = version.sensitive ? 'sensitive' : version.plaintext !== null ? 'plain' : 'secret';
|
|
106
|
+
const key = nature === 'plain' ? null : { kekProvider: version.kekProvider, kekId: version.kekId, kekVersion: version.kekVersion, bytes: version.wrappedDek };
|
|
107
|
+
const digest = nature === 'plain' ? plainDigest(version.plaintext!) : null;
|
|
108
|
+
return { kind: 'secret', id, projectId, environmentId, version: version.id, nature, key, digest, source: null, reference: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A plain value's digest, as its object holds it: SHA-256 of its UTF-8 text, base64. */
|
|
112
|
+
export function plainDigest(text: string): string {
|
|
113
|
+
return createHash('sha256').update(text, 'utf8').digest('base64');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** An environment's columns (`environments`), as the app sends them too (@coffre/core/vault). */
|
|
117
|
+
export type { EnvironmentColumns };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An environment: its slug, as it was before any tombstone, whether it was
|
|
121
|
+
* deleted, and its own archival and its project's, apart. A deleted one
|
|
122
|
+
* holds no project's archival: it reads nothing, and its project archived
|
|
123
|
+
* or restored after changes nothing of it.
|
|
124
|
+
*/
|
|
125
|
+
export function environmentObject(row: EnvironmentColumns): EnvironmentInput {
|
|
126
|
+
const tomb = row.slug.indexOf(TOMBSTONE);
|
|
127
|
+
return {
|
|
128
|
+
kind: 'environment', id: row.id, projectId: row.projectId, slug: tomb < 0 ? row.slug : row.slug.slice(0, tomb),
|
|
129
|
+
owner: row.owner, parentId: row.parentId, protected: row.protected === true, deleted: tomb >= 0, archived: row.archived, projectArchived: tomb < 0 && row.projectArchived,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The roles and the policy: each one `log_config` row whose value is the
|
|
135
|
+
* object as the change that made it wrote it (0022's `change_input`:
|
|
136
|
+
* genesis's `value`), read whole. A value that is not that object fails
|
|
137
|
+
* its seal.
|
|
138
|
+
*/
|
|
139
|
+
export function configObject(value: string): Input {
|
|
140
|
+
return JSON.parse(value) as Input;
|
|
141
|
+
}
|