@coffre/vault 0.8.2 → 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/dist/cloudflare.d.ts +10 -11
- package/dist/cloudflare.js +8 -2
- package/dist/{index-BkHJR9Gt.d.ts → index-CByXzyi-.d.ts} +7 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.js +4 -2
- package/dist/{src-BcttALFQ.js → src-D82jyprb.js} +2 -6
- package/dist/{vault-lBbyZ3kQ.js → vault-COqZ12ny.js} +389 -53
- package/package.json +3 -3
- package/src/cloudflare.ts +3 -0
- package/src/config.ts +12 -19
- package/src/index.ts +1 -1
- package/src/local.ts +2 -0
- package/src/store.ts +84 -1
- package/src/vault.ts +389 -57
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "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.
|
|
40
|
-
"@coffre/db": "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
|
-
/**
|
|
80
|
-
|
|
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,
|
|
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
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 = {
|
|
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
|
}
|