@coffre/core 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -3
  3. package/dist/access.d.ts +100 -0
  4. package/dist/access.js +109 -0
  5. package/dist/audit.d.ts +119 -0
  6. package/dist/audit.js +160 -0
  7. package/dist/dotenv.d.ts +45 -0
  8. package/dist/dotenv.js +194 -0
  9. package/dist/envelope.d.ts +50 -0
  10. package/dist/envelope.js +83 -0
  11. package/dist/identity.d.ts +433 -0
  12. package/dist/identity.js +691 -0
  13. package/dist/kek.d.ts +72 -0
  14. package/dist/kek.js +486 -0
  15. package/dist/registry-C0NGpxcc.d.ts +126 -0
  16. package/dist/schemas.d.ts +18 -0
  17. package/dist/schemas.js +13 -0
  18. package/dist/types-Mi7-Tzj9.js +57 -0
  19. package/dist/vault.d.ts +375 -0
  20. package/dist/vault.js +45 -0
  21. package/package.json +75 -5
  22. package/src/access-fault.ts +61 -0
  23. package/src/access.ts +152 -0
  24. package/src/audit/chain.ts +207 -0
  25. package/src/checkpoint.ts +13 -0
  26. package/src/context.ts +54 -0
  27. package/src/dotenv.ts +242 -0
  28. package/src/envelope.ts +127 -0
  29. package/src/identity/auth.ts +124 -0
  30. package/src/identity/index.ts +6 -0
  31. package/src/identity/signin/config.ts +301 -0
  32. package/src/identity/signin/github.ts +191 -0
  33. package/src/identity/signin/index.ts +5 -0
  34. package/src/identity/signin/oidc.ts +199 -0
  35. package/src/identity/signin/sealed.ts +55 -0
  36. package/src/identity/signin/types.ts +94 -0
  37. package/src/identity/tokens.ts +45 -0
  38. package/src/identity/types.ts +51 -0
  39. package/src/identity/verifier.ts +86 -0
  40. package/src/kek/aws-kms.ts +279 -0
  41. package/src/kek/cancellation.ts +56 -0
  42. package/src/kek/index.ts +6 -0
  43. package/src/kek/local.ts +122 -0
  44. package/src/kek/registry.ts +63 -0
  45. package/src/kek/sigv4.ts +75 -0
  46. package/src/kek/types.ts +79 -0
  47. package/src/schemas.ts +11 -0
  48. package/src/vault.ts +276 -0
@@ -0,0 +1,79 @@
1
+ import type { SecretContext } from '../context.ts';
2
+
3
+ /**
4
+ * A data encryption key wrapped by a key encryption key.
5
+ *
6
+ * The provider metadata travels with every wrapped DEK and is stored per row,
7
+ * never as a single global key id. Rotation and provider migration are then the
8
+ * same code path: write new rows under the new KEK, keep unwrapping old rows
9
+ * under the old one.
10
+ */
11
+ export type WrappedDek = {
12
+ kekProvider: string;
13
+ kekId: string;
14
+ kekVersion: string;
15
+ bytes: Buffer;
16
+ };
17
+
18
+ /** One deadline for a batch. Providers must abort and settle their work when its signal fires. */
19
+ export type KeyOperation = { deadline: number; signal: AbortSignal };
20
+
21
+ /**
22
+ * The only interface any key encryption key backend must satisfy.
23
+ *
24
+ * Two methods. We generate the DEK ourselves with a CSPRNG and use the backend
25
+ * purely to wrap it, rather than depending on a provider `GenerateDataKey`
26
+ * primitive -- GCP KMS has no such call, and staying off it is what keeps the
27
+ * exit plan cheap.
28
+ *
29
+ * `ctx` is passed through to the provider deliberately. A remote KEK service
30
+ * cannot otherwise record *which* secret an unwrap was for, only that some
31
+ * opaque DEK was unwrapped. AWS KMS logs it in CloudTrail as the encryption
32
+ * context (`aws-kms.ts`). Scaleway's Audit Trail does not log Key Manager
33
+ * Decrypt at all, which is why AWS came first.
34
+ *
35
+ * `unwrap` throws `KekUnavailableError` when the service cannot answer, and
36
+ * `KekBadClaimError` when the wrapped DEK does not open under `ctx`. Other
37
+ * errors are unexpected faults; the vault records them and fails the call.
38
+ */
39
+ export interface KekProvider {
40
+ readonly provider: string;
41
+ readonly keyId: string;
42
+ readonly keyVersion: string;
43
+
44
+ wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek>;
45
+ unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
46
+ }
47
+
48
+ /** Length of the data encryption keys we generate, in bytes (AES-256). */
49
+ export const DEK_BYTES = 32;
50
+
51
+ /**
52
+ * The key service could not answer: it is down, throttling past the
53
+ * retries, or refusing coffre's own credentials. Nothing is known about the
54
+ * wrapped key, so the vault fails the call instead of refusing it.
55
+ */
56
+ export class KekUnavailableError extends Error {
57
+ override readonly name: string = 'KekUnavailableError';
58
+
59
+ readonly uncertain: boolean;
60
+
61
+ constructor(message: string, uncertain = false) {
62
+ super(message);
63
+ this.uncertain = uncertain;
64
+ }
65
+ }
66
+
67
+ /** No more work may start. A request already sent may have been accepted by KMS. */
68
+ export class KekCancelledError extends KekUnavailableError {
69
+ override readonly name = 'KekCancelledError';
70
+
71
+ constructor(uncertain = false) {
72
+ super('key operation was cancelled', uncertain);
73
+ }
74
+ }
75
+
76
+ /** The ciphertext does not open under the claimed key and context. */
77
+ export class KekBadClaimError extends Error {
78
+ override readonly name = 'KekBadClaimError';
79
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+
3
+ export const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
4
+ export const displayName = z.string().trim().min(1).max(120);
5
+ export const secretKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/);
6
+ export const principalType = z.enum(['user', 'service']);
7
+ export const principalId = z.string().trim().min(1).max(320);
8
+ export const instanceRole = z.enum(['user', 'owner']);
9
+ export const grantId = z.string().uuid();
10
+ export const emailAddress = z.string().email().max(320);
11
+ export const syncId = z.string().uuid();
package/src/vault.ts ADDED
@@ -0,0 +1,276 @@
1
+ import type { AccessFault } from './access-fault.ts';
2
+ import type { Role } from './access.ts';
3
+
4
+ export { checkpointMessage, verifyCheckpoint } from './checkpoint.ts';
5
+ export { describeAccessFault, type AccessFault, type FaultGrant, type FaultNames } from './access-fault.ts';
6
+
7
+ /**
8
+ * The vault as the app sees it: the contract between `@coffre/server` and
9
+ * `@coffre/vault`, kept in core so that neither side owns it. Every argument
10
+ * and result is plain JSON: strings, numbers, booleans, arrays and objects,
11
+ * never a Buffer, a Date, a class or a function. So it crosses a Workers RPC
12
+ * boundary, or a process boundary later, as it is, and the in-process
13
+ * transport proves it by round-tripping everything through JSON.
14
+ *
15
+ * The app says who is asking and the vault decides. The app never holds a
16
+ * key: it encrypts a value under a fresh data key, has the vault wrap that
17
+ * key, and stores the result; to read, it names stored versions and the
18
+ * vault loads their keys itself.
19
+ *
20
+ * Decisions come back as values: `{ ok: false, refusal }` for a refusal, which
21
+ * the vault has already logged. Only a fault (a bug, the store down) throws.
22
+ */
23
+ export interface Vault {
24
+ /** Unwrap data keys for `principal`: all of them, or none. */
25
+ unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>>;
26
+ /**
27
+ * Wrap fresh data keys for new versions `principal` writes: all, or none.
28
+ * `seqs` are the `key.wrap` entries, one per key, which the app's
29
+ * `secret.write` entries name as related.
30
+ */
31
+ wrap(input: WrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>>;
32
+ /**
33
+ * Wrap existing data keys again for new versions of the same secrets,
34
+ * under the current key: restoring an old value. It needs a write grant,
35
+ * not a read one, since the key never leaves the vault.
36
+ */
37
+ rewrap(input: RewrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>>;
38
+
39
+ /**
40
+ * What one principal holds right now: the app asks once per request. A
41
+ * decision, its rows checked: lists of who holds what read the rows
42
+ * themselves, and need not ask.
43
+ */
44
+ access(principal: string): Promise<Access>;
45
+ /** Set roles at several places for one principal: all of it, or none. */
46
+ setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>>;
47
+ /**
48
+ * Add a member, bring back a removed one, or change whether they are an
49
+ * owner. `generation` is theirs now: sessions and tokens of an earlier
50
+ * one are dead.
51
+ */
52
+ admit(input: AdmitInput): Promise<Outcome<{ created: boolean; owner: boolean; generation: number }>>;
53
+ /** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
54
+ remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>>;
55
+
56
+ /**
57
+ * Sign the log up to its last entry, in an `audit.checkpoint` entry of
58
+ * the vault's, if the whole chain recomputes from its first entry, the
59
+ * vault's entries by their MACs, and the prefix the last checkpoint signed
60
+ * is still there. The vault reads the log itself: it takes nobody's word
61
+ * for where it ends, or for what came before. It checks every
62
+ * member's row too, as `access` would, and logs a `vault.tampered` for
63
+ * each one changed around it: lists read rows without asking the vault.
64
+ */
65
+ checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>>;
66
+ /**
67
+ * What only the vault's configuration says, which the app shows: the key
68
+ * its checkpoints verify under, and the root admins, as principals.
69
+ */
70
+ about(): Promise<{ publicKey: string; rootAdmins: string[] }>;
71
+ /**
72
+ * Check the whole log: rehash it from the first entry, the vault's
73
+ * entries by their MACs, every checkpoint against the prefix it signed,
74
+ * and replay it to see that who is a member, and what they hold, follow
75
+ * from it. A verdict and nothing else, so anyone may ask; the app asks
76
+ * for owners.
77
+ */
78
+ verifyLog(input: VerifyLogInput): Promise<LogVerification>;
79
+ }
80
+
81
+ /**
82
+ * The vault's entries that change what a member holds: what their row's
83
+ * `access_seq` names, and what a finding about them is newer than while it
84
+ * stands.
85
+ */
86
+ export const ACCESS_ACTIONS = ['member.add', 'member.restore', 'member.owner', 'member.remove', 'access.grant', 'access.revoke'] as const;
87
+
88
+ /** A refusal, already logged by the time the app sees it. */
89
+ export type Refusal = { code: RefusalCode; message: string };
90
+
91
+ export type RefusalCode =
92
+ /** The principal was removed; only `admit` brings them back. */
93
+ | 'removed'
94
+ /** The principal was never admitted. */
95
+ | 'not_a_member'
96
+ /** No grant covers the place for what was asked. */
97
+ | 'no_grant'
98
+ /** A grant covered it, but has expired. */
99
+ | 'expired'
100
+ /** Too many unwraps in the window; see `BulkLimit`. */
101
+ | 'bulk_limit'
102
+ /** The wrapped key does not belong to the secret it was presented as. */
103
+ | 'bad_claim'
104
+ /** Not allowed to manage access, members or the log. */
105
+ | 'not_allowed'
106
+ /** Root admins come from the vault's configuration and cannot be changed. */
107
+ | 'root_admin'
108
+ /** Well-formed, but not something the rules allow: a project role on an environment, an owner token. */
109
+ | 'invalid'
110
+ /** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
111
+ | 'log_broken'
112
+ /**
113
+ * A KEK the vault is given does not open what it wrapped: a restore with
114
+ * the wrong key, or a key mistyped under the right id. Every key
115
+ * operation is refused until the vault restarts with the right one.
116
+ */
117
+ | 'wrong_kek'
118
+ /**
119
+ * The member's row or grants were changed outside the vault, or put back
120
+ * from before a later change: it refuses them until an owner removes them,
121
+ * which starts their access over from the log.
122
+ */
123
+ | 'tampered';
124
+
125
+ export type Outcome<T> = ({ ok: true } & T) | { ok: false; refusal: Refusal };
126
+
127
+ /**
128
+ * A proposed version the app will store. The ids bind its key, and `path`
129
+ * is the app's label for the write's log. Reads name stored version ids
130
+ * instead, so the vault reads the binding and wrapped key itself.
131
+ */
132
+ export type SecretRef = {
133
+ projectId: string;
134
+ environmentId: string;
135
+ secretId: string;
136
+ version: number;
137
+ /** `market/prod/DATABASE_URL`. */
138
+ path: string;
139
+ };
140
+
141
+ /** A wrapped data key and the key encryption key that wrapped it; `bytes` is base64. */
142
+ export type WrappedKey = { kekProvider: string; kekId: string; kekVersion: string; bytes: string };
143
+
144
+ /** Why someone reads: shown in the log, and the same rules apply to each. */
145
+ export type Purpose = 'reveal' | 'run' | 'compare' | 'sync';
146
+
147
+ /** Ties the vault's entries to the app's request and audit rows. */
148
+ type Correlation = {
149
+ requestId?: string | null;
150
+ /**
151
+ * The one action the call is part of, the app's id for it: a reveal's
152
+ * reads, a write's versions, an access change. Its entries share it.
153
+ */
154
+ operationId?: string | null;
155
+ };
156
+
157
+ export type UnwrapInput = Correlation & {
158
+ principal: string;
159
+ purpose: Purpose;
160
+ items: { secretVersionId: string }[];
161
+ };
162
+
163
+ export type WrapInput = Correlation & {
164
+ principal: string;
165
+ /** `key` is a fresh 32-byte data key, base64. */
166
+ items: { secret: SecretRef; key: string }[];
167
+ };
168
+
169
+ export type RewrapInput = Correlation & {
170
+ principal: string;
171
+ /** `secret` is the proposed new version; `secretVersionId` is the stored source. */
172
+ items: { secret: SecretRef; secretVersionId: string }[];
173
+ };
174
+
175
+ export type Grant = {
176
+ projectId: string;
177
+ /** Null for the whole project. */
178
+ environmentId: string | null;
179
+ role: Role;
180
+ /** ISO 8601, or null for no end. */
181
+ expiresAt: string | null;
182
+ grantedAt: string;
183
+ grantedBy: string;
184
+ };
185
+
186
+ export type Access = {
187
+ principal: string;
188
+ /**
189
+ * `unknown` for someone never admitted, `tampered` for a member whose row
190
+ * fails the vault's integrity check: refused everything, as `removed` is.
191
+ * Root admins are always active.
192
+ */
193
+ status: 'active' | 'removed' | 'unknown' | 'tampered';
194
+ /** Advanced by removal, even if the app cannot commit its credential revocations. */
195
+ generation: number;
196
+ isRootAdmin: boolean;
197
+ /** Root admins and users admitted as owners, while active. */
198
+ isOwner: boolean;
199
+ /** Live grants only, and none unless active. */
200
+ grants: Grant[];
201
+ /** When the status last changed, and who changed it; null for root admins and strangers. */
202
+ since: string | null;
203
+ by: string | null;
204
+ };
205
+
206
+ /** One place: a role to hold there, with an optional end, or null for none. */
207
+ export type GrantChange = {
208
+ projectId: string;
209
+ environmentId: string | null;
210
+ role: Role | null;
211
+ expiresAt: string | null;
212
+ };
213
+
214
+ export type AccessChange = 'created' | 'updated' | 'revoked' | 'unchanged';
215
+
216
+ export type SetAccessInput = Correlation & {
217
+ actor: string;
218
+ principal: string;
219
+ changes: GrantChange[];
220
+ };
221
+
222
+ export type AdmitInput = Correlation & {
223
+ actor: string;
224
+ principal: string;
225
+ /** Left out, an existing member keeps their role and a new one is not an owner. */
226
+ owner?: boolean;
227
+ };
228
+
229
+ export type RemoveInput = Correlation & {
230
+ actor: string;
231
+ principal: string;
232
+ /**
233
+ * For a sync: the environment it pushes from. Whoever may manage it there
234
+ * may stop the sync, also once its grant there is revoked and only the
235
+ * one on its credential is left.
236
+ */
237
+ source?: { projectId: string; environmentId: string };
238
+ };
239
+
240
+ /**
241
+ * A prefix of the log the vault signed: every entry up to `seq`, the last
242
+ * of which has `hash`, which pins every byte before it. Kept as the vault's
243
+ * `audit.checkpoint` entry, just after the prefix it signs.
244
+ */
245
+ export type Checkpoint = {
246
+ seq: number;
247
+ /** The hash of entry `seq`, hex. */
248
+ hash: string;
249
+ signedAt: string;
250
+ keyId: string;
251
+ /** Ed25519 over `checkpointMessage(...)`, base64. */
252
+ signature: string;
253
+ };
254
+
255
+ /** An entry of the vault's log, and so the chain up to it. */
256
+ export type LogHead = { seq: number; hash: string };
257
+
258
+ export type VerifyLogInput = {
259
+ /**
260
+ * The last entry the app verified, its own entries by its key: the vault
261
+ * checks the log still holds it, so between them both authors' entries
262
+ * are authenticated over the same prefix.
263
+ */
264
+ upTo?: LogHead | null;
265
+ };
266
+
267
+ /**
268
+ * Whether the vault's log holds, with the number of entries in it. On a
269
+ * failure, the entry where it breaks, or null when the chain holds but the
270
+ * members and grants do not follow from it. Then `fault` says which, as
271
+ * facts the app can word with names: `reason` has only ids.
272
+ * Full checks report live key batches in `pending`; overdue ones fail.
273
+ */
274
+ export type LogVerification =
275
+ | { ok: true; entries: number; pending?: number }
276
+ | { ok: false; failedAtSeq: number | null; reason: string; fault?: AccessFault };