@coffre/vault 0.6.2 → 0.8.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/dist/cloudflare.d.ts +2 -2
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CwsWOH6j.d.ts → index-BkHJR9Gt.d.ts} +12 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.js +2 -2
- package/dist/{src-CQJS3OZZ.js → src-BcttALFQ.js} +6 -1
- package/dist/{vault-ihE3Yc4a.js → vault-lBbyZ3kQ.js} +235 -54
- package/package.json +3 -3
- package/src/accounting.ts +30 -18
- package/src/config.ts +20 -0
- package/src/index.ts +1 -1
- package/src/log.ts +1 -2
- package/src/store.ts +37 -12
- package/src/vault.ts +193 -25
package/src/accounting.ts
CHANGED
|
@@ -5,7 +5,8 @@ const KEY_ACTIONS = new Set(['secret.read', 'key.wrap', 'key.rewrap']);
|
|
|
5
5
|
|
|
6
6
|
type Item = { item: number; secretId: string; version: number; subject: string };
|
|
7
7
|
type ParsedIntent = { intentId: string; operation: string; expiresAt: number; keys: Item[] };
|
|
8
|
-
|
|
8
|
+
/** An outcome entry: one item of its intent, or several, for a read logged once per place. */
|
|
9
|
+
type ParsedOutcome = { intentId: string; relatedSeq: bigint; items: Item[] };
|
|
9
10
|
type Intent = ParsedIntent & { entry: StoredEntry; seen: Set<number> };
|
|
10
11
|
type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
|
|
11
12
|
|
|
@@ -36,19 +37,21 @@ export class KeyAccounting {
|
|
|
36
37
|
const parsed = parseOutcome(entry);
|
|
37
38
|
if (typeof parsed === 'string') return broken(parsed);
|
|
38
39
|
const intent = this.#intents.get(parsed.relatedSeq);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
for (const outcome of parsed.items) {
|
|
41
|
+
const key = intent?.keys[outcome.item];
|
|
42
|
+
if (intent === undefined || key === undefined || intent.seen.has(outcome.item)) {
|
|
43
|
+
return broken('key outcome does not identify one item of its intent');
|
|
44
|
+
}
|
|
45
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
|
|
46
|
+
return broken('key outcome belongs to another operation');
|
|
47
|
+
}
|
|
48
|
+
if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) {
|
|
49
|
+
return broken('key outcome does not match its intended secret');
|
|
50
|
+
}
|
|
51
|
+
intent.seen.add(outcome.item);
|
|
42
52
|
}
|
|
43
|
-
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
|
|
44
|
-
return broken('key outcome belongs to another operation');
|
|
45
|
-
}
|
|
46
|
-
if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
|
|
47
|
-
return broken('key outcome does not match its intended secret');
|
|
48
|
-
}
|
|
49
|
-
intent.seen.add(parsed.item);
|
|
50
53
|
// Completed batches need no state, and another outcome for one is a duplicate.
|
|
51
|
-
if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
|
|
54
|
+
if (intent !== undefined && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -91,13 +94,22 @@ function parseIntent(entry: StoredEntry): ParsedIntent | string {
|
|
|
91
94
|
function parseOutcome(entry: StoredEntry): ParsedOutcome | string {
|
|
92
95
|
const detail = payload(entry.metadata);
|
|
93
96
|
if (detail === null) return 'key outcome has no valid accounting payload';
|
|
94
|
-
const { intent
|
|
97
|
+
const { intent } = detail;
|
|
95
98
|
if (typeof intent !== 'string' || entry.relatedSeq === null) return 'key outcome has no intent identity';
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// Several secrets read at one place share one entry, each its item there.
|
|
100
|
+
const many = detail.secrets;
|
|
101
|
+
if (many !== undefined && (!Array.isArray(many) || many.length === 0)) return 'key outcome has no valid item list';
|
|
102
|
+
const listed = many === undefined ? [{ ...detail, secretId: entry.secretId ?? detail.secretId }] : many.map(record);
|
|
103
|
+
const items: Item[] = [];
|
|
104
|
+
for (const one of listed) {
|
|
105
|
+
if (one === null) return 'key outcome has no valid item list';
|
|
106
|
+
const { item, secretId, version, subject } = one;
|
|
107
|
+
if (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 0) return 'key outcome has no valid item number';
|
|
108
|
+
if (typeof secretId !== 'string' || typeof subject !== 'string') return 'key outcome has no secret identity';
|
|
109
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) return 'key outcome has no valid version';
|
|
110
|
+
items.push({ item, secretId, version, subject });
|
|
111
|
+
}
|
|
112
|
+
return { intentId: intent, relatedSeq: entry.relatedSeq, items };
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
function payload(text: string): Record<string, unknown> | null {
|
package/src/config.ts
CHANGED
|
@@ -28,8 +28,20 @@ export type ResolvedVaultConfig = {
|
|
|
28
28
|
*/
|
|
29
29
|
signingKeys: readonly Uint8Array[];
|
|
30
30
|
bulkLimit: BulkLimit;
|
|
31
|
+
/** `values` when left out. */
|
|
32
|
+
people?: People;
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* What a person's reads open (docs/design/credential-proxy.md): `values`,
|
|
37
|
+
* as any member's; or `stand-ins`, where a person, on any credential of
|
|
38
|
+
* theirs (a session, a personal token, an MCP connection), opens values
|
|
39
|
+
* only for coffre to use for them, never to be handed back: the credential
|
|
40
|
+
* proxy injects them, and the deployment's `expose` list names the few it
|
|
41
|
+
* may pass on. Service accounts read values either way.
|
|
42
|
+
*/
|
|
43
|
+
export type People = 'values' | 'stand-ins';
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* A key-encryption key: 32 random bytes, base64, and the id envelopes record
|
|
35
47
|
* it under; or one a key service holds, such as `awsKms({ keyArn, … })`.
|
|
@@ -64,6 +76,8 @@ export type VaultConfig = {
|
|
|
64
76
|
signingKey?: string;
|
|
65
77
|
/** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
66
78
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
79
|
+
/** What a person's reads open (`People`): `values` unless set. */
|
|
80
|
+
people?: People;
|
|
67
81
|
} & Owners;
|
|
68
82
|
|
|
69
83
|
/**
|
|
@@ -142,6 +156,7 @@ export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
|
|
|
142
156
|
owners: checkOwners(config.owners ?? config.bootstrapOwners ?? config.rootAdmins ?? []),
|
|
143
157
|
signingKeys: signingKeys(config.signingKey, keks),
|
|
144
158
|
bulkLimit: config.bulkLimit === undefined ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit),
|
|
159
|
+
people: checkPeople(config.people ?? 'values'),
|
|
145
160
|
};
|
|
146
161
|
}
|
|
147
162
|
|
|
@@ -169,6 +184,11 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
|
|
|
169
184
|
return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
|
|
170
185
|
}
|
|
171
186
|
|
|
187
|
+
function checkPeople(people: People): People {
|
|
188
|
+
if (people !== 'values' && people !== 'stand-ins') throw new Error(`people is 'values' or 'stand-ins'; got ${JSON.stringify(people)}`);
|
|
189
|
+
return people;
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
|
|
173
193
|
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
|
|
174
194
|
throw new Error('bulkLimit needs a whole count of at least 1 and a window above 0 minutes');
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,6 @@ export { checkpointMessage, verifyCheckpoint } from '@coffre/core/vault';
|
|
|
3
3
|
export type { SecretContext } from '@coffre/core/envelope';
|
|
4
4
|
export { awsKms, KekBadClaimError, KekUnavailableError } from '@coffre/core/kek';
|
|
5
5
|
export type { AwsCredentials, AwsKmsOptions, KekProvider, WrappedDek } from '@coffre/core/kek';
|
|
6
|
-
export type { Kek, VaultConfig } from './config.ts';
|
|
6
|
+
export type { Kek, People, VaultConfig } from './config.ts';
|
|
7
7
|
/** The signing key a local KEK stands for, for tools that check the vault's records from outside. */
|
|
8
8
|
export { derivedSigningKey } from './config.ts';
|
package/src/log.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function further(a: Anchor, b: Anchor): Anchor {
|
|
|
38
38
|
return b.nextSeq > a.nextSeq ? b : a;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** Entries read at once
|
|
41
|
+
/** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
|
|
42
42
|
export const VERIFY_BATCH = 5000;
|
|
43
43
|
|
|
44
44
|
type Verified = { verification: LogVerification; anchor: Anchor };
|
|
@@ -96,7 +96,6 @@ export async function verifyChain(
|
|
|
96
96
|
vaultEntries: verified.vaultEntries + result.authenticated,
|
|
97
97
|
vaultKeys: moved.keys,
|
|
98
98
|
};
|
|
99
|
-
if (batch.length < VERIFY_BATCH) break;
|
|
100
99
|
}
|
|
101
100
|
return { verification: { ok: true, entries: verified.vaultEntries }, anchor: verified };
|
|
102
101
|
}
|
package/src/store.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/va
|
|
|
4
4
|
import { 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
|
-
import { entryColumns } from '@coffre/db/log';
|
|
8
|
-
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
7
|
+
import { entryColumns, withinBytes } from '@coffre/db/log';
|
|
8
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Every query the vault makes, and the only code in it that holds them.
|
|
@@ -193,10 +193,20 @@ function at(db: Queryable, place: Place) {
|
|
|
193
193
|
* deleted, alone or with its project: then no key under it opens, or is
|
|
194
194
|
* wrapped, whatever grant would cover it.
|
|
195
195
|
*/
|
|
196
|
+
/** An environment as a decision reads it. */
|
|
197
|
+
export type EnvironmentRow = {
|
|
198
|
+
projectId: string;
|
|
199
|
+
slug: string;
|
|
200
|
+
owner: string | null;
|
|
201
|
+
parentId: string | null;
|
|
202
|
+
deleted: boolean;
|
|
203
|
+
archived: boolean;
|
|
204
|
+
};
|
|
205
|
+
|
|
196
206
|
export async function environmentsById(
|
|
197
207
|
db: Queryable,
|
|
198
208
|
ids: readonly string[],
|
|
199
|
-
): Promise<Map<string,
|
|
209
|
+
): Promise<Map<string, EnvironmentRow>> {
|
|
200
210
|
if (ids.length === 0) return new Map();
|
|
201
211
|
const { environments, projects } = tablesOf(db);
|
|
202
212
|
const rows = await db
|
|
@@ -204,7 +214,12 @@ export async function environmentsById(
|
|
|
204
214
|
id: environments.id,
|
|
205
215
|
projectId: environments.projectId,
|
|
206
216
|
slug: environments.slug,
|
|
217
|
+
// A personal config's owner, and the environment it is a config of, whose read it takes.
|
|
218
|
+
owner: environments.owner,
|
|
219
|
+
parentId: environments.parentId,
|
|
207
220
|
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
|
|
221
|
+
// Only what names a fork asks it: nothing is decided on it.
|
|
222
|
+
archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt))!),
|
|
208
223
|
})
|
|
209
224
|
.from(environments)
|
|
210
225
|
.innerJoin(projects, eq(projects.id, environments.projectId))
|
|
@@ -291,11 +306,18 @@ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
|
|
|
291
306
|
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
292
307
|
export const RELEASE = 'secret.read';
|
|
293
308
|
|
|
294
|
-
/**
|
|
309
|
+
/**
|
|
310
|
+
* How many keys the vault has released to `principal` since `after`: one
|
|
311
|
+
* for an entry about one secret, and as many as it lists for one about
|
|
312
|
+
* several (`secrets`), a read logged once per place.
|
|
313
|
+
*/
|
|
295
314
|
export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
|
|
296
315
|
const { auditLog } = tablesOf(db);
|
|
316
|
+
const listed = engineOf(db) === 'postgres'
|
|
317
|
+
? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
|
|
318
|
+
: sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
297
319
|
const [{ n }] = await db
|
|
298
|
-
.select({ n:
|
|
320
|
+
.select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
|
|
299
321
|
.from(auditLog)
|
|
300
322
|
.where(
|
|
301
323
|
and(
|
|
@@ -306,7 +328,7 @@ export async function releasesSince(db: Queryable, principal: string, after: num
|
|
|
306
328
|
gt(auditLog.occurredAt, after),
|
|
307
329
|
),
|
|
308
330
|
);
|
|
309
|
-
return Number(n);
|
|
331
|
+
return Number(n ?? 0);
|
|
310
332
|
}
|
|
311
333
|
|
|
312
334
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
@@ -317,24 +339,27 @@ function stored(rows: object[]): StoredEntry[] {
|
|
|
317
339
|
/** Up to `limit` entries of either author from `fromSeq`, oldest first: the chain as it runs. */
|
|
318
340
|
export async function entriesFrom(db: Queryable, fromSeq: bigint, limit: number): Promise<StoredEntry[]> {
|
|
319
341
|
const { auditLog } = tablesOf(db);
|
|
342
|
+
const batch = await withinBytes(db, gte(auditLog.seq, fromSeq), limit);
|
|
343
|
+
if (batch === null) return [];
|
|
320
344
|
const rows = await db
|
|
321
345
|
.select(entryColumns(db))
|
|
322
346
|
.from(auditLog)
|
|
323
|
-
.where(gte(auditLog.seq,
|
|
324
|
-
.orderBy(asc(auditLog.seq))
|
|
325
|
-
.limit(limit);
|
|
347
|
+
.where(and(gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to)))
|
|
348
|
+
.orderBy(asc(auditLog.seq));
|
|
326
349
|
return stored(rows);
|
|
327
350
|
}
|
|
328
351
|
|
|
329
352
|
/** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
|
|
330
353
|
export async function vaultEntriesAfter(db: Queryable, afterSeq: bigint, limit: number): Promise<StoredEntry[]> {
|
|
331
354
|
const { auditLog } = tablesOf(db);
|
|
355
|
+
const vault = eq(auditLog.author, 'vault' satisfies Author);
|
|
356
|
+
const batch = await withinBytes(db, and(vault, gt(auditLog.seq, afterSeq)), limit);
|
|
357
|
+
if (batch === null) return [];
|
|
332
358
|
const rows = await db
|
|
333
359
|
.select(entryColumns(db))
|
|
334
360
|
.from(auditLog)
|
|
335
|
-
.where(and(
|
|
336
|
-
.orderBy(asc(auditLog.seq))
|
|
337
|
-
.limit(limit);
|
|
361
|
+
.where(and(vault, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to)))
|
|
362
|
+
.orderBy(asc(auditLog.seq));
|
|
338
363
|
return stored(rows);
|
|
339
364
|
}
|
|
340
365
|
|
package/src/vault.ts
CHANGED
|
@@ -24,9 +24,10 @@ import {
|
|
|
24
24
|
type Role,
|
|
25
25
|
type Scope,
|
|
26
26
|
type ServiceAccount,
|
|
27
|
+
withinCredential,
|
|
27
28
|
} from '@coffre/core/access';
|
|
28
29
|
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
29
|
-
import { checkContext, type SecretContext } from '@coffre/core/envelope';
|
|
30
|
+
import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
|
|
30
31
|
import { shownMember } from '@coffre/core/schemas';
|
|
31
32
|
import {
|
|
32
33
|
DEK_BYTES,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
checkpointMessage,
|
|
46
47
|
checkpointVerifier,
|
|
47
48
|
describeAccessFault,
|
|
49
|
+
HANDED_BACK,
|
|
48
50
|
type Access,
|
|
49
51
|
type AccessChange,
|
|
50
52
|
type AccessFault,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
type LogHead,
|
|
60
62
|
type LogVerification,
|
|
61
63
|
type Outcome,
|
|
64
|
+
type Purpose,
|
|
62
65
|
type ReferenceInput,
|
|
63
66
|
type Refusal,
|
|
64
67
|
type RefusalCode,
|
|
@@ -279,6 +282,8 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
279
282
|
bad_claim: 'the key does not belong to this secret',
|
|
280
283
|
deleted: 'this project or environment was deleted',
|
|
281
284
|
ended: 'the reference read through was ended',
|
|
285
|
+
personal: "a personal config's values are its owner's alone",
|
|
286
|
+
stand_ins: "people use this instance's values through stand-ins, never as values: `coffre run` gives your app them; a service account reads values",
|
|
282
287
|
not_allowed: 'not allowed to change this',
|
|
283
288
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
284
289
|
invalid: 'not something the rules allow',
|
|
@@ -428,7 +433,8 @@ class VaultService implements Vault {
|
|
|
428
433
|
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
429
434
|
};
|
|
430
435
|
const result = await decide(d);
|
|
431
|
-
|
|
436
|
+
// A read is logged once per place, whichever way it ends (`readsByPlace`).
|
|
437
|
+
const entries = [...reports, ...readsByPlace(d.log)];
|
|
432
438
|
let at = d.at;
|
|
433
439
|
// Each member's newest access entry, which their row names (rows.ts).
|
|
434
440
|
const accessSeq = new Map<string, bigint>();
|
|
@@ -451,7 +457,7 @@ class VaultService implements Vault {
|
|
|
451
457
|
return { ok: true, ...result };
|
|
452
458
|
} catch (error) {
|
|
453
459
|
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
454
|
-
const entries = [...reports, ...error.entries];
|
|
460
|
+
const entries = [...reports, ...readsByPlace(error.entries)];
|
|
455
461
|
if (entries.length > 0) {
|
|
456
462
|
await this.#db.transaction(async (tx) => {
|
|
457
463
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
@@ -785,23 +791,45 @@ class VaultService implements Vault {
|
|
|
785
791
|
const { principal } = input;
|
|
786
792
|
validateText(input.purpose);
|
|
787
793
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
794
|
+
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
788
795
|
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
789
796
|
if (!loaded.ok) return loaded;
|
|
790
797
|
// RPC can preserve shared objects, and two holders can read one source: each item its own secret.
|
|
791
798
|
const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
|
|
792
799
|
validateItems(items, 'wrapped');
|
|
793
800
|
const sealed = await this.#referencesAt(vias);
|
|
801
|
+
const forks = await store.environmentsById(this.#db, throughs.flatMap((through) => (through === null ? [] : [through])));
|
|
802
|
+
// A reference reads only the current version of the source its entry names.
|
|
803
|
+
const viaOk = items.map((item, i) => vias[i] === null || (sealed[i] !== null && sealed[i]!.source.secretId === item.secret.secretId && item.current));
|
|
804
|
+
// A fork an inherited value is read in is a live environment of the same project as the value's, and not its own,
|
|
805
|
+
// for a read whose reference, if any, holds. Checked once, not again under the log's head: it decides nothing, so a
|
|
806
|
+
// fork archived meanwhile only names a read.
|
|
807
|
+
const throughOk = items.map((item, i) => {
|
|
808
|
+
const through = throughs[i];
|
|
809
|
+
if (through === null) return true;
|
|
810
|
+
if (!viaOk[i]) return false;
|
|
811
|
+
const fork = forks.get(through);
|
|
812
|
+
const decidedAt = sealed[i]?.holder ?? item.secret;
|
|
813
|
+
return fork !== undefined && !fork.deleted && !fork.archived && fork.projectId === decidedAt.projectId && through !== decidedAt.environmentId;
|
|
814
|
+
});
|
|
794
815
|
const versionIds = new Map(items.map((item) => [item.secret, item.id]));
|
|
795
|
-
const viaOf = new Map(items.map((item, i) => [item.secret, { via: vias[i], sealed: sealed[i] }]));
|
|
816
|
+
const viaOf = new Map(items.map((item, i) => [item.secret, { via: vias[i], sealed: sealed[i], through: throughs[i], throughOk: throughOk[i] }]));
|
|
796
817
|
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => {
|
|
797
|
-
const { via, sealed: seal } = viaOf.get(secret)!;
|
|
818
|
+
const { via, sealed: seal, through, throughOk: ok } = viaOf.get(secret)!;
|
|
798
819
|
return {
|
|
799
|
-
...keyEntry('secret.read', principal, secret, decision, code, input, {
|
|
820
|
+
...keyEntry('secret.read', principal, secret, decision, code, input, {
|
|
821
|
+
purpose: input.purpose,
|
|
822
|
+
...(via === null ? {} : viaDetail(via, seal)),
|
|
823
|
+
// A fork that failed the check is named, never filed under: its project's log is no place for this read.
|
|
824
|
+
...(through === null ? {} : ok ? throughDetail(through, forks.get(through), via !== null) : { through: { environmentId: through, refused: true } }),
|
|
825
|
+
}),
|
|
800
826
|
secretVersionId: versionIds.get(secret),
|
|
801
827
|
};
|
|
802
828
|
};
|
|
803
|
-
|
|
804
|
-
|
|
829
|
+
if (viaOk.includes(false)) {
|
|
830
|
+
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
831
|
+
}
|
|
832
|
+
if (throughOk.includes(false)) {
|
|
805
833
|
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
806
834
|
}
|
|
807
835
|
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
@@ -822,9 +850,9 @@ class VaultService implements Vault {
|
|
|
822
850
|
entry,
|
|
823
851
|
input,
|
|
824
852
|
},
|
|
825
|
-
() =>
|
|
853
|
+
(contextOf) =>
|
|
826
854
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
827
|
-
const key = await this.#open(wrapped, secret, operation);
|
|
855
|
+
const key = await this.#open(wrapped, contextOf(secret), operation);
|
|
828
856
|
return key && { key, wipe: () => key.fill(0) };
|
|
829
857
|
}),
|
|
830
858
|
(opened) => ({
|
|
@@ -856,11 +884,11 @@ class VaultService implements Vault {
|
|
|
856
884
|
entry,
|
|
857
885
|
input,
|
|
858
886
|
},
|
|
859
|
-
() =>
|
|
887
|
+
(contextOf) =>
|
|
860
888
|
items.map(({ secret, key }) => async (operation: KeyOperation) => {
|
|
861
889
|
const dek = Buffer.from(key, 'base64');
|
|
862
890
|
try {
|
|
863
|
-
return { wrapped: serialisable(await this.#config.keks.wrap(dek,
|
|
891
|
+
return { wrapped: serialisable(await this.#config.keks.wrap(dek, contextOf(secret), operation)), wipe: () => {} };
|
|
864
892
|
} finally {
|
|
865
893
|
dek.fill(0);
|
|
866
894
|
}
|
|
@@ -892,12 +920,12 @@ class VaultService implements Vault {
|
|
|
892
920
|
this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
893
921
|
return this.#keys(
|
|
894
922
|
{ action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input },
|
|
895
|
-
() =>
|
|
923
|
+
(contextOf) =>
|
|
896
924
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
897
|
-
const key = await this.#open(wrapped, secret, operation);
|
|
925
|
+
const key = await this.#open(wrapped, contextOf(secret), operation);
|
|
898
926
|
if (key === null) return null;
|
|
899
927
|
try {
|
|
900
|
-
return { wrapped: serialisable(await this.#config.keks.wrap(key,
|
|
928
|
+
return { wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)), wipe: () => {} };
|
|
901
929
|
} finally {
|
|
902
930
|
key.fill(0);
|
|
903
931
|
}
|
|
@@ -951,9 +979,9 @@ class VaultService implements Vault {
|
|
|
951
979
|
* that is not what it says. A key service that cannot answer is an outage,
|
|
952
980
|
* not a verdict on the claim, and throws.
|
|
953
981
|
*/
|
|
954
|
-
async #open(wrapped: WrappedKey,
|
|
982
|
+
async #open(wrapped: WrappedKey, context: SecretContext, operation: KeyOperation): Promise<Buffer | null> {
|
|
955
983
|
try {
|
|
956
|
-
return await this.#config.keks.unwrap(unwrappable(wrapped), context
|
|
984
|
+
return await this.#config.keks.unwrap(unwrappable(wrapped), context, operation);
|
|
957
985
|
} catch (error) {
|
|
958
986
|
if (error instanceof KekBadClaimError) return null;
|
|
959
987
|
throw error;
|
|
@@ -989,8 +1017,12 @@ class VaultService implements Vault {
|
|
|
989
1017
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
990
1018
|
input: Correlation & Through & { purpose?: string };
|
|
991
1019
|
},
|
|
992
|
-
/**
|
|
993
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* One per secret; null for a bad claim, an error for an outage or an
|
|
1022
|
+
* unexpected fault. `context` is each secret's, as the decision read its
|
|
1023
|
+
* environment: a personal config's binds its owner.
|
|
1024
|
+
*/
|
|
1025
|
+
operations: (context: (secret: SecretRef) => SecretContext) => ((operation: KeyOperation) => Promise<T | null>)[],
|
|
994
1026
|
result: (done: T[]) => R,
|
|
995
1027
|
): Promise<Outcome<R>> {
|
|
996
1028
|
const { action, principal, secrets, entry } = call;
|
|
@@ -1001,6 +1033,8 @@ class VaultService implements Vault {
|
|
|
1001
1033
|
throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
|
|
1002
1034
|
});
|
|
1003
1035
|
}
|
|
1036
|
+
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1037
|
+
const owners = new Map<string, string>();
|
|
1004
1038
|
const check = async (d: Decision) => {
|
|
1005
1039
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
1006
1040
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
@@ -1008,13 +1042,23 @@ class VaultService implements Vault {
|
|
|
1008
1042
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
1009
1043
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1010
1044
|
const environments = await store.environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
|
|
1045
|
+
// A personal config's parent, whose read its owner needs.
|
|
1046
|
+
const parents = [...environments.values()].flatMap((environment) => (environment.owner === null || environment.parentId === null ? [] : [environment.parentId]));
|
|
1047
|
+
for (const [id, parent] of await store.environmentsById(d.tx, parents)) environments.set(id, parent);
|
|
1011
1048
|
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1012
|
-
|
|
1013
|
-
|
|
1049
|
+
owners.clear();
|
|
1050
|
+
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1051
|
+
const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
|
|
1052
|
+
const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
|
|
1053
|
+
if (personal !== undefined) return personal;
|
|
1054
|
+
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1055
|
+
});
|
|
1014
1056
|
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1015
1057
|
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1016
1058
|
}
|
|
1017
1059
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1060
|
+
// A person's values are only ever used for them, where people get stand-ins: by whatever credential of theirs asks.
|
|
1061
|
+
if (first === null && action === 'secret.read' && this.#config.people === 'stand-ins' && principal.startsWith('user:') && HANDED_BACK.includes(call.input.purpose as Purpose)) first = 'stand_ins';
|
|
1018
1062
|
if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
|
|
1019
1063
|
if (first !== null) {
|
|
1020
1064
|
throw new Refused(
|
|
@@ -1087,7 +1131,7 @@ class VaultService implements Vault {
|
|
|
1087
1131
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
1088
1132
|
});
|
|
1089
1133
|
}
|
|
1090
|
-
const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
|
|
1134
|
+
const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
|
|
1091
1135
|
const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
|
|
1092
1136
|
try {
|
|
1093
1137
|
const entries = () => secrets.map((secret, i) =>
|
|
@@ -1192,6 +1236,8 @@ class VaultService implements Vault {
|
|
|
1192
1236
|
const source = sources.get(item.source.secretId);
|
|
1193
1237
|
if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
|
|
1194
1238
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1239
|
+
// A personal config's values are bound to its owner: no reference holds one, nor reads one.
|
|
1240
|
+
if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
|
|
1195
1241
|
return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
|
|
1196
1242
|
?? refuses(reader, 'secret.read', placeIn(source, environments), gone(source));
|
|
1197
1243
|
});
|
|
@@ -2238,6 +2284,7 @@ class VaultService implements Vault {
|
|
|
2238
2284
|
let keys: readonly string[] = [];
|
|
2239
2285
|
for (let after = -1n; ; ) {
|
|
2240
2286
|
const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
2287
|
+
if (batch.length === 0) return { ok: true };
|
|
2241
2288
|
for (const entry of batch) {
|
|
2242
2289
|
const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
|
|
2243
2290
|
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
@@ -2246,7 +2293,6 @@ class VaultService implements Vault {
|
|
|
2246
2293
|
if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
2247
2294
|
keys = moved.keys;
|
|
2248
2295
|
for (const entry of batch) findings.add(entry);
|
|
2249
|
-
if (batch.length < VERIFY_BATCH) return { ok: true };
|
|
2250
2296
|
after = batch[batch.length - 1]!.seq;
|
|
2251
2297
|
}
|
|
2252
2298
|
}
|
|
@@ -2458,6 +2504,29 @@ function validateVia(via: Via | null | undefined): Via | null {
|
|
|
2458
2504
|
return { reference: via.reference, seq: via.seq };
|
|
2459
2505
|
}
|
|
2460
2506
|
|
|
2507
|
+
/** A read's fork, checked for shape: null for none. */
|
|
2508
|
+
function validateThrough(through: string | null | undefined): string | null {
|
|
2509
|
+
if (through === undefined || through === null) return null;
|
|
2510
|
+
if (typeof through !== 'string' || !UUID.test(through)) throw new Error("a fork's id must be a lowercase UUID");
|
|
2511
|
+
return through;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/**
|
|
2515
|
+
* How a read of an inherited value is logged: the fork it was read in,
|
|
2516
|
+
* `through`, and as `also`, which puts it in the fork's log too, unless it
|
|
2517
|
+
* was read through the parent's reference. The fork decides nothing: the
|
|
2518
|
+
* reader's grants where the value is held do, as for any read.
|
|
2519
|
+
*/
|
|
2520
|
+
function throughDetail(through: string, fork: { projectId: string; slug: string } | undefined, viaReference: boolean): Record<string, unknown> {
|
|
2521
|
+
return {
|
|
2522
|
+
through: { environmentId: through, ...(fork === undefined ? {} : { slug: fork.slug }) },
|
|
2523
|
+
// Through a reference, the entry is the source's, in another project perhaps, and stays filed where references
|
|
2524
|
+
// file theirs: the source and the holder, the parent. A fork's own log would show a reader of the fork alone
|
|
2525
|
+
// what the parent's reference reads, which is the parent's to tell.
|
|
2526
|
+
...(viaReference ? {} : { also: { projectId: fork?.projectId ?? null, environmentId: through } }),
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2461
2530
|
/**
|
|
2462
2531
|
* How a read through a reference is logged: the way in, and `also`, the
|
|
2463
2532
|
* holder's place, which puts the read in the holder's project's log too.
|
|
@@ -2489,6 +2558,32 @@ function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { project
|
|
|
2489
2558
|
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
2490
2559
|
* on the project, the environment or every project, can reach into one.
|
|
2491
2560
|
*/
|
|
2561
|
+
/**
|
|
2562
|
+
* A key of a personal config is its owner's alone (docs/design/forking.md,
|
|
2563
|
+
* section 4): no grant or role opens or wraps it, an Owner's included,
|
|
2564
|
+
* only its owner, while they read the environment it is a config of,
|
|
2565
|
+
* whatever the call is. Nor does any reference reach into or out of one.
|
|
2566
|
+
* Undefined for a key of any other environment, decided as ever.
|
|
2567
|
+
*/
|
|
2568
|
+
function personalRefusal(
|
|
2569
|
+
reader: Standing,
|
|
2570
|
+
principal: string,
|
|
2571
|
+
permission: Permission,
|
|
2572
|
+
secret: SecretPlace,
|
|
2573
|
+
at: SecretPlace,
|
|
2574
|
+
environments: ReadonlyMap<string, store.EnvironmentRow>,
|
|
2575
|
+
): RefusalCode | null | undefined {
|
|
2576
|
+
const own = environments.get(secret.environmentId);
|
|
2577
|
+
const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
|
|
2578
|
+
if (own?.owner == null && held?.owner == null) return undefined;
|
|
2579
|
+
if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
|
|
2580
|
+
const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
|
|
2581
|
+
const refused = refuses(reader, 'secret.read', parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
|
|
2582
|
+
if (refused !== null) return refused;
|
|
2583
|
+
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key.
|
|
2584
|
+
return withinCredential(reader.live, permission, parent) ? null : 'no_grant';
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2492
2587
|
function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
2493
2588
|
if (reader.status === 'tampered') return 'tampered';
|
|
2494
2589
|
if (reader.status === 'removed') return 'removed';
|
|
@@ -2532,6 +2627,78 @@ function keyEntry(
|
|
|
2532
2627
|
};
|
|
2533
2628
|
}
|
|
2534
2629
|
|
|
2630
|
+
/** What every secret of one read at a place shares, said once in its entry rather than in each of its `secrets`. */
|
|
2631
|
+
const READ_SHARED = ['purpose', 'intent', 'credentialId', 'through'] as const;
|
|
2632
|
+
|
|
2633
|
+
/**
|
|
2634
|
+
* A decision's `secret.read` entries, made one per secret, as the log keeps
|
|
2635
|
+
* them: one per place it was decided at, the environment read and, through
|
|
2636
|
+
* a reference, the holder's, or the fork that inherits it (`also`,
|
|
2637
|
+
* `through`). An entry about several secrets lists
|
|
2638
|
+
* them in `secrets`, each with what its own entry would have said: its
|
|
2639
|
+
* `secretId`, `secretVersionId`, `subject`, `version`, and its `via`, holder
|
|
2640
|
+
* (`also.secretId`) and intent `item` when it has them. An entry about one
|
|
2641
|
+
* secret is as it always was. A `coffre run` of a hundred keys is one entry,
|
|
2642
|
+
* not a hundred; what it decided, and for which keys, is the same. Other
|
|
2643
|
+
* entries pass through, in order.
|
|
2644
|
+
*/
|
|
2645
|
+
function readsByPlace(entries: readonly NewEntry[]): NewEntry[] {
|
|
2646
|
+
const groups = new Map<string, NewEntry[]>();
|
|
2647
|
+
const out: (NewEntry | NewEntry[])[] = [];
|
|
2648
|
+
for (const entry of entries) {
|
|
2649
|
+
if (entry.action !== 'secret.read') {
|
|
2650
|
+
out.push(entry);
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
const { also, through } = JSON.parse(entry.metadata ?? '{}') as { also?: { projectId?: string; environmentId?: string }; through?: unknown };
|
|
2654
|
+
const place = JSON.stringify([
|
|
2655
|
+
entry.actor, entry.decision, entry.code ?? null, entry.projectId ?? null, entry.environmentId ?? null,
|
|
2656
|
+
entry.operationId ?? null, entry.requestId ?? null, String(entry.relatedSeq ?? null), also?.projectId ?? null, also?.environmentId ?? null,
|
|
2657
|
+
through ?? null,
|
|
2658
|
+
]);
|
|
2659
|
+
const group = groups.get(place);
|
|
2660
|
+
if (group !== undefined) {
|
|
2661
|
+
group.push(entry);
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
const fresh = [entry];
|
|
2665
|
+
groups.set(place, fresh);
|
|
2666
|
+
out.push(fresh);
|
|
2667
|
+
}
|
|
2668
|
+
return out.map((item) => (Array.isArray(item) ? oneRead(item) : item));
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/** Entries of one read at one place as one entry: `readsByPlace`. */
|
|
2672
|
+
function oneRead(group: NewEntry[]): NewEntry {
|
|
2673
|
+
const [first] = group as [NewEntry];
|
|
2674
|
+
if (group.length === 1) return first;
|
|
2675
|
+
const details = group.map((entry) => JSON.parse(entry.metadata ?? '{}') as Record<string, unknown>);
|
|
2676
|
+
const shared = Object.fromEntries(READ_SHARED.flatMap((field) => (details[0]![field] === undefined ? [] : [[field, details[0]![field]]])));
|
|
2677
|
+
// The place on the other side, which the group shares: a reference's holder, or the fork reading.
|
|
2678
|
+
const beside = details[0]!.also as { projectId: string | null; environmentId: string } | undefined;
|
|
2679
|
+
const secrets = group.map((entry, i) => {
|
|
2680
|
+
const own = Object.fromEntries(Object.entries(details[i]!).filter(([field]) => !(READ_SHARED as readonly string[]).includes(field) && field !== 'also'));
|
|
2681
|
+
// A reference's holder is its own; a fork's `also` names only the fork, which the entry says once.
|
|
2682
|
+
const holder = (details[i]!.also as { secretId?: string } | undefined)?.secretId;
|
|
2683
|
+
return {
|
|
2684
|
+
...(entry.secretId == null ? {} : { secretId: entry.secretId }),
|
|
2685
|
+
...(entry.secretVersionId == null ? {} : { secretVersionId: entry.secretVersionId }),
|
|
2686
|
+
...own,
|
|
2687
|
+
...(holder === undefined ? {} : { also: { secretId: holder } }),
|
|
2688
|
+
};
|
|
2689
|
+
});
|
|
2690
|
+
return {
|
|
2691
|
+
...first,
|
|
2692
|
+
secretId: null,
|
|
2693
|
+
secretVersionId: null,
|
|
2694
|
+
metadata: JSON.stringify({
|
|
2695
|
+
...shared,
|
|
2696
|
+
...(beside === undefined ? {} : { also: { projectId: beside.projectId, environmentId: beside.environmentId } }),
|
|
2697
|
+
secrets,
|
|
2698
|
+
}),
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2535
2702
|
/** Whether `entry` changes a member's access: what their row's `access_seq` names. */
|
|
2536
2703
|
function isAccessEntry(entry: NewEntry): boolean {
|
|
2537
2704
|
return (
|
|
@@ -2614,8 +2781,9 @@ function iso(ms: number): string {
|
|
|
2614
2781
|
return new Date(ms).toISOString();
|
|
2615
2782
|
}
|
|
2616
2783
|
|
|
2617
|
-
|
|
2618
|
-
|
|
2784
|
+
/** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
|
|
2785
|
+
function context(secret: SecretRef, owner?: string): SecretContext {
|
|
2786
|
+
return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId, ...(owner === undefined ? {} : { owner }) };
|
|
2619
2787
|
}
|
|
2620
2788
|
|
|
2621
2789
|
function unwrappable(wrapped: WrappedKey) {
|