@coffre/vault 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.9.0",
40
- "@coffre/db": "0.9.0"
39
+ "@coffre/core": "0.10.0",
40
+ "@coffre/db": "0.10.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/cloudflare.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  RewrapInput,
24
24
  SetAccessInput,
25
25
  SetSettingsInput,
26
+ ChangePolicyInput,
26
27
  UnwrapInput,
27
28
  Vault,
28
29
  VerifyLogInput,
@@ -94,6 +95,8 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
94
95
  async remove(input: RemoveInput) { return this.#call((vault) => vault.remove(input)); }
95
96
  async settings() { return this.#call((vault) => vault.settings()); }
96
97
  async setSettings(input: SetSettingsInput) { return this.#call((vault) => vault.setSettings(input)); }
98
+ async policy() { return this.#call((vault) => vault.policy()); }
99
+ async changePolicy(input: ChangePolicyInput) { return this.#call((vault) => vault.changePolicy(input)); }
97
100
  async checkpoint() { return this.#call((vault) => vault.checkpoint()); }
98
101
  async about() { return this.#call((vault) => vault.about()); }
99
102
  async keyChecks() { return this.#call((vault) => vault.keyChecks()); }
package/src/config.ts CHANGED
@@ -28,20 +28,8 @@ export type ResolvedVaultConfig = {
28
28
  */
29
29
  signingKeys: readonly Uint8Array[];
30
30
  bulkLimit: BulkLimit;
31
- /** `values` when left out. */
32
- people?: People;
33
31
  };
34
32
 
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
-
45
33
  /**
46
34
  * A key-encryption key: 32 random bytes, base64, and the id envelopes record
47
35
  * it under; or one a key service holds, such as `awsKms({ keyArn, … })`.
@@ -76,8 +64,12 @@ export type VaultConfig = {
76
64
  signingKey?: string;
77
65
  /** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
78
66
  bulkLimit?: { count: number; windowMinutes: number };
79
- /** What a person's reads open (`People`): `values` unless set. */
80
- people?: People;
67
+ /**
68
+ * @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
69
+ * those no person may read sensitive (`coffre mark <env> sensitive`), and
70
+ * give people read through their roles where they should see values.
71
+ */
72
+ people?: 'gone in 0.10: mark keys sensitive (coffre mark <env> sensitive), and let roles say who reads the rest';
81
73
  } & Owners;
82
74
 
83
75
  /**
@@ -146,6 +138,12 @@ function kek(entry: Kek): { provider: KekProvider; signingKey: Buffer | null } {
146
138
 
147
139
  /** Check a deployment's vault configuration, failing on the first problem. */
148
140
  export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
141
+ if ('people' in config && config.people !== undefined) {
142
+ throw new Error(
143
+ `people is gone in 0.10 (it was ${JSON.stringify(config.people)}): each key says who sees it now. Remove people, mark the keys no person may read sensitive ` +
144
+ '(coffre mark <env> sensitive, or coffre set <env>/<KEY> --sensitive), and let roles say who reads the rest (docs/design/credential-proxy.md)',
145
+ );
146
+ }
149
147
  const keks = [config.kek, ...(config.previousKeks ?? [])].map(kek);
150
148
  const [current, ...previous] = keks.map((entry) => entry.provider);
151
149
  const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
@@ -156,7 +154,6 @@ export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
156
154
  owners: checkOwners(config.owners ?? config.bootstrapOwners ?? config.rootAdmins ?? []),
157
155
  signingKeys: signingKeys(config.signingKey, keks),
158
156
  bulkLimit: config.bulkLimit === undefined ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit),
159
- people: checkPeople(config.people ?? 'values'),
160
157
  };
161
158
  }
162
159
 
@@ -184,10 +181,6 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
184
181
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
185
182
  }
186
183
 
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
184
 
192
185
  function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
193
186
  if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
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, People, VaultConfig } from './config.ts';
6
+ export type { Kek, 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/local.ts CHANGED
@@ -17,6 +17,8 @@ export const METHODS = [
17
17
  'remove',
18
18
  'settings',
19
19
  'setSettings',
20
+ 'policy',
21
+ 'changePolicy',
20
22
  'checkpoint',
21
23
  'about',
22
24
  'keyChecks',
package/src/store.ts CHANGED
@@ -451,6 +451,13 @@ export async function endedReferences(db: Queryable, seqs: readonly bigint[]): P
451
451
  }
452
452
 
453
453
  /** The project each environment is in: a holder's ids, checked against each other. */
454
+ /** Whether a project of this id is there, and not deleted. */
455
+ export async function projectLive(db: Queryable, id: string): Promise<boolean> {
456
+ const { projects } = tablesOf(db);
457
+ const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
458
+ return rows.length > 0 && !rows[0]!.deleted;
459
+ }
460
+
454
461
  export async function projectsOfEnvironments(db: Queryable, ids: readonly string[]): Promise<Map<string, string>> {
455
462
  if (ids.length === 0) return new Map();
456
463
  const { environments } = tablesOf(db);
@@ -481,6 +488,43 @@ export async function secretsByIds(db: Queryable, ids: readonly string[]): Promi
481
488
  return new Map(rows.map(({ project, environment, key, ...row }) => [row.secretId, { ...row, path: `${project}/${environment}/${key}` }]));
482
489
  }
483
490
 
491
+ /**
492
+ * Each secret's nature, as its current version has it: plain where its
493
+ * value is stored as it is, sensitive where it is bound so, and otherwise
494
+ * secret, as is one with no value of its own.
495
+ */
496
+ export async function natures(db: Queryable, secretIds: readonly string[]): Promise<Map<string, 'plain' | 'secret' | 'sensitive'>> {
497
+ if (secretIds.length === 0) return new Map();
498
+ const { secrets, secretVersions } = tablesOf(db);
499
+ const rows = await db
500
+ .select({ id: secrets.id, sensitive: secretVersions.sensitive, plain: truth(isNotNull(secretVersions.plaintext)) })
501
+ .from(secrets)
502
+ .innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId))
503
+ .where(inArray(secrets.id, [...new Set(secretIds)]));
504
+ return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? 'sensitive' : Boolean(row.plain) ? 'plain' : 'secret']));
505
+ }
506
+
507
+ /**
508
+ * For each version a rewrap would bring back into its secret, its nature
509
+ * as stored when it is an old value, not the secret's current one; null
510
+ * for the current one, which a mark wraps again with no value changing.
511
+ */
512
+ export async function restoredNatures(
513
+ db: Queryable,
514
+ items: readonly { secretId: string; from: string }[],
515
+ ): Promise<('plain' | 'secret' | 'sensitive' | null)[]> {
516
+ if (items.length === 0) return [];
517
+ const { secrets, secretVersions } = tablesOf(db);
518
+ const [current, versions] = await Promise.all([
519
+ db.select({ id: secrets.id, version: secrets.currentVersionId }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])),
520
+ db.select({ id: secretVersions.id, sensitive: secretVersions.sensitive, plain: truth(isNotNull(secretVersions.plaintext)) }).from(secretVersions)
521
+ .where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))])),
522
+ ]);
523
+ const currentOf = new Map(current.map((row) => [row.id, row.version]));
524
+ const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? 'sensitive' as const : Boolean(row.plain) ? 'plain' as const : 'secret' as const]));
525
+ return items.map(({ secretId, from }) => (currentOf.get(secretId) === from ? null : natureOf.get(from) ?? 'secret'));
526
+ }
527
+
484
528
  /** The vault's newest entry of any of these actions, allowed, or undefined. */
485
529
  export async function latestVaultEntry(db: Queryable, actions: readonly string[]): Promise<StoredEntry | undefined> {
486
530
  const { auditLog } = tablesOf(db);
@@ -531,6 +575,31 @@ export async function vaultEntriesOf(
531
575
  return stored(rows);
532
576
  }
533
577
 
578
+ /** Which of these secrets' newest versions are sensitive, by the versions' own order. */
579
+ export async function newestSensitive(db: Queryable, secretIds: readonly string[]): Promise<Set<string>> {
580
+ if (secretIds.length === 0) return new Set();
581
+ const { secretVersions } = tablesOf(db);
582
+ const rows = await db
583
+ .select({ secretId: secretVersions.secretId })
584
+ .from(secretVersions)
585
+ .where(and(
586
+ inArray(secretVersions.secretId, [...new Set(secretIds)]),
587
+ eq(secretVersions.sensitive, true),
588
+ sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
589
+ ));
590
+ return new Set(rows.map((row) => row.secretId));
591
+ }
592
+
593
+ /** How many grants name `role`: all of them, or those on environments. */
594
+ export async function grantsNaming(db: Queryable, role: string, filter: { onEnvironments?: boolean } = {}): Promise<number> {
595
+ const { vaultGrants } = tablesOf(db);
596
+ const rows = await db
597
+ .select({ count: sql<number | string>`count(*)` })
598
+ .from(vaultGrants)
599
+ .where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : undefined));
600
+ return Number(rows[0]?.count ?? 0);
601
+ }
602
+
534
603
  /**
535
604
  * A version that still holds its wrapped key. A deleted place's versions are
536
605
  * erased, their keys emptied: there is nothing left to open, or to try a
@@ -546,7 +615,16 @@ function sealed(wrappedDek: SQLWrapper): SQL {
546
615
  * one, as a read through a reference needs. An erased version outside a
547
616
  * deleted place is no version.
548
617
  */
549
- export type SecretVersion = { id: string; secret: SecretRef; wrapped: WrappedKey; deleted: boolean; current: boolean };
618
+ export type SecretVersion = {
619
+ id: string;
620
+ /** Its secret, `sensitive` as the version was written. */
621
+ secret: SecretRef;
622
+ wrapped: WrappedKey;
623
+ deleted: boolean;
624
+ current: boolean;
625
+ /** Whether its secret's newest version is sensitive: what a rewrap of it may not loosen. */
626
+ newestSensitive: boolean;
627
+ };
550
628
 
551
629
  export async function versions(db: Queryable, ids: readonly string[]): Promise<SecretVersion[]> {
552
630
  if (ids.length === 0) return [];
@@ -567,8 +645,10 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
567
645
  wrappedDek: secretVersions.wrappedDek,
568
646
  deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
569
647
  currentVersionId: secrets.currentVersionId,
648
+ sensitive: secretVersions.sensitive,
570
649
  // The newest by the versions' own order: the app may write `current_version_id`, never a version.
571
650
  newest: sql<number | string>`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
651
+ newestSensitive: sql<boolean | number>`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`,
572
652
  })
573
653
  .from(secretVersions)
574
654
  .innerJoin(secrets, eq(secrets.id, secretVersions.secretId))
@@ -584,6 +664,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
584
664
  secretId: row.secretId,
585
665
  version: row.version,
586
666
  path: `${row.project}/${row.environment}/${row.key}`,
667
+ sensitive: row.sensitive,
587
668
  },
588
669
  wrapped: {
589
670
  kekProvider: row.kekProvider,
@@ -592,5 +673,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
592
673
  bytes: row.wrappedDek.toString('base64'),
593
674
  },
594
675
  current: row.currentVersionId === row.id && Number(row.newest) === row.version,
676
+ // SQLite answers a subquery's boolean as a number.
677
+ newestSensitive: Boolean(row.newestSensitive),
595
678
  }));
596
679
  }