@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
package/dist/kek.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { a as KekProvider, c as WrappedDek, i as KekCancelledError, n as DEK_BYTES, o as KekUnavailableError, r as KekBadClaimError, s as KeyOperation, t as KekRegistry, u as SecretContext } from "./registry-C0NGpxcc.js";
2
+ //#region src/kek/local.d.ts
3
+ /**
4
+ * A KEK held in local process memory, loaded from configuration.
5
+ *
6
+ * This is the local-development implementation of `KekProvider`. It is a
7
+ * different implementation of the same interface, not a branch that skips
8
+ * wrapping: the envelope format, the context binding and the failure modes are
9
+ * identical to what a remote KMS-backed provider will produce.
10
+ */
11
+ export declare class LocalKekProvider implements KekProvider {
12
+ #private;
13
+ readonly provider = "local";
14
+ readonly keyId: string;
15
+ readonly keyVersion: string;
16
+ constructor(kek: Buffer, keyId: string, keyVersion?: string);
17
+ /** Build a provider from a base64-encoded 32-byte key, e.g. from an env var. */
18
+ static fromBase64(encoded: string, keyId: string, keyVersion?: string): LocalKekProvider;
19
+ /** Generate a fresh random KEK. Used by tests and by the dev seed script. */
20
+ static generate(keyId: string, keyVersion?: string): LocalKekProvider;
21
+ wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek>;
22
+ unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
23
+ }
24
+ /** Constant-time buffer comparison, for tests and callers that need it. */
25
+ export declare function equalBytes(a: Buffer, b: Buffer): boolean;
26
+ //#endregion
27
+ //#region src/kek/sigv4.d.ts
28
+ /** An AWS access key, with `sessionToken` when it is temporary (a role's). */
29
+ type AwsCredentials = {
30
+ accessKeyId: string;
31
+ secretAccessKey: string;
32
+ sessionToken?: string;
33
+ };
34
+ //#endregion
35
+ //#region src/kek/aws-kms.d.ts
36
+ type AwsKmsOptions = {
37
+ /**
38
+ * The key's ARN, `arn:aws:kms:<region>:<account>:key/<id>`. Coffre calls
39
+ * KMS in that region, and records the ARN on every data key it wraps. Not
40
+ * an alias, which can come to name another key.
41
+ */
42
+ keyArn: string;
43
+ /**
44
+ * An access key allowed `kms:Encrypt` and `kms:Decrypt` on it, or a
45
+ * function that returns one, asked before each call so that it may refresh
46
+ * (an AWS SDK credential provider is one).
47
+ */
48
+ credentials: AwsCredentials | (() => Promise<AwsCredentials>);
49
+ /** For tests. */
50
+ fetch?: typeof fetch;
51
+ };
52
+ /** A KEK held in AWS KMS: it never leaves, and coffre asks KMS to wrap and unwrap each data key. */
53
+ export declare function awsKms(options: AwsKmsOptions): KekProvider;
54
+ /**
55
+ * KMS's JSON API, `Encrypt` and `Decrypt`, signed with SigV4 and no SDK, so
56
+ * it runs as is on Node and in a Worker. Each call sends the secret's UUIDs
57
+ * as the encryption context, which KMS binds to the ciphertext and CloudTrail
58
+ * logs with the call: every decrypt there names the secret it was for, in a
59
+ * log neither coffre nor whoever runs it can edit.
60
+ */
61
+ export declare class AwsKmsKekProvider implements KekProvider {
62
+ #private;
63
+ readonly provider = "aws-kms";
64
+ readonly keyId: string;
65
+ /** KMS keeps its own versions inside each ciphertext, and rotates the key material under the same ARN. */
66
+ readonly keyVersion = "1";
67
+ constructor(options: AwsKmsOptions);
68
+ wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek>;
69
+ unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
70
+ }
71
+ //#endregion
72
+ export { type AwsCredentials, type AwsKmsOptions, DEK_BYTES, KekBadClaimError, KekCancelledError, type KekProvider, KekRegistry, KekUnavailableError, type KeyOperation, type WrappedDek };
package/dist/kek.js ADDED
@@ -0,0 +1,486 @@
1
+ import { i as KekUnavailableError, n as KekBadClaimError, o as checkContext, r as KekCancelledError, s as encodeAad, t as DEK_BYTES } from "./types-Mi7-Tzj9.js";
2
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ //#region src/kek/cancellation.ts
4
+ function checkOperation(operation, uncertain = false) {
5
+ if (operation !== void 0 && (operation.signal.aborted || Date.now() >= operation.deadline)) throw new KekCancelledError(uncertain);
6
+ }
7
+ const signals = /* @__PURE__ */ new WeakMap();
8
+ /**
9
+ * Enforce the deadline even when the caller's signal has not fired yet: one
10
+ * signal for every call of an operation, so its deadline cancels them all at
11
+ * once. With a timer per call, one call's could fire first, free its slot,
12
+ * and start a key queued behind it whose own timer had not fired yet.
13
+ */
14
+ function operationSignal(operation) {
15
+ if (operation === void 0) return void 0;
16
+ checkOperation(operation);
17
+ let signal = signals.get(operation);
18
+ if (signal === void 0) {
19
+ signal = AbortSignal.any([operation.signal, AbortSignal.timeout(Math.max(0, operation.deadline - Date.now()))]);
20
+ signals.set(operation, signal);
21
+ }
22
+ return signal;
23
+ }
24
+ /** Credentials can finish later, but must never send a request after cancellation. */
25
+ function cancellable(work, signal) {
26
+ if (signal === void 0) return work;
27
+ return new Promise((resolve, reject) => {
28
+ const abort = () => {
29
+ signal.removeEventListener("abort", abort);
30
+ reject(new KekCancelledError());
31
+ };
32
+ if (signal.aborted) abort();
33
+ else signal.addEventListener("abort", abort, { once: true });
34
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
35
+ });
36
+ }
37
+ function delay(ms, signal) {
38
+ return new Promise((resolve, reject) => {
39
+ const abort = () => {
40
+ clearTimeout(timer);
41
+ signal?.removeEventListener("abort", abort);
42
+ reject(new KekCancelledError());
43
+ };
44
+ const timer = setTimeout(() => {
45
+ signal?.removeEventListener("abort", abort);
46
+ resolve();
47
+ }, ms);
48
+ if (signal?.aborted) abort();
49
+ else signal?.addEventListener("abort", abort, { once: true });
50
+ });
51
+ }
52
+ //#endregion
53
+ //#region src/kek/local.ts
54
+ const IV_BYTES = 12;
55
+ const KEK_BYTES = 32;
56
+ /**
57
+ * A KEK held in local process memory, loaded from configuration.
58
+ *
59
+ * This is the local-development implementation of `KekProvider`. It is a
60
+ * different implementation of the same interface, not a branch that skips
61
+ * wrapping: the envelope format, the context binding and the failure modes are
62
+ * identical to what a remote KMS-backed provider will produce.
63
+ */
64
+ var LocalKekProvider = class LocalKekProvider {
65
+ provider = "local";
66
+ keyId;
67
+ keyVersion;
68
+ #kek;
69
+ constructor(kek, keyId, keyVersion = "1") {
70
+ if (kek.length !== KEK_BYTES) throw new Error(`local KEK must be ${KEK_BYTES} bytes, got ${kek.length}`);
71
+ this.#kek = kek;
72
+ this.keyId = keyId;
73
+ this.keyVersion = keyVersion;
74
+ }
75
+ /** Build a provider from a base64-encoded 32-byte key, e.g. from an env var. */
76
+ static fromBase64(encoded, keyId, keyVersion = "1") {
77
+ return new LocalKekProvider(Buffer.from(encoded, "base64"), keyId, keyVersion);
78
+ }
79
+ /** Generate a fresh random KEK. Used by tests and by the dev seed script. */
80
+ static generate(keyId, keyVersion = "1") {
81
+ return new LocalKekProvider(randomBytes(KEK_BYTES), keyId, keyVersion);
82
+ }
83
+ /**
84
+ * AAD for the wrap operation.
85
+ *
86
+ * Binds the wrapped DEK both to the secret it belongs to and to the KEK
87
+ * identity that produced it, so a wrapped DEK cannot be replayed against a
88
+ * row claiming a different KEK.
89
+ */
90
+ #wrapAad(ctx) {
91
+ return Buffer.concat([encodeAad(ctx), Buffer.from(`|${this.provider}|${this.keyId}|${this.keyVersion}`, "utf8")]);
92
+ }
93
+ async wrap(dek, ctx, operation) {
94
+ checkOperation(operation);
95
+ if (dek.length !== 32) throw new Error(`DEK must be 32 bytes, got ${dek.length}`);
96
+ const iv = randomBytes(IV_BYTES);
97
+ const cipher = createCipheriv("aes-256-gcm", this.#kek, iv);
98
+ cipher.setAAD(this.#wrapAad(ctx));
99
+ const body = Buffer.concat([cipher.update(dek), cipher.final()]);
100
+ return {
101
+ kekProvider: this.provider,
102
+ kekId: this.keyId,
103
+ kekVersion: this.keyVersion,
104
+ bytes: Buffer.concat([
105
+ iv,
106
+ cipher.getAuthTag(),
107
+ body
108
+ ])
109
+ };
110
+ }
111
+ async unwrap(wrapped, ctx, operation) {
112
+ checkOperation(operation);
113
+ if (wrapped.kekProvider !== this.provider || wrapped.kekId !== this.keyId) throw new KekBadClaimError(`wrapped DEK is for ${wrapped.kekProvider}:${wrapped.kekId}, not ${this.provider}:${this.keyId}`);
114
+ if (wrapped.bytes.length !== 60) throw new KekBadClaimError("wrapped DEK has the wrong length");
115
+ const iv = wrapped.bytes.subarray(0, IV_BYTES);
116
+ const tag = wrapped.bytes.subarray(IV_BYTES, 28);
117
+ const body = wrapped.bytes.subarray(28);
118
+ const decipher = createDecipheriv("aes-256-gcm", this.#kek, iv);
119
+ decipher.setAAD(this.#wrapAad(ctx));
120
+ decipher.setAuthTag(tag);
121
+ let dek;
122
+ const partial = decipher.update(body);
123
+ try {
124
+ dek = Buffer.concat([partial, decipher.final()]);
125
+ } catch {
126
+ throw new KekBadClaimError("wrapped DEK does not open under this context");
127
+ } finally {
128
+ partial.fill(0);
129
+ }
130
+ if (dek.length !== 32) {
131
+ dek.fill(0);
132
+ throw new KekBadClaimError("unwrapped DEK has the wrong length");
133
+ }
134
+ return dek;
135
+ }
136
+ };
137
+ /** Constant-time buffer comparison, for tests and callers that need it. */
138
+ function equalBytes(a, b) {
139
+ return a.length === b.length && timingSafeEqual(a, b);
140
+ }
141
+ //#endregion
142
+ //#region src/kek/registry.ts
143
+ function refOf(provider, keyId) {
144
+ return `${provider}:${keyId}`;
145
+ }
146
+ /**
147
+ * A set of active KEKs, one of which is primary.
148
+ *
149
+ * New secret versions are always wrapped under the primary. Old versions keep
150
+ * unwrapping under whichever KEK produced them, looked up by the provider and
151
+ * key id stored on their own row. Key rotation and migrating from the local
152
+ * provider to a KMS-backed one are therefore the same operation: add a new KEK,
153
+ * make it primary, leave the old one readable until nothing references it.
154
+ */
155
+ var KekRegistry = class {
156
+ #byRef = /* @__PURE__ */ new Map();
157
+ #primary;
158
+ constructor(primary, additional = []) {
159
+ this.#primary = primary;
160
+ for (const provider of [primary, ...additional]) {
161
+ const ref = refOf(provider.provider, provider.keyId);
162
+ if (this.#byRef.has(ref)) throw new Error(`duplicate KEK in registry: ${ref}`);
163
+ this.#byRef.set(ref, provider);
164
+ }
165
+ }
166
+ get primary() {
167
+ return this.#primary;
168
+ }
169
+ /** Every KEK, the primary first. */
170
+ get all() {
171
+ return [...this.#byRef.values()];
172
+ }
173
+ /** The KEK a wrapped key names, or undefined when none is configured. */
174
+ providerOf(wrapped) {
175
+ return this.#byRef.get(refOf(wrapped.kekProvider, wrapped.kekId));
176
+ }
177
+ /** Wrap under the primary KEK. Used for every new secret version. */
178
+ wrap(dek, ctx, operation) {
179
+ return this.#primary.wrap(dek, ctx, operation);
180
+ }
181
+ /** Unwrap under whichever KEK the row says produced it. */
182
+ unwrap(wrapped, ctx, operation) {
183
+ const ref = refOf(wrapped.kekProvider, wrapped.kekId);
184
+ const provider = this.#byRef.get(ref);
185
+ if (!provider) throw new Error(`no KEK configured for ${ref}; cannot unwrap`);
186
+ return provider.unwrap(wrapped, ctx, operation);
187
+ }
188
+ };
189
+ //#endregion
190
+ //#region src/kek/sigv4.ts
191
+ /**
192
+ * Every header a request signed with AWS Signature Version 4 sends: those
193
+ * given, `host`, `x-amz-date`, the session token if any, and
194
+ * `authorization`, which signs all of them and the body. The path is used as
195
+ * it is, which is right for the only one KMS has, `/`.
196
+ *
197
+ * https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
198
+ */
199
+ function signV4(input) {
200
+ const stamp = input.date.toISOString().replace(/[-:]|\.\d{3}/g, "");
201
+ const day = stamp.slice(0, 8);
202
+ const headers = {
203
+ host: input.url.host,
204
+ "x-amz-date": stamp
205
+ };
206
+ for (const [name, value] of Object.entries(input.headers)) headers[name.toLowerCase()] = value;
207
+ if (input.credentials.sessionToken) headers["x-amz-security-token"] = input.credentials.sessionToken;
208
+ const names = Object.keys(headers).sort();
209
+ const signed = names.join(";");
210
+ const request = [
211
+ input.method,
212
+ input.url.pathname,
213
+ canonicalQuery(input.url.searchParams),
214
+ ...names.map((name) => `${name}:${headers[name].trim().replace(/\s+/g, " ")}`),
215
+ "",
216
+ signed,
217
+ sha256(input.body)
218
+ ].join("\n");
219
+ const scope = `${day}/${input.region}/${input.service}/aws4_request`;
220
+ const toSign = [
221
+ "AWS4-HMAC-SHA256",
222
+ stamp,
223
+ scope,
224
+ sha256(request)
225
+ ].join("\n");
226
+ let key = hmac(`AWS4${input.credentials.secretAccessKey}`, day);
227
+ for (const part of [
228
+ input.region,
229
+ input.service,
230
+ "aws4_request"
231
+ ]) key = hmac(key, part);
232
+ const signature = createHmac("sha256", key).update(toSign, "utf8").digest("hex");
233
+ return {
234
+ ...headers,
235
+ authorization: `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${signature}`
236
+ };
237
+ }
238
+ function canonicalQuery(params) {
239
+ return [...params].map(([name, value]) => [encode(name), encode(value)]).sort(([a, x], [b, y]) => a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0).map(([name, value]) => `${name}=${value}`).join("&");
240
+ }
241
+ /** RFC 3986: everything but letters, digits and `-._~` percent-encoded. */
242
+ function encode(text) {
243
+ return encodeURIComponent(text).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
244
+ }
245
+ function sha256(text) {
246
+ return createHash("sha256").update(text, "utf8").digest("hex");
247
+ }
248
+ function hmac(key, text) {
249
+ return createHmac("sha256", key).update(text, "utf8").digest();
250
+ }
251
+ //#endregion
252
+ //#region src/kek/aws-kms.ts
253
+ const KEY_ARN = /^arn:(aws|aws-cn|aws-us-gov):kms:([a-z0-9-]+):\d{12}:key\/[A-Za-z0-9-]{1,128}$/;
254
+ const ATTEMPTS = 3;
255
+ const TIMEOUT_MS = 5e3;
256
+ /** Calls in flight at once. A Worker opens at most six connections anyway. */
257
+ const CONCURRENCY = 8;
258
+ /** What KMS says when the ciphertext does not open under this key and context: a bad claim, not an outage. */
259
+ const NOT_THIS_KEY = /* @__PURE__ */ new Set(["InvalidCiphertextException", "IncorrectKeyException"]);
260
+ /** A KEK held in AWS KMS: it never leaves, and coffre asks KMS to wrap and unwrap each data key. */
261
+ function awsKms(options) {
262
+ return new AwsKmsKekProvider(options);
263
+ }
264
+ /**
265
+ * KMS's JSON API, `Encrypt` and `Decrypt`, signed with SigV4 and no SDK, so
266
+ * it runs as is on Node and in a Worker. Each call sends the secret's UUIDs
267
+ * as the encryption context, which KMS binds to the ciphertext and CloudTrail
268
+ * logs with the call: every decrypt there names the secret it was for, in a
269
+ * log neither coffre nor whoever runs it can edit.
270
+ */
271
+ var AwsKmsKekProvider = class {
272
+ provider = "aws-kms";
273
+ keyId;
274
+ /** KMS keeps its own versions inside each ciphertext, and rotates the key material under the same ARN. */
275
+ keyVersion = "1";
276
+ #region;
277
+ #url;
278
+ #credentials;
279
+ #fetch;
280
+ #slots = new Slots(CONCURRENCY);
281
+ constructor(options) {
282
+ const match = KEY_ARN.exec(options.keyArn);
283
+ if (match === null) throw new Error(`the KMS key must be a key ARN, like arn:aws:kms:eu-west-3:123456789012:key/<id>, not an alias; got "${options.keyArn}"`);
284
+ const [, partition, region] = match;
285
+ this.keyId = options.keyArn;
286
+ this.#region = region;
287
+ this.#url = new URL(`https://kms.${region}.amazonaws.com${partition === "aws-cn" ? ".cn" : ""}/`);
288
+ const { credentials } = options;
289
+ this.#credentials = typeof credentials === "function" ? credentials : async () => credentials;
290
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
291
+ }
292
+ async wrap(dek, ctx, operation) {
293
+ if (dek.length !== 32) throw new Error(`DEK must be 32 bytes, got ${dek.length}`);
294
+ const { CiphertextBlob } = await this.#call("Encrypt", {
295
+ KeyId: this.keyId,
296
+ Plaintext: base64(dek),
297
+ EncryptionContext: encryptionContext(ctx)
298
+ }, operation);
299
+ return {
300
+ kekProvider: this.provider,
301
+ kekId: this.keyId,
302
+ kekVersion: this.keyVersion,
303
+ bytes: Buffer.from(CiphertextBlob, "base64")
304
+ };
305
+ }
306
+ async unwrap(wrapped, ctx, operation) {
307
+ if (wrapped.kekProvider !== this.provider || wrapped.kekId !== this.keyId) throw new KekBadClaimError(`wrapped DEK is for ${wrapped.kekProvider}:${wrapped.kekId}, not ${this.provider}:${this.keyId}`);
308
+ const { Plaintext, KeyId } = await this.#call("Decrypt", {
309
+ KeyId: this.keyId,
310
+ CiphertextBlob: base64(wrapped.bytes),
311
+ EncryptionContext: encryptionContext(ctx)
312
+ }, operation);
313
+ if (KeyId !== this.keyId) throw new KekUnavailableError(`KMS decrypted under ${KeyId}, not ${this.keyId}`, true);
314
+ const dek = Buffer.from(Plaintext, "base64");
315
+ if (dek.length !== 32) {
316
+ dek.fill(0);
317
+ throw new KekUnavailableError("unwrapped DEK has the wrong length", true);
318
+ }
319
+ return dek;
320
+ }
321
+ /**
322
+ * One KMS call, tried up to three times while KMS throttles, fails on its
323
+ * side or does not answer. Every failure but a ciphertext that does not
324
+ * open is a `KekUnavailableError`, retried or not: a key KMS will not use
325
+ * or credentials it refuses say nothing about the claim either.
326
+ */
327
+ async #call(action, request, operation) {
328
+ const body = JSON.stringify(request);
329
+ const signal = operationSignal(operation);
330
+ return this.#slots.run(async () => {
331
+ checkOperation(operation);
332
+ let credentials;
333
+ try {
334
+ credentials = await cancellable(this.#credentials(), signal);
335
+ } catch (error) {
336
+ if (error instanceof KekCancelledError) throw error;
337
+ throw new KekUnavailableError(`no AWS credentials for KMS: ${messageOf(error)}`);
338
+ }
339
+ let uncertain = false;
340
+ for (let attempt = 1;; attempt++) {
341
+ checkOperation(operation, uncertain);
342
+ if (signal?.aborted) throw new KekCancelledError(uncertain);
343
+ const answer = await this.#send(action, body, credentials, signal, operation);
344
+ if ("data" in answer) return answer.data;
345
+ uncertain ||= answer.uncertain;
346
+ checkOperation(operation, uncertain);
347
+ if (signal?.aborted) throw new KekCancelledError(uncertain);
348
+ if (NOT_THIS_KEY.has(answer.type)) {
349
+ if (uncertain) throw new KekUnavailableError(`KMS ${action}: ${answer.type} after an unanswered request`, true);
350
+ throw new KekBadClaimError(`KMS ${action}: ${answer.type}`);
351
+ }
352
+ if (!answer.retry || attempt === ATTEMPTS) throw new KekUnavailableError(`KMS ${action} failed${attempt > 1 ? ` ${attempt} times` : ""}: ${answer.failure}`, uncertain);
353
+ try {
354
+ await delay(100 * 4 ** (attempt - 1) * (1 + Math.random()), signal);
355
+ } catch (error) {
356
+ if (error instanceof KekCancelledError) throw new KekCancelledError(uncertain);
357
+ throw error;
358
+ }
359
+ }
360
+ }, signal);
361
+ }
362
+ async #send(action, body, credentials, signal, operation) {
363
+ const headers = signV4({
364
+ method: "POST",
365
+ url: this.#url,
366
+ headers: {
367
+ "content-type": "application/x-amz-json-1.1",
368
+ "x-amz-target": `TrentService.${action}`
369
+ },
370
+ body,
371
+ region: this.#region,
372
+ service: "kms",
373
+ credentials,
374
+ date: /* @__PURE__ */ new Date()
375
+ });
376
+ let response;
377
+ let text;
378
+ let sent = false;
379
+ try {
380
+ checkOperation(operation);
381
+ if (signal?.aborted) throw new KekCancelledError();
382
+ sent = true;
383
+ response = await this.#fetch(this.#url, {
384
+ method: "POST",
385
+ headers,
386
+ body,
387
+ signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...signal === void 0 ? [] : [signal]])
388
+ });
389
+ text = await response.text();
390
+ } catch (error) {
391
+ return {
392
+ type: "network",
393
+ failure: messageOf(error),
394
+ retry: sent,
395
+ uncertain: sent
396
+ };
397
+ }
398
+ const parsed = parse(text);
399
+ if (response.ok) {
400
+ if (parsed !== null && (action === "Encrypt" ? typeof parsed.CiphertextBlob === "string" : typeof parsed.Plaintext === "string")) return { data: parsed };
401
+ return {
402
+ type: "malformed",
403
+ failure: `KMS answered ${response.status} without a result`,
404
+ retry: true,
405
+ uncertain: true
406
+ };
407
+ }
408
+ const type = (response.headers.get("x-amzn-errortype") ?? (typeof parsed?.__type === "string" ? parsed.__type : "")).split(":")[0].split("#").pop() || `HTTP ${response.status}`;
409
+ const message = parsed?.message ?? parsed?.Message;
410
+ return {
411
+ type,
412
+ failure: `${type}${typeof message === "string" ? `, ${message}` : ""}`,
413
+ uncertain: false,
414
+ retry: response.status >= 500 || response.status === 429 || type === "ThrottlingException"
415
+ };
416
+ }
417
+ };
418
+ /**
419
+ * The secret's UUIDs, as KMS binds them to the ciphertext and CloudTrail
420
+ * shows them. Checked first, so a malformed one never reaches KMS.
421
+ */
422
+ function encryptionContext(ctx) {
423
+ checkContext(ctx);
424
+ return {
425
+ "coffre:project": ctx.projectId,
426
+ "coffre:environment": ctx.environmentId,
427
+ "coffre:secret": ctx.secretId
428
+ };
429
+ }
430
+ /**
431
+ * Bytes as base64. Through `Buffer.from`, a view of the same memory, because
432
+ * Workers' types declare their own `Buffer` and a bare one loses its
433
+ * `toString(encoding)` to them.
434
+ */
435
+ function base64(bytes) {
436
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
437
+ }
438
+ function parse(text) {
439
+ try {
440
+ const value = JSON.parse(text);
441
+ return typeof value === "object" && value !== null ? value : null;
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
446
+ function messageOf(error) {
447
+ return error instanceof Error ? error.message : String(error);
448
+ }
449
+ /** At most `n` calls at once; the rest wait their turn. */
450
+ var Slots = class {
451
+ #free;
452
+ #waiting = [];
453
+ constructor(n) {
454
+ this.#free = n;
455
+ }
456
+ async run(work, signal) {
457
+ if (signal?.aborted) throw new KekCancelledError();
458
+ if (this.#free > 0) this.#free--;
459
+ else await new Promise((resolve, reject) => {
460
+ const waiting = {
461
+ start: () => {
462
+ signal?.removeEventListener("abort", waiting.cancel);
463
+ resolve();
464
+ },
465
+ cancel: () => {
466
+ const index = this.#waiting.indexOf(waiting);
467
+ if (index >= 0) this.#waiting.splice(index, 1);
468
+ signal?.removeEventListener("abort", waiting.cancel);
469
+ reject(new KekCancelledError());
470
+ }
471
+ };
472
+ this.#waiting.push(waiting);
473
+ signal?.addEventListener("abort", waiting.cancel, { once: true });
474
+ });
475
+ try {
476
+ if (signal?.aborted) throw new KekCancelledError();
477
+ return await work();
478
+ } finally {
479
+ const next = this.#waiting.shift();
480
+ if (next) next.start();
481
+ else this.#free++;
482
+ }
483
+ }
484
+ };
485
+ //#endregion
486
+ export { AwsKmsKekProvider, DEK_BYTES, KekBadClaimError, KekCancelledError, KekRegistry, KekUnavailableError, LocalKekProvider, awsKms, equalBytes };
@@ -0,0 +1,126 @@
1
+ //#region src/context.d.ts
2
+ /**
3
+ * The binding context for a secret's ciphertext.
4
+ *
5
+ * This is the AES-GCM additional authenticated data (AAD). It ties a ciphertext
6
+ * to exactly one (project, environment, secret) triple, so a ciphertext lifted
7
+ * out of the `dev` environment row and dropped into the `prod` row fails to
8
+ * decrypt rather than silently succeeding.
9
+ *
10
+ * These are immutable UUIDs, never names. Binding to `project/environment/key`
11
+ * names would mean that renaming an environment or a secret key makes every
12
+ * existing ciphertext permanently undecryptable.
13
+ */
14
+ type SecretContext = {
15
+ projectId: string;
16
+ environmentId: string;
17
+ secretId: string;
18
+ };
19
+ /**
20
+ * Version prefix for the AAD encoding. If the encoding ever changes, this
21
+ * changes with it, and old ciphertexts keep decrypting under the old rule.
22
+ */
23
+ declare const AAD_VERSION = "coffre.aad.v1";
24
+ /** Throw unless every field is a lowercase UUID. */
25
+ declare function checkContext(ctx: SecretContext): void;
26
+ /**
27
+ * Encode a context into AAD bytes.
28
+ *
29
+ * Every field is validated as a UUID first, which is what makes joining with
30
+ * `|` unambiguous: a UUID cannot contain the separator, so no two distinct
31
+ * contexts can encode to the same bytes.
32
+ */
33
+ declare function encodeAad(ctx: SecretContext): Buffer;
34
+ //#endregion
35
+ //#region src/kek/types.d.ts
36
+ /**
37
+ * A data encryption key wrapped by a key encryption key.
38
+ *
39
+ * The provider metadata travels with every wrapped DEK and is stored per row,
40
+ * never as a single global key id. Rotation and provider migration are then the
41
+ * same code path: write new rows under the new KEK, keep unwrapping old rows
42
+ * under the old one.
43
+ */
44
+ type WrappedDek = {
45
+ kekProvider: string;
46
+ kekId: string;
47
+ kekVersion: string;
48
+ bytes: Buffer;
49
+ };
50
+ /** One deadline for a batch. Providers must abort and settle their work when its signal fires. */
51
+ type KeyOperation = {
52
+ deadline: number;
53
+ signal: AbortSignal;
54
+ };
55
+ /**
56
+ * The only interface any key encryption key backend must satisfy.
57
+ *
58
+ * Two methods. We generate the DEK ourselves with a CSPRNG and use the backend
59
+ * purely to wrap it, rather than depending on a provider `GenerateDataKey`
60
+ * primitive -- GCP KMS has no such call, and staying off it is what keeps the
61
+ * exit plan cheap.
62
+ *
63
+ * `ctx` is passed through to the provider deliberately. A remote KEK service
64
+ * cannot otherwise record *which* secret an unwrap was for, only that some
65
+ * opaque DEK was unwrapped. AWS KMS logs it in CloudTrail as the encryption
66
+ * context (`aws-kms.ts`). Scaleway's Audit Trail does not log Key Manager
67
+ * Decrypt at all, which is why AWS came first.
68
+ *
69
+ * `unwrap` throws `KekUnavailableError` when the service cannot answer, and
70
+ * `KekBadClaimError` when the wrapped DEK does not open under `ctx`. Other
71
+ * errors are unexpected faults; the vault records them and fails the call.
72
+ */
73
+ interface KekProvider {
74
+ readonly provider: string;
75
+ readonly keyId: string;
76
+ readonly keyVersion: string;
77
+ wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek>;
78
+ unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
79
+ }
80
+ /** Length of the data encryption keys we generate, in bytes (AES-256). */
81
+ declare const DEK_BYTES = 32;
82
+ /**
83
+ * The key service could not answer: it is down, throttling past the
84
+ * retries, or refusing coffre's own credentials. Nothing is known about the
85
+ * wrapped key, so the vault fails the call instead of refusing it.
86
+ */
87
+ declare class KekUnavailableError extends Error {
88
+ readonly name: string;
89
+ readonly uncertain: boolean;
90
+ constructor(message: string, uncertain?: boolean);
91
+ }
92
+ /** No more work may start. A request already sent may have been accepted by KMS. */
93
+ declare class KekCancelledError extends KekUnavailableError {
94
+ readonly name = "KekCancelledError";
95
+ constructor(uncertain?: boolean);
96
+ }
97
+ /** The ciphertext does not open under the claimed key and context. */
98
+ declare class KekBadClaimError extends Error {
99
+ readonly name = "KekBadClaimError";
100
+ }
101
+ //#endregion
102
+ //#region src/kek/registry.d.ts
103
+ /**
104
+ * A set of active KEKs, one of which is primary.
105
+ *
106
+ * New secret versions are always wrapped under the primary. Old versions keep
107
+ * unwrapping under whichever KEK produced them, looked up by the provider and
108
+ * key id stored on their own row. Key rotation and migrating from the local
109
+ * provider to a KMS-backed one are therefore the same operation: add a new KEK,
110
+ * make it primary, leave the old one readable until nothing references it.
111
+ */
112
+ declare class KekRegistry {
113
+ #private;
114
+ constructor(primary: KekProvider, additional?: readonly KekProvider[]);
115
+ get primary(): KekProvider;
116
+ /** Every KEK, the primary first. */
117
+ get all(): KekProvider[];
118
+ /** The KEK a wrapped key names, or undefined when none is configured. */
119
+ providerOf(wrapped: Pick<WrappedDek, 'kekProvider' | 'kekId'>): KekProvider | undefined;
120
+ /** Wrap under the primary KEK. Used for every new secret version. */
121
+ wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek>;
122
+ /** Unwrap under whichever KEK the row says produced it. */
123
+ unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
124
+ }
125
+ //#endregion
126
+ export { KekProvider as a, WrappedDek as c, checkContext as d, encodeAad as f, KekCancelledError as i, AAD_VERSION as l, DEK_BYTES as n, KekUnavailableError as o, KekBadClaimError as r, KeyOperation as s, KekRegistry as t, SecretContext as u };