@coffre/vault 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +25 -3
- package/dist/cloudflare.d.ts +55 -0
- package/dist/cloudflare.js +73 -0
- package/dist/index-qXbB_vlp.d.ts +40 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/node.d.ts +48 -0
- package/dist/node.js +142 -0
- package/dist/vault-DvTsSAOX.js +2225 -0
- package/package.json +51 -5
- package/src/accounting.ts +110 -0
- package/src/checkpoint.ts +25 -0
- package/src/cloudflare-workers.d.ts +14 -0
- package/src/cloudflare.ts +89 -0
- package/src/config.ts +146 -0
- package/src/index.ts +6 -0
- package/src/local.ts +48 -0
- package/src/log.ts +140 -0
- package/src/node.ts +141 -0
- package/src/replay.ts +150 -0
- package/src/rows.ts +67 -0
- package/src/store.ts +420 -0
- package/src/vault.ts +1564 -0
package/src/node.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vault on Node, one of two ways, each over the database the server
|
|
3
|
+
* uses, through the vault's own login:
|
|
4
|
+
*
|
|
5
|
+
* - in the server's own process: `vault: await localVault({ database, ...keys })`;
|
|
6
|
+
* - as a process of its own, which the server reaches over a Unix socket:
|
|
7
|
+
* `serveVault({ socket, database, ...keys })` there, and
|
|
8
|
+
* `vault: connectVault(socket)` in the server.
|
|
9
|
+
*
|
|
10
|
+
* The second keeps every key out of the process that faces the network. The
|
|
11
|
+
* socket is the whole of its authentication: a file only the vault's user
|
|
12
|
+
* and the group it shares with the server may open, so no port to reach and
|
|
13
|
+
* no shared secret to leak. Each call is one HTTP POST over it, `/<method>`
|
|
14
|
+
* with the arguments as a JSON array, and refusals come back as values, as
|
|
15
|
+
* from any vault; only a failure is an error.
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, existsSync, lstatSync, rmSync } from 'node:fs';
|
|
18
|
+
import { Agent, createServer, request as httpRequest, type IncomingMessage } from 'node:http';
|
|
19
|
+
|
|
20
|
+
import type { Vault } from '@coffre/core/vault';
|
|
21
|
+
import type { Database } from '@coffre/db';
|
|
22
|
+
import { openDatabase } from '@coffre/db/connect';
|
|
23
|
+
|
|
24
|
+
import { resolveVaultConfig, type VaultConfig } from './config.ts';
|
|
25
|
+
import { METHODS, openLocalVault, type LocalVault } from './local.ts';
|
|
26
|
+
import type { VaultOptions } from './vault.ts';
|
|
27
|
+
|
|
28
|
+
export type { Vault, VaultConfig };
|
|
29
|
+
export * from './index.ts';
|
|
30
|
+
export type { LocalVault, VaultOptions };
|
|
31
|
+
|
|
32
|
+
export type NodeVaultConfig = VaultConfig & {
|
|
33
|
+
/**
|
|
34
|
+
* The database the server uses, as the vault's own login:
|
|
35
|
+
* `postgres://coffre_vault_runtime:…@…/coffre`, or a `file:` URL for local
|
|
36
|
+
* development. Or one already open, which the vault leaves open.
|
|
37
|
+
*/
|
|
38
|
+
database: string | Database;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The vault in this process. */
|
|
42
|
+
export async function localVault(config: NodeVaultConfig, options: VaultOptions = {}): Promise<LocalVault> {
|
|
43
|
+
const resolved = resolveVaultConfig(config);
|
|
44
|
+
if (typeof config.database !== 'string') return openLocalVault(config.database, resolved, options);
|
|
45
|
+
const { db, close } = await openDatabase(config.database);
|
|
46
|
+
return openLocalVault(db, resolved, options, close).catch(async (error: unknown) => {
|
|
47
|
+
await close();
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const METHOD_NAMES = new Set<string>(METHODS);
|
|
53
|
+
const MAX_BODY = 8 * 1024 * 1024;
|
|
54
|
+
|
|
55
|
+
export type VaultServer = { socket: string; close(): Promise<void> };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The vault as a process of its own, answering on a Unix socket. The socket
|
|
59
|
+
* is made `0660`: put it in a directory the server's user can reach through
|
|
60
|
+
* a shared group, e.g. `/run/coffre/vault.sock`.
|
|
61
|
+
*/
|
|
62
|
+
export async function serveVault(config: NodeVaultConfig & { socket: string }): Promise<VaultServer> {
|
|
63
|
+
const vault = await localVault(config);
|
|
64
|
+
const server = createServer(async (req, res) => {
|
|
65
|
+
const name = (req.url ?? '').slice(1);
|
|
66
|
+
const reply = (status: number, body: unknown) => {
|
|
67
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
68
|
+
res.end(JSON.stringify(body));
|
|
69
|
+
};
|
|
70
|
+
if (req.method !== 'POST' || !METHOD_NAMES.has(name)) return reply(404, { error: 'not a vault call' });
|
|
71
|
+
try {
|
|
72
|
+
const args = JSON.parse(await readBody(req)) as unknown;
|
|
73
|
+
if (!Array.isArray(args)) return reply(400, { error: 'the arguments must be a JSON array' });
|
|
74
|
+
const result = await (vault[name as keyof Vault] as (...args: unknown[]) => Promise<unknown>)(...args);
|
|
75
|
+
reply(200, result === undefined ? {} : { result });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(`vault ${name} failed`, error);
|
|
78
|
+
reply(500, { error: error instanceof Error ? error.message : 'the vault failed' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A socket file left by a vault that did not stop cleanly would make
|
|
83
|
+
// listen fail; anything else at that path is not ours to remove.
|
|
84
|
+
if (existsSync(config.socket)) {
|
|
85
|
+
if (!lstatSync(config.socket).isSocket()) throw new Error(`${config.socket} exists and is not a socket`);
|
|
86
|
+
rmSync(config.socket);
|
|
87
|
+
}
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
server.once('error', reject);
|
|
90
|
+
server.listen(config.socket, () => resolve());
|
|
91
|
+
});
|
|
92
|
+
chmodSync(config.socket, 0o660);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
socket: config.socket,
|
|
96
|
+
close: () =>
|
|
97
|
+
new Promise<void>((resolve) => {
|
|
98
|
+
server.close(() => void vault.close().then(resolve));
|
|
99
|
+
server.closeAllConnections();
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The vault that `serveVault` runs, from the server's side of its socket. */
|
|
105
|
+
export function connectVault(socket: string): Vault {
|
|
106
|
+
const agent = new Agent({ keepAlive: true });
|
|
107
|
+
const call = (name: string) => (...args: unknown[]) =>
|
|
108
|
+
new Promise<unknown>((resolve, reject) => {
|
|
109
|
+
const body = JSON.stringify(args);
|
|
110
|
+
const req = httpRequest(
|
|
111
|
+
{
|
|
112
|
+
socketPath: socket,
|
|
113
|
+
agent,
|
|
114
|
+
method: 'POST',
|
|
115
|
+
path: `/${name}`,
|
|
116
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
|
117
|
+
},
|
|
118
|
+
(res) => {
|
|
119
|
+
readBody(res).then((text) => {
|
|
120
|
+
const payload = JSON.parse(text) as { result?: unknown; error?: string };
|
|
121
|
+
if (res.statusCode === 200) resolve(payload.result);
|
|
122
|
+
else reject(new Error(`vault ${name}: ${payload.error ?? `status ${res.statusCode}`}`));
|
|
123
|
+
}, reject);
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
req.on('error', (error) => reject(new Error(`the vault at ${socket} is unreachable: ${error.message}`)));
|
|
127
|
+
req.end(body);
|
|
128
|
+
});
|
|
129
|
+
return Object.fromEntries(METHODS.map((name) => [name, call(name)])) as unknown as Vault;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readBody(stream: IncomingMessage): Promise<string> {
|
|
133
|
+
const chunks: Buffer[] = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
for await (const chunk of stream) {
|
|
136
|
+
size += (chunk as Buffer).length;
|
|
137
|
+
if (size > MAX_BODY) throw new Error('the message is too large');
|
|
138
|
+
chunks.push(chunk as Buffer);
|
|
139
|
+
}
|
|
140
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
141
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
|
+
import type { AccessFault, FaultGrant } from '@coffre/core/vault';
|
|
3
|
+
import type { Queryable } from '@coffre/db';
|
|
4
|
+
|
|
5
|
+
import { ACCESS_ACTIONS, allMembers, grants, vaultEntriesOf, type GrantRow, type Member, type Place } from './store.ts';
|
|
6
|
+
|
|
7
|
+
export { ACCESS_ACTIONS };
|
|
8
|
+
|
|
9
|
+
const BATCH = 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Why the members and grants in the database do not follow from the
|
|
13
|
+
* vault's entries, or null when they do; `describeAccessFault` words it.
|
|
14
|
+
* Every change to either is logged in the transaction that makes it, with
|
|
15
|
+
* the row's times taken from its entry, so replaying the allowed ones from
|
|
16
|
+
* the first gives the tables back, and a row the log does not explain was
|
|
17
|
+
* written around the vault: a grant inserted with the database's own login,
|
|
18
|
+
* a removal undone.
|
|
19
|
+
*
|
|
20
|
+
* Call it after the chain is verified, in the same snapshot, so every entry
|
|
21
|
+
* it replays carries the vault's MAC.
|
|
22
|
+
*
|
|
23
|
+
* Grants are compared as they are live at `at`. Clearing one that has
|
|
24
|
+
* lapsed changes nothing anyone holds, so it is not logged.
|
|
25
|
+
*/
|
|
26
|
+
export async function replay(db: Queryable, at: number): Promise<AccessFault | null> {
|
|
27
|
+
const state: Replayed = { members: new Map(), held: new Map() };
|
|
28
|
+
for (let after = -1n; ; ) {
|
|
29
|
+
const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
|
|
30
|
+
for (const row of batch) {
|
|
31
|
+
const fault = apply(state, row);
|
|
32
|
+
if (fault !== null) return fault;
|
|
33
|
+
}
|
|
34
|
+
if (batch.length < BATCH) break;
|
|
35
|
+
after = batch[batch.length - 1].seq;
|
|
36
|
+
}
|
|
37
|
+
const { members, held } = state;
|
|
38
|
+
const stored = new Map((await allMembers(db)).map((member) => [member.principal, member]));
|
|
39
|
+
for (const principal of [...new Set([...stored.keys(), ...members.keys()])].sort()) {
|
|
40
|
+
const [inStore, inLog] = [stored.get(principal), members.get(principal)];
|
|
41
|
+
if (inLog === undefined) return { kind: 'unlogged-member', principal };
|
|
42
|
+
if (inStore === undefined) return { kind: 'missing-member', principal };
|
|
43
|
+
const fields = MEMBER_FIELDS.filter(([field]) => inStore[field] !== inLog[field]).map(([, column]) => column);
|
|
44
|
+
if (fields.length > 0) return { kind: 'member-differs', principal, fields };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const live = (grant: GrantRow) => grant.expiresAt === null || grant.expiresAt > at;
|
|
48
|
+
const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
|
|
49
|
+
const inStore = new Set((await grants(db)).filter(live).map(grantKey));
|
|
50
|
+
const extra = [...inStore].sort().find((grant) => !logged.has(grant));
|
|
51
|
+
if (extra !== undefined) return { kind: 'unlogged-grant', grant: faultGrant(extra) };
|
|
52
|
+
const missing = [...logged].sort().find((grant) => !inStore.has(grant));
|
|
53
|
+
if (missing !== undefined) return { kind: 'missing-grant', grant: faultGrant(missing) };
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A member as the log says they are: their row, but for the MAC, which only the store has. */
|
|
58
|
+
export type LoggedMember = Omit<Member, 'mac'>;
|
|
59
|
+
|
|
60
|
+
/** Who the log says is a member, and what each holds, as far as it has been replayed. */
|
|
61
|
+
export type Replayed = { members: Map<string, LoggedMember>; held: Map<string, Map<string, GrantRow>> };
|
|
62
|
+
|
|
63
|
+
/** One access entry, authenticated, applied to `state`; a fault when it changes someone never admitted. */
|
|
64
|
+
export function apply(state: Replayed, row: StoredEntry): AccessFault | null {
|
|
65
|
+
const principal = row.subjectPrincipal!;
|
|
66
|
+
const detail = JSON.parse(row.metadata) as Record<string, unknown>;
|
|
67
|
+
const place: Place = { projectId: row.projectId!, environmentId: row.environmentId };
|
|
68
|
+
const grantsOf = state.held.get(principal) ?? new Map<string, GrantRow>();
|
|
69
|
+
state.held.set(principal, grantsOf);
|
|
70
|
+
const before = state.members.get(principal);
|
|
71
|
+
const changed = { statusChangedAt: row.occurredAt, statusChangedBy: row.actor };
|
|
72
|
+
switch (row.action) {
|
|
73
|
+
case 'member.add':
|
|
74
|
+
case 'member.restore':
|
|
75
|
+
state.members.set(principal, {
|
|
76
|
+
principal,
|
|
77
|
+
status: 'active',
|
|
78
|
+
owner: detail.owner === true,
|
|
79
|
+
generation: before?.generation ?? 0,
|
|
80
|
+
createdAt: before?.createdAt ?? row.occurredAt,
|
|
81
|
+
createdBy: before?.createdBy ?? row.actor,
|
|
82
|
+
accessSeq: row.seq,
|
|
83
|
+
...changed,
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
case 'member.owner':
|
|
87
|
+
if (before === undefined) return { kind: 'unadmitted-change', seq: Number(row.seq), principal };
|
|
88
|
+
state.members.set(principal, { ...before, owner: detail.owner === true, accessSeq: row.seq });
|
|
89
|
+
return null;
|
|
90
|
+
case 'member.remove': {
|
|
91
|
+
if (before === undefined) return { kind: 'unadmitted-change', seq: Number(row.seq), principal };
|
|
92
|
+
// A removal names the generation it moved to; one before this format
|
|
93
|
+
// was always the next.
|
|
94
|
+
const generation = typeof detail.generation === 'number' ? detail.generation : before.generation + 1;
|
|
95
|
+
state.members.set(principal, { ...before, status: 'removed', owner: false, generation, accessSeq: row.seq, ...changed });
|
|
96
|
+
grantsOf.clear();
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
case 'access.grant':
|
|
100
|
+
grantsOf.set(placeKey(place), {
|
|
101
|
+
principal,
|
|
102
|
+
...place,
|
|
103
|
+
role: detail.role as string,
|
|
104
|
+
expiresAt: detail.expiresAt === null ? null : Date.parse(detail.expiresAt as string),
|
|
105
|
+
grantedAt: row.occurredAt,
|
|
106
|
+
grantedBy: row.actor,
|
|
107
|
+
});
|
|
108
|
+
if (before !== undefined) state.members.set(principal, { ...before, accessSeq: row.seq });
|
|
109
|
+
return null;
|
|
110
|
+
case 'access.revoke':
|
|
111
|
+
grantsOf.delete(placeKey(place));
|
|
112
|
+
if (before !== undefined) state.members.set(principal, { ...before, accessSeq: row.seq });
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A member's fields, and the columns a fault names them by. */
|
|
119
|
+
const MEMBER_FIELDS = [
|
|
120
|
+
['status', 'status'],
|
|
121
|
+
['owner', 'owner'],
|
|
122
|
+
['generation', 'generation'],
|
|
123
|
+
['createdAt', 'created_at'],
|
|
124
|
+
['createdBy', 'created_by'],
|
|
125
|
+
['statusChangedAt', 'status_changed_at'],
|
|
126
|
+
['statusChangedBy', 'status_changed_by'],
|
|
127
|
+
['accessSeq', 'access_seq'],
|
|
128
|
+
] as const satisfies readonly (readonly [keyof LoggedMember, string])[];
|
|
129
|
+
|
|
130
|
+
/** A grant's place: its environment, or its project when it has none. */
|
|
131
|
+
function placeKey(place: Place): string {
|
|
132
|
+
return place.environmentId ?? place.projectId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function grantKey(grant: GrantRow): string {
|
|
136
|
+
return JSON.stringify([
|
|
137
|
+
grant.principal,
|
|
138
|
+
grant.projectId,
|
|
139
|
+
grant.environmentId,
|
|
140
|
+
grant.role,
|
|
141
|
+
grant.expiresAt,
|
|
142
|
+
grant.grantedAt,
|
|
143
|
+
grant.grantedBy,
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function faultGrant(key: string): FaultGrant {
|
|
148
|
+
const [principal, projectId, environmentId, role] = JSON.parse(key) as [string, string, string | null, string];
|
|
149
|
+
return { principal, projectId, environmentId, role };
|
|
150
|
+
}
|
package/src/rows.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { GrantRow, Member } from './store.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MAC over each member's row and grants: what makes a row written
|
|
7
|
+
* around the vault, by anyone who can write the database but does not hold
|
|
8
|
+
* its signing key, one the vault refuses rather than trusts.
|
|
9
|
+
*
|
|
10
|
+
* One MAC per member, over the row and the member's grants as a sorted set,
|
|
11
|
+
* lapsed ones included, rather than one per row: a grant deleted fails it as
|
|
12
|
+
* surely as one added or edited. `access_seq` is in it too, so a genuine row
|
|
13
|
+
* put back from before a later change still names the older entry, which the
|
|
14
|
+
* log has moved past (vault.ts, `#integrity`).
|
|
15
|
+
*
|
|
16
|
+
* A change to what is covered is a new version of the tuple, never an edit.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The key member rows are sealed under, derived from the signing key: one more secret to hold, not one more to keep. */
|
|
20
|
+
export function rowKey(signingKey: Uint8Array): Buffer {
|
|
21
|
+
return Buffer.from(hkdfSync('sha256', signingKey, new Uint8Array(0), 'coffre.vault.rows.v1', 32));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A member's grants as the MAC covers them: each one's place, role, end and grant, as a sorted set. */
|
|
25
|
+
export function grantSet(grants: readonly GrantRow[]): string[] {
|
|
26
|
+
return grants
|
|
27
|
+
.map((grant) => [
|
|
28
|
+
grant.environmentId === null ? 'project' : 'environment',
|
|
29
|
+
grant.environmentId ?? grant.projectId,
|
|
30
|
+
grant.role,
|
|
31
|
+
grant.expiresAt,
|
|
32
|
+
grant.grantedAt,
|
|
33
|
+
grant.grantedBy,
|
|
34
|
+
])
|
|
35
|
+
.map((tuple) => JSON.stringify(tuple))
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether two reads of a member's grants are the same set, as the MAC sees them. */
|
|
40
|
+
export function sameGrants(a: readonly GrantRow[], b: readonly GrantRow[]): boolean {
|
|
41
|
+
const [x, y] = [grantSet(a), grantSet(b)];
|
|
42
|
+
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function memberMac(key: Buffer, member: Omit<Member, 'mac'>, grants: readonly GrantRow[]): Buffer {
|
|
46
|
+
const held = grantSet(grants);
|
|
47
|
+
const tuple = [
|
|
48
|
+
'coffre.vault.member.v1',
|
|
49
|
+
member.principal,
|
|
50
|
+
member.status,
|
|
51
|
+
member.owner,
|
|
52
|
+
member.generation,
|
|
53
|
+
member.accessSeq.toString(),
|
|
54
|
+
member.createdAt,
|
|
55
|
+
member.createdBy,
|
|
56
|
+
member.statusChangedAt,
|
|
57
|
+
member.statusChangedBy,
|
|
58
|
+
held,
|
|
59
|
+
];
|
|
60
|
+
return createHmac('sha256', key).update(JSON.stringify(tuple)).digest();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether `member`'s MAC is the vault's, over this row and these grants. */
|
|
64
|
+
export function sealed(key: Buffer, member: Member, grants: readonly GrantRow[]): boolean {
|
|
65
|
+
const expected = memberMac(key, member, grants);
|
|
66
|
+
return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
|
|
67
|
+
}
|