@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/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
- type ParsedOutcome = Item & { intentId: string; relatedSeq: bigint };
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 key = intent?.keys[parsed.item];
40
- if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
41
- return broken('key outcome does not identify one item of its intent');
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, item, version, subject } = detail;
97
+ const { intent } = detail;
95
98
  if (typeof intent !== 'string' || entry.relatedSeq === null) return 'key outcome has no intent identity';
96
- if (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 0) return 'key outcome has no valid item number';
97
- const secretId = entry.secretId ?? detail.secretId;
98
- if (typeof secretId !== 'string' || typeof subject !== 'string') return 'key outcome has no secret identity';
99
- if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) return 'key outcome has no valid version';
100
- return { intentId: intent, relatedSeq: entry.relatedSeq, item, secretId, version, subject };
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: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
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, { projectId: string; slug: string; deleted: boolean }>> {
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
- /** How many keys the vault has released to `principal` since `after`. */
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: count() })
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, fromSeq))
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(eq(auditLog.author, 'vault' satisfies Author), gt(auditLog.seq, afterSeq)))
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
- const entries = [...reports, ...d.log];
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, { purpose: input.purpose, ...(via === null ? {} : viaDetail(via, seal)) }),
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
- // A reference reads only the current version of the source its entry names.
804
- if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i]!.source.secretId !== item.secret.secretId || !item.current))) {
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, context(secret), operation)), wipe: () => {} };
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, context(secret), operation)), wipe: () => {} };
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, secret: SecretRef, operation: KeyOperation): Promise<Buffer | null> {
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(secret), operation);
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
- /** One per secret; null for a bad claim, an error for an outage or an unexpected fault. */
993
- operations: () => ((operation: KeyOperation) => Promise<T | null>)[],
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
- const codes: (RefusalCode | null)[] = secrets.map((secret, i) =>
1013
- refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!)));
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
- function context(secret: SecretRef): SecretContext {
2618
- return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId };
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) {