@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.
- package/LICENSE +21 -0
- package/README.md +10 -3
- package/dist/access.d.ts +100 -0
- package/dist/access.js +109 -0
- package/dist/audit.d.ts +119 -0
- package/dist/audit.js +160 -0
- package/dist/dotenv.d.ts +45 -0
- package/dist/dotenv.js +194 -0
- package/dist/envelope.d.ts +50 -0
- package/dist/envelope.js +83 -0
- package/dist/identity.d.ts +433 -0
- package/dist/identity.js +691 -0
- package/dist/kek.d.ts +72 -0
- package/dist/kek.js +486 -0
- package/dist/registry-C0NGpxcc.d.ts +126 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +13 -0
- package/dist/types-Mi7-Tzj9.js +57 -0
- package/dist/vault.d.ts +375 -0
- package/dist/vault.js +45 -0
- package/package.json +75 -5
- package/src/access-fault.ts +61 -0
- package/src/access.ts +152 -0
- package/src/audit/chain.ts +207 -0
- package/src/checkpoint.ts +13 -0
- package/src/context.ts +54 -0
- package/src/dotenv.ts +242 -0
- package/src/envelope.ts +127 -0
- package/src/identity/auth.ts +124 -0
- package/src/identity/index.ts +6 -0
- package/src/identity/signin/config.ts +301 -0
- package/src/identity/signin/github.ts +191 -0
- package/src/identity/signin/index.ts +5 -0
- package/src/identity/signin/oidc.ts +199 -0
- package/src/identity/signin/sealed.ts +55 -0
- package/src/identity/signin/types.ts +94 -0
- package/src/identity/tokens.ts +45 -0
- package/src/identity/types.ts +51 -0
- package/src/identity/verifier.ts +86 -0
- package/src/kek/aws-kms.ts +279 -0
- package/src/kek/cancellation.ts +56 -0
- package/src/kek/index.ts +6 -0
- package/src/kek/local.ts +122 -0
- package/src/kek/registry.ts +63 -0
- package/src/kek/sigv4.ts +75 -0
- package/src/kek/types.ts +79 -0
- package/src/schemas.ts +11 -0
- 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 };
|