@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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
|
2
|
+
import type { AccessVerifierConfig, IdentityVerifier, Principal } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export type { AccessVerifierConfig };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Signature algorithms we accept, as an explicit allowlist.
|
|
8
|
+
*
|
|
9
|
+
* Without this, the algorithm is taken from the attacker-controlled token
|
|
10
|
+
* header, which is how `alg: none` and RS256->HS256 confusion work. jose
|
|
11
|
+
* defaults to permitting whatever the key supports; we do not rely on that.
|
|
12
|
+
*/
|
|
13
|
+
const ALLOWED_ALGORITHMS = ['RS256', 'ES256'] as const;
|
|
14
|
+
|
|
15
|
+
type AccessClaims = JWTPayload & {
|
|
16
|
+
email?: unknown;
|
|
17
|
+
common_name?: unknown;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function asNonEmptyString(value: unknown): string | null {
|
|
21
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Verifies Cloudflare Access JWTs.
|
|
26
|
+
*
|
|
27
|
+
* Signature and claim verification for the TanStack request boundary. The
|
|
28
|
+
* boundary also rejects `x-middleware-subrequest` before calling this verifier.
|
|
29
|
+
*/
|
|
30
|
+
export class AccessIdentityVerifier implements IdentityVerifier {
|
|
31
|
+
readonly #config: AccessVerifierConfig;
|
|
32
|
+
readonly #jwks: ReturnType<typeof createRemoteJWKSet>;
|
|
33
|
+
|
|
34
|
+
constructor(config: AccessVerifierConfig) {
|
|
35
|
+
this.#config = config;
|
|
36
|
+
// jose handles JWKS fetching, caching and rotation-driven refetch. Not
|
|
37
|
+
// something to hand-roll.
|
|
38
|
+
this.#jwks = createRemoteJWKSet(new URL(config.jwksUrl), {
|
|
39
|
+
cooldownDuration: 30_000,
|
|
40
|
+
cacheMaxAge: 600_000,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async verify(token: string): Promise<Principal> {
|
|
45
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
46
|
+
throw new Error('missing or empty Access token');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { payload } = await jwtVerify<AccessClaims>(token, this.#jwks, {
|
|
50
|
+
issuer: this.#config.issuer,
|
|
51
|
+
audience: this.#config.audience,
|
|
52
|
+
algorithms: [...ALLOWED_ALGORITHMS],
|
|
53
|
+
clockTolerance: this.#config.clockToleranceSeconds ?? 0,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return toPrincipal(payload);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derive the caller from verified claims.
|
|
62
|
+
*
|
|
63
|
+
* Order matters. A service token is identified by `common_name` and carries an
|
|
64
|
+
* empty `sub`; an identity token carries both `sub` and `email`. Anything else
|
|
65
|
+
* is rejected rather than being allowed through as an authenticated caller
|
|
66
|
+
* with no name -- an audit row with a null actor answers no question worth
|
|
67
|
+
* asking.
|
|
68
|
+
*/
|
|
69
|
+
export function toPrincipal(claims: AccessClaims): Principal {
|
|
70
|
+
const commonName = asNonEmptyString(claims.common_name);
|
|
71
|
+
if (commonName !== null) {
|
|
72
|
+
return { type: 'service', id: commonName, commonName };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const email = asNonEmptyString(claims.email);
|
|
76
|
+
const subject = asNonEmptyString(claims.sub);
|
|
77
|
+
if (email !== null && subject !== null) {
|
|
78
|
+
// People are stored by lowercased email; see principals_user_id_lowercase.
|
|
79
|
+
const id = email.toLowerCase();
|
|
80
|
+
return { type: 'user', id, email: id, subject };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(
|
|
84
|
+
'token verified but carries no usable identity: expected common_name (service) or sub+email (user)',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { checkContext, type SecretContext } from '../context.ts';
|
|
2
|
+
import { signV4, type AwsCredentials } from './sigv4.ts';
|
|
3
|
+
import { cancellable, checkOperation, delay, operationSignal } from './cancellation.ts';
|
|
4
|
+
import {
|
|
5
|
+
DEK_BYTES,
|
|
6
|
+
KekBadClaimError,
|
|
7
|
+
KekCancelledError,
|
|
8
|
+
KekUnavailableError,
|
|
9
|
+
type KeyOperation,
|
|
10
|
+
type KekProvider,
|
|
11
|
+
type WrappedDek,
|
|
12
|
+
} from './types.ts';
|
|
13
|
+
|
|
14
|
+
export type AwsKmsOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* The key's ARN, `arn:aws:kms:<region>:<account>:key/<id>`. Coffre calls
|
|
17
|
+
* KMS in that region, and records the ARN on every data key it wraps. Not
|
|
18
|
+
* an alias, which can come to name another key.
|
|
19
|
+
*/
|
|
20
|
+
keyArn: string;
|
|
21
|
+
/**
|
|
22
|
+
* An access key allowed `kms:Encrypt` and `kms:Decrypt` on it, or a
|
|
23
|
+
* function that returns one, asked before each call so that it may refresh
|
|
24
|
+
* (an AWS SDK credential provider is one).
|
|
25
|
+
*/
|
|
26
|
+
credentials: AwsCredentials | (() => Promise<AwsCredentials>);
|
|
27
|
+
/** For tests. */
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const KEY_ARN = /^arn:(aws|aws-cn|aws-us-gov):kms:([a-z0-9-]+):\d{12}:key\/[A-Za-z0-9-]{1,128}$/;
|
|
32
|
+
|
|
33
|
+
const ATTEMPTS = 3;
|
|
34
|
+
const TIMEOUT_MS = 5_000;
|
|
35
|
+
/** Calls in flight at once. A Worker opens at most six connections anyway. */
|
|
36
|
+
const CONCURRENCY = 8;
|
|
37
|
+
/** What KMS says when the ciphertext does not open under this key and context: a bad claim, not an outage. */
|
|
38
|
+
const NOT_THIS_KEY = new Set(['InvalidCiphertextException', 'IncorrectKeyException']);
|
|
39
|
+
|
|
40
|
+
/** A KEK held in AWS KMS: it never leaves, and coffre asks KMS to wrap and unwrap each data key. */
|
|
41
|
+
export function awsKms(options: AwsKmsOptions): KekProvider {
|
|
42
|
+
return new AwsKmsKekProvider(options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* KMS's JSON API, `Encrypt` and `Decrypt`, signed with SigV4 and no SDK, so
|
|
47
|
+
* it runs as is on Node and in a Worker. Each call sends the secret's UUIDs
|
|
48
|
+
* as the encryption context, which KMS binds to the ciphertext and CloudTrail
|
|
49
|
+
* logs with the call: every decrypt there names the secret it was for, in a
|
|
50
|
+
* log neither coffre nor whoever runs it can edit.
|
|
51
|
+
*/
|
|
52
|
+
export class AwsKmsKekProvider implements KekProvider {
|
|
53
|
+
readonly provider = 'aws-kms';
|
|
54
|
+
readonly keyId: string;
|
|
55
|
+
/** KMS keeps its own versions inside each ciphertext, and rotates the key material under the same ARN. */
|
|
56
|
+
readonly keyVersion = '1';
|
|
57
|
+
|
|
58
|
+
readonly #region: string;
|
|
59
|
+
readonly #url: URL;
|
|
60
|
+
readonly #credentials: () => Promise<AwsCredentials>;
|
|
61
|
+
readonly #fetch: typeof fetch;
|
|
62
|
+
readonly #slots = new Slots(CONCURRENCY);
|
|
63
|
+
|
|
64
|
+
constructor(options: AwsKmsOptions) {
|
|
65
|
+
const match = KEY_ARN.exec(options.keyArn);
|
|
66
|
+
if (match === null) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`the KMS key must be a key ARN, like arn:aws:kms:eu-west-3:123456789012:key/<id>, not an alias; got "${options.keyArn}"`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const [, partition, region] = match as unknown as [string, string, string];
|
|
72
|
+
this.keyId = options.keyArn;
|
|
73
|
+
this.#region = region;
|
|
74
|
+
this.#url = new URL(`https://kms.${region}.amazonaws.com${partition === 'aws-cn' ? '.cn' : ''}/`);
|
|
75
|
+
const { credentials } = options;
|
|
76
|
+
this.#credentials = typeof credentials === 'function' ? credentials : async () => credentials;
|
|
77
|
+
// Called bare: a Worker's fetch refuses to run as a method of anything else.
|
|
78
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek> {
|
|
82
|
+
if (dek.length !== DEK_BYTES) throw new Error(`DEK must be ${DEK_BYTES} bytes, got ${dek.length}`);
|
|
83
|
+
const { CiphertextBlob } = await this.#call('Encrypt', {
|
|
84
|
+
KeyId: this.keyId,
|
|
85
|
+
Plaintext: base64(dek),
|
|
86
|
+
EncryptionContext: encryptionContext(ctx),
|
|
87
|
+
}, operation);
|
|
88
|
+
return {
|
|
89
|
+
kekProvider: this.provider,
|
|
90
|
+
kekId: this.keyId,
|
|
91
|
+
kekVersion: this.keyVersion,
|
|
92
|
+
bytes: Buffer.from(CiphertextBlob, 'base64'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer> {
|
|
97
|
+
if (wrapped.kekProvider !== this.provider || wrapped.kekId !== this.keyId) {
|
|
98
|
+
throw new KekBadClaimError(`wrapped DEK is for ${wrapped.kekProvider}:${wrapped.kekId}, not ${this.provider}:${this.keyId}`);
|
|
99
|
+
}
|
|
100
|
+
// KeyId makes KMS refuse a ciphertext made under any other key.
|
|
101
|
+
const { Plaintext, KeyId } = await this.#call('Decrypt', {
|
|
102
|
+
KeyId: this.keyId,
|
|
103
|
+
CiphertextBlob: base64(wrapped.bytes),
|
|
104
|
+
EncryptionContext: encryptionContext(ctx),
|
|
105
|
+
}, operation);
|
|
106
|
+
if (KeyId !== this.keyId) throw new KekUnavailableError(`KMS decrypted under ${KeyId}, not ${this.keyId}`, true);
|
|
107
|
+
const dek = Buffer.from(Plaintext, 'base64');
|
|
108
|
+
if (dek.length !== DEK_BYTES) {
|
|
109
|
+
dek.fill(0);
|
|
110
|
+
throw new KekUnavailableError('unwrapped DEK has the wrong length', true);
|
|
111
|
+
}
|
|
112
|
+
return dek;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One KMS call, tried up to three times while KMS throttles, fails on its
|
|
117
|
+
* side or does not answer. Every failure but a ciphertext that does not
|
|
118
|
+
* open is a `KekUnavailableError`, retried or not: a key KMS will not use
|
|
119
|
+
* or credentials it refuses say nothing about the claim either.
|
|
120
|
+
*/
|
|
121
|
+
async #call(action: 'Encrypt' | 'Decrypt', request: object, operation?: KeyOperation): Promise<Record<string, string>> {
|
|
122
|
+
const body = JSON.stringify(request);
|
|
123
|
+
const signal = operationSignal(operation);
|
|
124
|
+
return this.#slots.run(async () => {
|
|
125
|
+
checkOperation(operation);
|
|
126
|
+
let credentials: AwsCredentials;
|
|
127
|
+
try {
|
|
128
|
+
credentials = await cancellable(this.#credentials(), signal);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof KekCancelledError) throw error;
|
|
131
|
+
throw new KekUnavailableError(`no AWS credentials for KMS: ${messageOf(error)}`);
|
|
132
|
+
}
|
|
133
|
+
let uncertain = false;
|
|
134
|
+
for (let attempt = 1; ; attempt++) {
|
|
135
|
+
checkOperation(operation, uncertain);
|
|
136
|
+
if (signal?.aborted) throw new KekCancelledError(uncertain);
|
|
137
|
+
const answer = await this.#send(action, body, credentials, signal, operation);
|
|
138
|
+
if ('data' in answer) return answer.data;
|
|
139
|
+
uncertain ||= answer.uncertain;
|
|
140
|
+
checkOperation(operation, uncertain);
|
|
141
|
+
if (signal?.aborted) throw new KekCancelledError(uncertain);
|
|
142
|
+
if (NOT_THIS_KEY.has(answer.type)) {
|
|
143
|
+
if (uncertain) throw new KekUnavailableError(`KMS ${action}: ${answer.type} after an unanswered request`, true);
|
|
144
|
+
throw new KekBadClaimError(`KMS ${action}: ${answer.type}`);
|
|
145
|
+
}
|
|
146
|
+
if (!answer.retry || attempt === ATTEMPTS) {
|
|
147
|
+
throw new KekUnavailableError(`KMS ${action} failed${attempt > 1 ? ` ${attempt} times` : ''}: ${answer.failure}`, uncertain);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await delay(100 * 4 ** (attempt - 1) * (1 + Math.random()), signal);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof KekCancelledError) throw new KekCancelledError(uncertain);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}, signal);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #send(
|
|
160
|
+
action: string,
|
|
161
|
+
body: string,
|
|
162
|
+
credentials: AwsCredentials,
|
|
163
|
+
signal?: AbortSignal,
|
|
164
|
+
operation?: KeyOperation,
|
|
165
|
+
): Promise<{ data: Record<string, string> } | { type: string; failure: string; retry: boolean; uncertain: boolean }> {
|
|
166
|
+
const headers = signV4({
|
|
167
|
+
method: 'POST',
|
|
168
|
+
url: this.#url,
|
|
169
|
+
headers: { 'content-type': 'application/x-amz-json-1.1', 'x-amz-target': `TrentService.${action}` },
|
|
170
|
+
body,
|
|
171
|
+
region: this.#region,
|
|
172
|
+
service: 'kms',
|
|
173
|
+
credentials,
|
|
174
|
+
date: new Date(),
|
|
175
|
+
});
|
|
176
|
+
let response: Response;
|
|
177
|
+
let text: string;
|
|
178
|
+
let sent = false;
|
|
179
|
+
try {
|
|
180
|
+
checkOperation(operation);
|
|
181
|
+
if (signal?.aborted) throw new KekCancelledError();
|
|
182
|
+
sent = true;
|
|
183
|
+
response = await this.#fetch(this.#url, {
|
|
184
|
+
method: 'POST', headers, body,
|
|
185
|
+
signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...(signal === undefined ? [] : [signal])]),
|
|
186
|
+
});
|
|
187
|
+
text = await response.text();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { type: 'network', failure: messageOf(error), retry: sent, uncertain: sent };
|
|
190
|
+
}
|
|
191
|
+
const parsed = parse(text);
|
|
192
|
+
if (response.ok) {
|
|
193
|
+
const complete =
|
|
194
|
+
parsed !== null && (action === 'Encrypt' ? typeof parsed.CiphertextBlob === 'string' : typeof parsed.Plaintext === 'string');
|
|
195
|
+
if (complete) return { data: parsed as Record<string, string> };
|
|
196
|
+
return { type: 'malformed', failure: `KMS answered ${response.status} without a result`, retry: true, uncertain: true };
|
|
197
|
+
}
|
|
198
|
+
// `__type` may carry a namespace (`com.amazonaws.kms#…`), the header a URL after `:`.
|
|
199
|
+
const raw = response.headers.get('x-amzn-errortype') ?? (typeof parsed?.__type === 'string' ? parsed.__type : '');
|
|
200
|
+
const type = raw.split(':')[0]!.split('#').pop() || `HTTP ${response.status}`;
|
|
201
|
+
const message = parsed?.message ?? parsed?.Message;
|
|
202
|
+
return {
|
|
203
|
+
type,
|
|
204
|
+
failure: `${type}${typeof message === 'string' ? `, ${message}` : ''}`,
|
|
205
|
+
uncertain: false,
|
|
206
|
+
retry: response.status >= 500 || response.status === 429 || type === 'ThrottlingException',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The secret's UUIDs, as KMS binds them to the ciphertext and CloudTrail
|
|
213
|
+
* shows them. Checked first, so a malformed one never reaches KMS.
|
|
214
|
+
*/
|
|
215
|
+
function encryptionContext(ctx: SecretContext): Record<string, string> {
|
|
216
|
+
checkContext(ctx);
|
|
217
|
+
return { 'coffre:project': ctx.projectId, 'coffre:environment': ctx.environmentId, 'coffre:secret': ctx.secretId };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bytes as base64. Through `Buffer.from`, a view of the same memory, because
|
|
222
|
+
* Workers' types declare their own `Buffer` and a bare one loses its
|
|
223
|
+
* `toString(encoding)` to them.
|
|
224
|
+
*/
|
|
225
|
+
function base64(bytes: Uint8Array): string {
|
|
226
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parse(text: string): Record<string, unknown> | null {
|
|
230
|
+
try {
|
|
231
|
+
const value = JSON.parse(text) as unknown;
|
|
232
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function messageOf(error: unknown): string {
|
|
239
|
+
return error instanceof Error ? error.message : String(error);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** At most `n` calls at once; the rest wait their turn. */
|
|
243
|
+
class Slots {
|
|
244
|
+
#free: number;
|
|
245
|
+
readonly #waiting: { start: () => void; cancel: () => void }[] = [];
|
|
246
|
+
|
|
247
|
+
constructor(n: number) {
|
|
248
|
+
this.#free = n;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
252
|
+
if (signal?.aborted) throw new KekCancelledError();
|
|
253
|
+
if (this.#free > 0) this.#free--;
|
|
254
|
+
else await new Promise<void>((resolve, reject) => {
|
|
255
|
+
const waiting = {
|
|
256
|
+
start: () => {
|
|
257
|
+
signal?.removeEventListener('abort', waiting.cancel);
|
|
258
|
+
resolve();
|
|
259
|
+
},
|
|
260
|
+
cancel: () => {
|
|
261
|
+
const index = this.#waiting.indexOf(waiting);
|
|
262
|
+
if (index >= 0) this.#waiting.splice(index, 1);
|
|
263
|
+
signal?.removeEventListener('abort', waiting.cancel);
|
|
264
|
+
reject(new KekCancelledError());
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
this.#waiting.push(waiting);
|
|
268
|
+
signal?.addEventListener('abort', waiting.cancel, { once: true });
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
if (signal?.aborted) throw new KekCancelledError();
|
|
272
|
+
return await work();
|
|
273
|
+
} finally {
|
|
274
|
+
const next = this.#waiting.shift();
|
|
275
|
+
if (next) next.start();
|
|
276
|
+
else this.#free++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { KekCancelledError, type KeyOperation } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export function checkOperation(operation?: KeyOperation, uncertain = false): void {
|
|
4
|
+
if (operation !== undefined && (operation.signal.aborted || Date.now() >= operation.deadline)) {
|
|
5
|
+
throw new KekCancelledError(uncertain);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const signals = new WeakMap<KeyOperation, AbortSignal>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Enforce the deadline even when the caller's signal has not fired yet: one
|
|
13
|
+
* signal for every call of an operation, so its deadline cancels them all at
|
|
14
|
+
* once. With a timer per call, one call's could fire first, free its slot,
|
|
15
|
+
* and start a key queued behind it whose own timer had not fired yet.
|
|
16
|
+
*/
|
|
17
|
+
export function operationSignal(operation?: KeyOperation): AbortSignal | undefined {
|
|
18
|
+
if (operation === undefined) return undefined;
|
|
19
|
+
checkOperation(operation);
|
|
20
|
+
let signal = signals.get(operation);
|
|
21
|
+
if (signal === undefined) {
|
|
22
|
+
signal = AbortSignal.any([operation.signal, AbortSignal.timeout(Math.max(0, operation.deadline - Date.now()))]);
|
|
23
|
+
signals.set(operation, signal);
|
|
24
|
+
}
|
|
25
|
+
return signal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Credentials can finish later, but must never send a request after cancellation. */
|
|
29
|
+
export function cancellable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
30
|
+
if (signal === undefined) return work;
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const abort = () => {
|
|
33
|
+
signal.removeEventListener('abort', abort);
|
|
34
|
+
reject(new KekCancelledError());
|
|
35
|
+
};
|
|
36
|
+
if (signal.aborted) abort();
|
|
37
|
+
else signal.addEventListener('abort', abort, { once: true });
|
|
38
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const abort = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
signal?.removeEventListener('abort', abort);
|
|
47
|
+
reject(new KekCancelledError());
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
signal?.removeEventListener('abort', abort);
|
|
51
|
+
resolve();
|
|
52
|
+
}, ms);
|
|
53
|
+
if (signal?.aborted) abort();
|
|
54
|
+
else signal?.addEventListener('abort', abort, { once: true });
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/kek/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { KekProvider, WrappedDek, KeyOperation } from './types.ts';
|
|
2
|
+
export { DEK_BYTES, KekUnavailableError, KekCancelledError, KekBadClaimError } from './types.ts';
|
|
3
|
+
export { LocalKekProvider, equalBytes } from './local.ts';
|
|
4
|
+
export { KekRegistry } from './registry.ts';
|
|
5
|
+
export { AwsKmsKekProvider, awsKms, type AwsKmsOptions } from './aws-kms.ts';
|
|
6
|
+
export type { AwsCredentials } from './sigv4.ts';
|
package/src/kek/local.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { SecretContext } from '../context.ts';
|
|
3
|
+
import { encodeAad } from '../context.ts';
|
|
4
|
+
import { checkOperation } from './cancellation.ts';
|
|
5
|
+
import { DEK_BYTES, KekBadClaimError, type KeyOperation, type KekProvider, type WrappedDek } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const IV_BYTES = 12;
|
|
8
|
+
const TAG_BYTES = 16;
|
|
9
|
+
const KEK_BYTES = 32;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A KEK held in local process memory, loaded from configuration.
|
|
13
|
+
*
|
|
14
|
+
* This is the local-development implementation of `KekProvider`. It is a
|
|
15
|
+
* different implementation of the same interface, not a branch that skips
|
|
16
|
+
* wrapping: the envelope format, the context binding and the failure modes are
|
|
17
|
+
* identical to what a remote KMS-backed provider will produce.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalKekProvider implements KekProvider {
|
|
20
|
+
readonly provider = 'local';
|
|
21
|
+
readonly keyId: string;
|
|
22
|
+
readonly keyVersion: string;
|
|
23
|
+
|
|
24
|
+
readonly #kek: Buffer;
|
|
25
|
+
|
|
26
|
+
constructor(kek: Buffer, keyId: string, keyVersion: string = '1') {
|
|
27
|
+
if (kek.length !== KEK_BYTES) {
|
|
28
|
+
throw new Error(`local KEK must be ${KEK_BYTES} bytes, got ${kek.length}`);
|
|
29
|
+
}
|
|
30
|
+
this.#kek = kek;
|
|
31
|
+
this.keyId = keyId;
|
|
32
|
+
this.keyVersion = keyVersion;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build a provider from a base64-encoded 32-byte key, e.g. from an env var. */
|
|
36
|
+
static fromBase64(encoded: string, keyId: string, keyVersion = '1'): LocalKekProvider {
|
|
37
|
+
return new LocalKekProvider(Buffer.from(encoded, 'base64'), keyId, keyVersion);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Generate a fresh random KEK. Used by tests and by the dev seed script. */
|
|
41
|
+
static generate(keyId: string, keyVersion = '1'): LocalKekProvider {
|
|
42
|
+
return new LocalKekProvider(randomBytes(KEK_BYTES), keyId, keyVersion);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* AAD for the wrap operation.
|
|
47
|
+
*
|
|
48
|
+
* Binds the wrapped DEK both to the secret it belongs to and to the KEK
|
|
49
|
+
* identity that produced it, so a wrapped DEK cannot be replayed against a
|
|
50
|
+
* row claiming a different KEK.
|
|
51
|
+
*/
|
|
52
|
+
#wrapAad(ctx: SecretContext): Buffer {
|
|
53
|
+
return Buffer.concat([
|
|
54
|
+
encodeAad(ctx),
|
|
55
|
+
Buffer.from(`|${this.provider}|${this.keyId}|${this.keyVersion}`, 'utf8'),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek> {
|
|
60
|
+
checkOperation(operation);
|
|
61
|
+
if (dek.length !== DEK_BYTES) {
|
|
62
|
+
throw new Error(`DEK must be ${DEK_BYTES} bytes, got ${dek.length}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const iv = randomBytes(IV_BYTES);
|
|
66
|
+
const cipher = createCipheriv('aes-256-gcm', this.#kek, iv);
|
|
67
|
+
cipher.setAAD(this.#wrapAad(ctx));
|
|
68
|
+
|
|
69
|
+
const body = Buffer.concat([cipher.update(dek), cipher.final()]);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
kekProvider: this.provider,
|
|
73
|
+
kekId: this.keyId,
|
|
74
|
+
kekVersion: this.keyVersion,
|
|
75
|
+
bytes: Buffer.concat([iv, cipher.getAuthTag(), body]),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer> {
|
|
80
|
+
checkOperation(operation);
|
|
81
|
+
if (wrapped.kekProvider !== this.provider || wrapped.kekId !== this.keyId) {
|
|
82
|
+
throw new KekBadClaimError(
|
|
83
|
+
`wrapped DEK is for ${wrapped.kekProvider}:${wrapped.kekId}, not ${this.provider}:${this.keyId}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (wrapped.bytes.length !== IV_BYTES + TAG_BYTES + DEK_BYTES) {
|
|
87
|
+
throw new KekBadClaimError('wrapped DEK has the wrong length');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const iv = wrapped.bytes.subarray(0, IV_BYTES);
|
|
91
|
+
const tag = wrapped.bytes.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
|
92
|
+
const body = wrapped.bytes.subarray(IV_BYTES + TAG_BYTES);
|
|
93
|
+
|
|
94
|
+
const decipher = createDecipheriv('aes-256-gcm', this.#kek, iv);
|
|
95
|
+
decipher.setAAD(this.#wrapAad(ctx));
|
|
96
|
+
decipher.setAuthTag(tag);
|
|
97
|
+
|
|
98
|
+
// GCM raises on a tag mismatch, which is what a relocated or tampered
|
|
99
|
+
// wrapped DEK produces. The error is deliberately not specific.
|
|
100
|
+
let dek: Buffer;
|
|
101
|
+
const partial = decipher.update(body);
|
|
102
|
+
try {
|
|
103
|
+
dek = Buffer.concat([partial, decipher.final()]);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new KekBadClaimError('wrapped DEK does not open under this context');
|
|
106
|
+
} finally {
|
|
107
|
+
partial.fill(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Defensive: a 32-byte plaintext is the only shape we ever wrap.
|
|
111
|
+
if (dek.length !== DEK_BYTES) {
|
|
112
|
+
dek.fill(0);
|
|
113
|
+
throw new KekBadClaimError('unwrapped DEK has the wrong length');
|
|
114
|
+
}
|
|
115
|
+
return dek;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Constant-time buffer comparison, for tests and callers that need it. */
|
|
120
|
+
export function equalBytes(a: Buffer, b: Buffer): boolean {
|
|
121
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
122
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SecretContext } from '../context.ts';
|
|
2
|
+
import type { KekProvider, WrappedDek, KeyOperation } from './types.ts';
|
|
3
|
+
|
|
4
|
+
function refOf(provider: string, keyId: string): string {
|
|
5
|
+
return `${provider}:${keyId}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A set of active KEKs, one of which is primary.
|
|
10
|
+
*
|
|
11
|
+
* New secret versions are always wrapped under the primary. Old versions keep
|
|
12
|
+
* unwrapping under whichever KEK produced them, looked up by the provider and
|
|
13
|
+
* key id stored on their own row. Key rotation and migrating from the local
|
|
14
|
+
* provider to a KMS-backed one are therefore the same operation: add a new KEK,
|
|
15
|
+
* make it primary, leave the old one readable until nothing references it.
|
|
16
|
+
*/
|
|
17
|
+
export class KekRegistry {
|
|
18
|
+
readonly #byRef = new Map<string, KekProvider>();
|
|
19
|
+
readonly #primary: KekProvider;
|
|
20
|
+
|
|
21
|
+
constructor(primary: KekProvider, additional: readonly KekProvider[] = []) {
|
|
22
|
+
this.#primary = primary;
|
|
23
|
+
for (const provider of [primary, ...additional]) {
|
|
24
|
+
const ref = refOf(provider.provider, provider.keyId);
|
|
25
|
+
if (this.#byRef.has(ref)) {
|
|
26
|
+
throw new Error(`duplicate KEK in registry: ${ref}`);
|
|
27
|
+
}
|
|
28
|
+
this.#byRef.set(ref, provider);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get primary(): KekProvider {
|
|
33
|
+
return this.#primary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every KEK, the primary first. */
|
|
37
|
+
get all(): KekProvider[] {
|
|
38
|
+
return [...this.#byRef.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The KEK a wrapped key names, or undefined when none is configured. */
|
|
42
|
+
providerOf(wrapped: Pick<WrappedDek, 'kekProvider' | 'kekId'>): KekProvider | undefined {
|
|
43
|
+
return this.#byRef.get(refOf(wrapped.kekProvider, wrapped.kekId));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Wrap under the primary KEK. Used for every new secret version. */
|
|
47
|
+
wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek> {
|
|
48
|
+
return this.#primary.wrap(dek, ctx, operation);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Unwrap under whichever KEK the row says produced it. */
|
|
52
|
+
unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer> {
|
|
53
|
+
const ref = refOf(wrapped.kekProvider, wrapped.kekId);
|
|
54
|
+
const provider = this.#byRef.get(ref);
|
|
55
|
+
if (!provider) {
|
|
56
|
+
// Operationally this means a KEK was removed from configuration while
|
|
57
|
+
// rows still reference it. Fail loudly rather than returning a decrypt
|
|
58
|
+
// error that looks like corruption.
|
|
59
|
+
throw new Error(`no KEK configured for ${ref}; cannot unwrap`);
|
|
60
|
+
}
|
|
61
|
+
return provider.unwrap(wrapped, ctx, operation);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/kek/sigv4.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** An AWS access key, with `sessionToken` when it is temporary (a role's). */
|
|
4
|
+
export type AwsCredentials = { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
|
|
5
|
+
|
|
6
|
+
export type SigningInput = {
|
|
7
|
+
method: string;
|
|
8
|
+
url: URL;
|
|
9
|
+
/** The headers to send, but for `host`, `x-amz-date` and the session token, which signing adds. */
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
body: string;
|
|
12
|
+
region: string;
|
|
13
|
+
service: string;
|
|
14
|
+
credentials: AwsCredentials;
|
|
15
|
+
date: Date;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every header a request signed with AWS Signature Version 4 sends: those
|
|
20
|
+
* given, `host`, `x-amz-date`, the session token if any, and
|
|
21
|
+
* `authorization`, which signs all of them and the body. The path is used as
|
|
22
|
+
* it is, which is right for the only one KMS has, `/`.
|
|
23
|
+
*
|
|
24
|
+
* https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
|
|
25
|
+
*/
|
|
26
|
+
export function signV4(input: SigningInput): Record<string, string> {
|
|
27
|
+
const stamp = input.date.toISOString().replace(/[-:]|\.\d{3}/g, ''); // 20260930T120000Z
|
|
28
|
+
const day = stamp.slice(0, 8);
|
|
29
|
+
const headers: Record<string, string> = { host: input.url.host, 'x-amz-date': stamp };
|
|
30
|
+
for (const [name, value] of Object.entries(input.headers)) headers[name.toLowerCase()] = value;
|
|
31
|
+
if (input.credentials.sessionToken) headers['x-amz-security-token'] = input.credentials.sessionToken;
|
|
32
|
+
|
|
33
|
+
const names = Object.keys(headers).sort();
|
|
34
|
+
const signed = names.join(';');
|
|
35
|
+
const request = [
|
|
36
|
+
input.method,
|
|
37
|
+
input.url.pathname,
|
|
38
|
+
canonicalQuery(input.url.searchParams),
|
|
39
|
+
...names.map((name) => `${name}:${headers[name]!.trim().replace(/\s+/g, ' ')}`),
|
|
40
|
+
'',
|
|
41
|
+
signed,
|
|
42
|
+
sha256(input.body),
|
|
43
|
+
].join('\n');
|
|
44
|
+
const scope = `${day}/${input.region}/${input.service}/aws4_request`;
|
|
45
|
+
const toSign = ['AWS4-HMAC-SHA256', stamp, scope, sha256(request)].join('\n');
|
|
46
|
+
|
|
47
|
+
let key = hmac(`AWS4${input.credentials.secretAccessKey}`, day);
|
|
48
|
+
for (const part of [input.region, input.service, 'aws4_request']) key = hmac(key, part);
|
|
49
|
+
const signature = createHmac('sha256', key).update(toSign, 'utf8').digest('hex');
|
|
50
|
+
return {
|
|
51
|
+
...headers,
|
|
52
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${signature}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function canonicalQuery(params: URLSearchParams): string {
|
|
57
|
+
return [...params]
|
|
58
|
+
.map(([name, value]) => [encode(name), encode(value)])
|
|
59
|
+
.sort(([a, x], [b, y]) => (a! < b! ? -1 : a! > b! ? 1 : x! < y! ? -1 : x! > y! ? 1 : 0))
|
|
60
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
61
|
+
.join('&');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** RFC 3986: everything but letters, digits and `-._~` percent-encoded. */
|
|
65
|
+
function encode(text: string): string {
|
|
66
|
+
return encodeURIComponent(text).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sha256(text: string): string {
|
|
70
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hmac(key: string | Uint8Array, text: string) {
|
|
74
|
+
return createHmac('sha256', key).update(text, 'utf8').digest();
|
|
75
|
+
}
|