@tokenoftrust/storefront-runner 4.11.0 → 4.11.1

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 (27) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
  2. package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
  3. package/apps/storefront/src/lib/customers/contactVault/blindIndex.ts +28 -0
  4. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +21 -7
  5. package/apps/storefront/src/lib/customers/contactVault/dataKeys.ts +256 -0
  6. package/apps/storefront/src/lib/customers/contactVault/fieldEnvelope.ts +88 -0
  7. package/apps/storefront/src/lib/customers/contactVault/isolateMetrics.ts +37 -0
  8. package/apps/storefront/src/lib/customers/contactVault/kms.ts +292 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +270 -12
  10. package/apps/storefront/src/lib/customers/contactVault/retentionPolicy.ts +290 -0
  11. package/apps/storefront/src/lib/customers/contactVault/zip3.ts +24 -0
  12. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +458 -118
  13. package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +33 -3
  14. package/apps/storefront/src/lib/orders/admin/orderActions.ts +180 -32
  15. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
  16. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
  17. package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
  18. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
  19. package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
  20. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
  21. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
  22. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
  23. package/apps/storefront/src/pages/api/contact.ts +7 -9
  24. package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
  25. package/package.json +1 -1
  26. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
  27. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Per-tenant data keys: one active key per (tenant, stage, purpose), generated by KMS
3
+ * GenerateDataKey and stored only as its wrapped CiphertextBlob in `contact_vault_data_keys`.
4
+ * Unwrapped keys are held as non-extractable CryptoKeys in a module-scope LRU (512 entries) keyed by
5
+ * tenant, stage, purpose, kid and the SHA-256 of the wrapped bytes; the plaintext buffer is zeroed
6
+ * right after import. Every lookup reads the key row from D1 first, so a deleted or replaced
7
+ * wrapped key stops opening even in a warm isolate.
8
+ *
9
+ * Seal protocol. A new key is never persisted on its own. When {@link activeKey} finds no active
10
+ * row it returns the new key with `pending` statements, and the caller's sealing batch is:
11
+ * 1. `...pending` — INSERT the key row WHERE NOT EXISTS an active key for that tenant/stage/purpose;
12
+ * 2. the record insert, which itself requires EXISTS the key row with this kid;
13
+ * 3. the caller's other writes;
14
+ * 4. {@link cleanupStatement} — DELETE this kid WHERE NOT EXISTS a row referencing it.
15
+ * A refused or losing batch therefore commits no key row; the loser re-reads the winner's key and
16
+ * re-seals. Rotation is the only other writer of key rows.
17
+ */
18
+
19
+ /** One statement of a D1 batch. */
20
+ export interface QueryStatement {
21
+ sql: string;
22
+ params?: readonly unknown[];
23
+ }
24
+
25
+ /** The SQL surface data keys need; D1's `fromD1` and the sqlite test harness both satisfy it. */
26
+ export interface Queryable {
27
+ all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
28
+ first<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T | null>;
29
+ batch?(statements: readonly QueryStatement[]): Promise<void>;
30
+ }
31
+ import { noteKmsCall } from "./isolateMetrics.js";
32
+ import {
33
+ sha256Hex,
34
+ VaultKeyUnavailableError,
35
+ type DataKeyPurpose,
36
+ type EncryptionContext,
37
+ type KmsClient,
38
+ } from "./kms.js";
39
+
40
+ export interface ActiveDataKey {
41
+ kid: string;
42
+ key: CryptoKey;
43
+ /** Statements the caller puts FIRST in its sealing batch; empty when the key row already exists. */
44
+ pending: QueryStatement[];
45
+ }
46
+
47
+ const CACHE_LIMIT = 512;
48
+ const DATA_KEY_BYTES = 32;
49
+ const PURPOSES: readonly DataKeyPurpose[] = ["order-contact", "contact-message", "blind-index"];
50
+ const cache = new Map<string, CryptoKey>();
51
+ const inflight = new Map<string, Promise<CryptoKey>>();
52
+ const counts = { decrypt: 0, generate: 0 };
53
+
54
+ /** KMS calls made by this isolate, for measurement. Carries no tenant data. */
55
+ export function kmsCallCounts(): { decrypt: number; generate: number } {
56
+ return { ...counts };
57
+ }
58
+
59
+ /** Drop every cached key (isolate recycle, tests). */
60
+ export function clearDataKeyCache(): void {
61
+ cache.clear();
62
+ inflight.clear();
63
+ }
64
+
65
+ function remember(cacheKey: string, key: CryptoKey): void {
66
+ cache.delete(cacheKey);
67
+ cache.set(cacheKey, key);
68
+ while (cache.size > CACHE_LIMIT) {
69
+ const oldest = cache.keys().next().value;
70
+ if (oldest === undefined) break;
71
+ cache.delete(oldest);
72
+ }
73
+ }
74
+
75
+ async function cacheKeyFor(ctx: EncryptionContext, kid: string, wrapped: string): Promise<string> {
76
+ return `${ctx.tenantId}|${ctx.stage}|${ctx.purpose}|${kid}|${await sha256Hex(wrapped)}`;
77
+ }
78
+
79
+ /** Import 32 key bytes as a non-extractable key for the purpose, zeroing the bytes in every case. */
80
+ async function importDataKey(purpose: DataKeyPurpose, plaintext: Uint8Array): Promise<CryptoKey> {
81
+ try {
82
+ if (plaintext.length !== DATA_KEY_BYTES) throw new VaultKeyUnavailableError();
83
+ return purpose === "blind-index"
84
+ ? await crypto.subtle.importKey("raw", plaintext as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"])
85
+ : await crypto.subtle.importKey("raw", plaintext as BufferSource, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
86
+ } finally {
87
+ plaintext.fill(0);
88
+ }
89
+ }
90
+
91
+ function newKid(): string {
92
+ return [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
93
+ }
94
+
95
+ async function unwrap(kms: KmsClient, ctx: EncryptionContext, kid: string, wrapped: string, keyArn: string): Promise<CryptoKey> {
96
+ const cacheKey = await cacheKeyFor(ctx, kid, wrapped);
97
+ const hit = cache.get(cacheKey);
98
+ if (hit) {
99
+ remember(cacheKey, hit);
100
+ return hit;
101
+ }
102
+ const pendingUnwrap = inflight.get(cacheKey);
103
+ if (pendingUnwrap) return pendingUnwrap;
104
+ const work = (async () => {
105
+ counts.decrypt++;
106
+ noteKmsCall("decrypt");
107
+ const key = await importDataKey(ctx.purpose, await kms.decrypt(wrapped, ctx, keyArn));
108
+ remember(cacheKey, key);
109
+ return key;
110
+ })();
111
+ inflight.set(cacheKey, work);
112
+ try {
113
+ return await work;
114
+ } finally {
115
+ inflight.delete(cacheKey);
116
+ }
117
+ }
118
+
119
+ interface KeyRow {
120
+ kid: string;
121
+ wrapped_key: string;
122
+ kms_key_arn: string;
123
+ }
124
+
125
+ function insertKeyStatement(ctx: EncryptionContext, kid: string, wrapped: string, keyArn: string, now: Date): QueryStatement {
126
+ return {
127
+ sql: `INSERT INTO contact_vault_data_keys (tenant_id, stage, purpose, kid, wrapped_key, kms_key_arn, created_at, retired_at)
128
+ SELECT ?, ?, ?, ?, ?, ?, ?, NULL
129
+ WHERE NOT EXISTS (
130
+ SELECT 1 FROM contact_vault_data_keys
131
+ WHERE tenant_id = ? AND stage = ? AND purpose = ? AND retired_at IS NULL
132
+ )`,
133
+ params: [ctx.tenantId, ctx.stage, ctx.purpose, kid, wrapped, keyArn, now.toISOString(), ctx.tenantId, ctx.stage, ctx.purpose],
134
+ };
135
+ }
136
+
137
+ /** Generate a key, cache its CryptoKey and return the row-insert statement for it. */
138
+ async function generate(kms: KmsClient, ctx: EncryptionContext, now: Date): Promise<ActiveDataKey> {
139
+ counts.generate++;
140
+ noteKmsCall("generate");
141
+ const generated = await kms.generateDataKey(ctx);
142
+ const kid = newKid();
143
+ const key = await importDataKey(ctx.purpose, generated.plaintext);
144
+ remember(await cacheKeyFor(ctx, kid, generated.ciphertextBlob), key);
145
+ return { kid, key, pending: [insertKeyStatement(ctx, kid, generated.ciphertextBlob, generated.keyArn, now)] };
146
+ }
147
+
148
+ /**
149
+ * The active key for sealing. When none exists a new one is generated and returned with `pending`;
150
+ * see the seal protocol above.
151
+ */
152
+ export async function activeKey(db: Queryable, kms: KmsClient, ctx: EncryptionContext, now: Date): Promise<ActiveDataKey> {
153
+ const row = await db.first<KeyRow>(
154
+ `SELECT kid, wrapped_key, kms_key_arn FROM contact_vault_data_keys
155
+ WHERE tenant_id = ? AND stage = ? AND purpose = ? AND retired_at IS NULL`,
156
+ ctx.tenantId, ctx.stage, ctx.purpose,
157
+ );
158
+ if (row) return { kid: row.kid, key: await unwrap(kms, ctx, row.kid, row.wrapped_key, row.kms_key_arn), pending: [] };
159
+ return generate(kms, ctx, now);
160
+ }
161
+
162
+ /**
163
+ * The final statement of a sealing batch that carried `pending`: removes this kid when no row
164
+ * references it, so a batch whose record insert did nothing commits no key. `referencedSql` is a
165
+ * SELECT over the caller's records naming this kid (with `referencedParams`). Only for a kid that
166
+ * {@link activeKey} returned with non-empty `pending`.
167
+ */
168
+ export function cleanupStatement(
169
+ ctx: EncryptionContext,
170
+ kid: string,
171
+ referencedSql: string,
172
+ referencedParams: readonly unknown[] = [],
173
+ ): QueryStatement {
174
+ return {
175
+ sql: `DELETE FROM contact_vault_data_keys
176
+ WHERE tenant_id = ? AND stage = ? AND purpose = ? AND kid = ? AND NOT EXISTS (${referencedSql})`,
177
+ params: [ctx.tenantId, ctx.stage, ctx.purpose, kid, ...referencedParams],
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The key for opening a record sealed under `kid`, active or retired. Reads the row on every call;
183
+ * null when it is absent, so a deleted wrapped key stops opening immediately.
184
+ */
185
+ export async function keyForKid(db: Queryable, kms: KmsClient, ctx: EncryptionContext, kid: string): Promise<CryptoKey | null> {
186
+ const row = await db.first<KeyRow>(
187
+ `SELECT kid, wrapped_key, kms_key_arn FROM contact_vault_data_keys
188
+ WHERE tenant_id = ? AND stage = ? AND purpose = ? AND kid = ?`,
189
+ ctx.tenantId, ctx.stage, ctx.purpose, kid,
190
+ );
191
+ if (!row) return null;
192
+ return unwrap(kms, ctx, row.kid, row.wrapped_key, row.kms_key_arn);
193
+ }
194
+
195
+ function requireBatch(db: Queryable): (statements: readonly QueryStatement[]) => Promise<void> {
196
+ if (!db.batch) throw new Error("D1 batch is unavailable");
197
+ return db.batch.bind(db);
198
+ }
199
+
200
+ /**
201
+ * Retire each active key created at or before `now - maxAgeDays` and install a fresh active key in
202
+ * the same batch (up to `limit` keys per run). The retired kid stays until no row references it.
203
+ */
204
+ export async function rotateDueKeys(
205
+ db: Queryable,
206
+ kms: KmsClient,
207
+ now: Date,
208
+ maxAgeDays = 365,
209
+ limit = 50,
210
+ ): Promise<{ rotated: number }> {
211
+ const batch = requireBatch(db);
212
+ const cutoff = new Date(now.getTime() - maxAgeDays * 86_400_000).toISOString();
213
+ const due = await db.all<{ tenant_id: string; stage: EncryptionContext["stage"]; purpose: DataKeyPurpose; kid: string }>(
214
+ `SELECT tenant_id, stage, purpose, kid FROM contact_vault_data_keys
215
+ WHERE retired_at IS NULL AND created_at <= ?
216
+ ORDER BY created_at, tenant_id, stage, purpose
217
+ LIMIT ?`,
218
+ cutoff, limit,
219
+ );
220
+ let rotated = 0;
221
+ for (const row of due) {
222
+ const ctx: EncryptionContext = { tenantId: row.tenant_id, stage: row.stage, purpose: row.purpose };
223
+ const fresh = await generate(kms, ctx, now);
224
+ // Retire first: the partial unique index admits one active key, and the insert only lands when
225
+ // this batch's retire left none (a concurrent rotation that already ran makes both no-ops).
226
+ await batch([
227
+ {
228
+ sql: `UPDATE contact_vault_data_keys SET retired_at = ?
229
+ WHERE tenant_id = ? AND stage = ? AND purpose = ? AND kid = ? AND retired_at IS NULL`,
230
+ params: [now.toISOString(), ctx.tenantId, ctx.stage, ctx.purpose, row.kid],
231
+ },
232
+ ...fresh.pending,
233
+ ]);
234
+ rotated++;
235
+ }
236
+ return { rotated };
237
+ }
238
+
239
+ /**
240
+ * Delete retired keys no record references. `referencedByPurpose[purpose]` is a correlated SELECT
241
+ * that refers to the key row as `contact_vault_data_keys` (its tenant_id, stage and kid columns),
242
+ * e.g. `SELECT 1 FROM order_contact_vault v WHERE v.tenant_id = contact_vault_data_keys.tenant_id
243
+ * AND v.stage = contact_vault_data_keys.stage AND v.order_kid = contact_vault_data_keys.kid`.
244
+ * Active keys are never deleted here.
245
+ */
246
+ export async function deleteUnreferencedRetiredKeys(
247
+ db: Queryable,
248
+ referencedByPurpose: Readonly<Record<DataKeyPurpose, string>>,
249
+ ): Promise<void> {
250
+ const batch = requireBatch(db);
251
+ await batch(PURPOSES.map((purpose) => ({
252
+ sql: `DELETE FROM contact_vault_data_keys
253
+ WHERE purpose = ? AND retired_at IS NOT NULL AND NOT EXISTS (${referencedByPurpose[purpose]})`,
254
+ params: [purpose],
255
+ })));
256
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Field-level envelope: each personal field is its own AES-256-GCM ciphertext under the tenant's
3
+ * data key, stored as base64(nonce(12) || ciphertext). The AAD binds purpose, field, tenant, stage,
4
+ * record id, key id and the group's immutable ceiling (`notAfter`), so a ciphertext moved to another
5
+ * field, row, tenant or stage fails its tag and reads as absent.
6
+ *
7
+ * Errors carry fixed codes only — never a key, nonce, ciphertext or plaintext value.
8
+ */
9
+ import { base64ToBytes, bytesToBase64 } from "./kms.js";
10
+
11
+ export type FieldPurpose = "order-contact" | "contact-message";
12
+
13
+ export interface FieldBinding {
14
+ purpose: FieldPurpose;
15
+ field: string;
16
+ tenantId: string;
17
+ stage: string;
18
+ recordId: string;
19
+ kid: string;
20
+ notAfter: string;
21
+ }
22
+
23
+ const NONCE_BYTES = 12;
24
+ const TAG_BYTES = 16;
25
+ const BINDING_KEYS = ["purpose", "field", "tenantId", "stage", "recordId", "kid", "notAfter"] as const;
26
+ const encoder = new TextEncoder();
27
+ const decoder = new TextDecoder("utf-8", { fatal: true });
28
+
29
+ /**
30
+ * Canonical AAD bytes: `{v:2, purpose, field, tenantId, stage, recordId, kid, notAfter}` in exactly
31
+ * that order. Every binding value must be a non-empty string, so no member can silently drop out.
32
+ */
33
+ export function fieldAad(binding: FieldBinding): Uint8Array {
34
+ for (const name of BINDING_KEYS) {
35
+ const value: unknown = binding[name];
36
+ if (typeof value !== "string" || value === "") throw new TypeError("vault-binding-invalid");
37
+ }
38
+ return encoder.encode(JSON.stringify({
39
+ v: 2,
40
+ purpose: binding.purpose,
41
+ field: binding.field,
42
+ tenantId: binding.tenantId,
43
+ stage: binding.stage,
44
+ recordId: binding.recordId,
45
+ kid: binding.kid,
46
+ notAfter: binding.notAfter,
47
+ }));
48
+ }
49
+
50
+ /** Seal one field value under an AES-GCM data key. */
51
+ export async function sealField(key: CryptoKey, binding: FieldBinding, value: string): Promise<string> {
52
+ const aad = fieldAad(binding);
53
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
54
+ const sealed = new Uint8Array(await crypto.subtle.encrypt(
55
+ { name: "AES-GCM", iv: nonce, additionalData: aad as BufferSource },
56
+ key,
57
+ encoder.encode(value) as BufferSource,
58
+ ));
59
+ const out = new Uint8Array(NONCE_BYTES + sealed.length);
60
+ out.set(nonce, 0);
61
+ out.set(sealed, NONCE_BYTES);
62
+ return bytesToBase64(out);
63
+ }
64
+
65
+ /** Open one sealed field. Null when it is malformed or does not authenticate under this binding. */
66
+ export async function openField(key: CryptoKey, binding: FieldBinding, sealed: string): Promise<string | null> {
67
+ const aad = fieldAad(binding);
68
+ const bytes = typeof sealed === "string" ? base64ToBytes(sealed) : null;
69
+ if (!bytes || bytes.length < NONCE_BYTES + TAG_BYTES) return null;
70
+ let plain: ArrayBuffer;
71
+ try {
72
+ plain = await crypto.subtle.decrypt(
73
+ { name: "AES-GCM", iv: bytes.subarray(0, NONCE_BYTES) as BufferSource, additionalData: aad as BufferSource },
74
+ key,
75
+ bytes.subarray(NONCE_BYTES) as BufferSource,
76
+ );
77
+ } catch (error) {
78
+ // AES-GCM reports a failed tag (wrong binding, key or bytes) as OperationError; anything else is a fault.
79
+ if (error instanceof DOMException && error.name === "OperationError") return null;
80
+ throw error;
81
+ }
82
+ try {
83
+ return decoder.decode(plain);
84
+ } catch (error) {
85
+ if (error instanceof TypeError) return null;
86
+ throw error;
87
+ }
88
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Isolate-level measurement of the vault's KMS cost: one log line when an isolate serves its first
3
+ * request, and one per KMS call, each tagged with a random per-isolate id. KMS calls per isolate
4
+ * start = distinct ids on kms lines / distinct ids on start lines. Lines carry no tenant, record,
5
+ * key or request data. The id is drawn lazily because Workers refuse random values at global scope.
6
+ */
7
+ export const ISOLATE_START_EVENT = "isolate-start";
8
+ export const KMS_CALL_EVENT = "contact-vault-kms";
9
+
10
+ let isolateId: string | null = null;
11
+ let started = false;
12
+ let kmsCalls = 0;
13
+
14
+ function id(): string {
15
+ isolateId ??= [...crypto.getRandomValues(new Uint8Array(6))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
16
+ return isolateId;
17
+ }
18
+
19
+ /** Log the isolate's first request once. */
20
+ export function noteIsolateStart(log: (line: string) => void = (line) => console.log(line)): void {
21
+ if (started) return;
22
+ started = true;
23
+ log(JSON.stringify({ event: ISOLATE_START_EVENT, isolate: id() }));
24
+ }
25
+
26
+ /** Log one KMS call with this isolate's running count. */
27
+ export function noteKmsCall(op: "decrypt" | "generate", log: (line: string) => void = (line) => console.log(line)): void {
28
+ kmsCalls++;
29
+ log(JSON.stringify({ event: KMS_CALL_EVENT, isolate: id(), op, isolateCalls: kmsCalls }));
30
+ }
31
+
32
+ /** Reset for tests. */
33
+ export function resetIsolateMetrics(): void {
34
+ isolateId = null;
35
+ started = false;
36
+ kmsCalls = 0;
37
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * AWS KMS client for the contact vault: GenerateDataKey and Decrypt over fetch, signed with SigV4
3
+ * through Web Crypto (no SDK). Every call carries the EncryptionContext {tenantId, stage, purpose}
4
+ * the environment key's IAM policy requires, and Decrypt pins KeyId to the ARN recorded with the
5
+ * wrapped key.
6
+ *
7
+ * The credential is the `CONTACT_VAULT_KMS` Worker secret: JSON
8
+ * `{ accessKeyId, secretAccessKey, sessionToken?, region, keyArn }`. Any non-2xx answer, timeout,
9
+ * network error or malformed response throws {@link VaultKeyUnavailableError}. Errors carry the
10
+ * fixed code only — never a credential, key byte, blob or response body.
11
+ */
12
+
13
+ export type DataKeyPurpose = "order-contact" | "contact-message" | "blind-index";
14
+ export type VaultStage = "preview" | "publication";
15
+
16
+ export interface EncryptionContext {
17
+ tenantId: string;
18
+ stage: VaultStage;
19
+ purpose: DataKeyPurpose;
20
+ }
21
+
22
+ export interface KmsCredential {
23
+ accessKeyId: string;
24
+ secretAccessKey: string;
25
+ sessionToken?: string;
26
+ region: string;
27
+ keyArn: string;
28
+ }
29
+
30
+ export interface GeneratedDataKey {
31
+ /** 32 plaintext key bytes. The caller imports and then zeroes them. */
32
+ plaintext: Uint8Array;
33
+ /** Base64 CiphertextBlob: the only form that is stored. */
34
+ ciphertextBlob: string;
35
+ keyArn: string;
36
+ }
37
+
38
+ export interface KmsClient {
39
+ generateDataKey(ctx: EncryptionContext): Promise<GeneratedDataKey>;
40
+ decrypt(ciphertextBlob: string, ctx: EncryptionContext, keyArn: string): Promise<Uint8Array>;
41
+ }
42
+
43
+ export class VaultKeyUnavailableError extends Error {
44
+ readonly code = "vault-key-unavailable";
45
+ constructor() {
46
+ super("vault-key-unavailable");
47
+ this.name = "VaultKeyUnavailableError";
48
+ }
49
+ }
50
+
51
+ const PURPOSES: ReadonlySet<string> = new Set(["order-contact", "contact-message", "blind-index"]);
52
+ const STAGES: ReadonlySet<string> = new Set(["preview", "publication"]);
53
+ const CREDENTIAL_KEYS: ReadonlySet<string> = new Set(["accessKeyId", "secretAccessKey", "sessionToken", "region", "keyArn"]);
54
+ const ACCESS_KEY_ID = /^[A-Z0-9]{16,128}$/;
55
+ const SECRET = /^[\x21-\x7e]{16,256}$/;
56
+ const SESSION_TOKEN = /^[\x21-\x7e]{16,4096}$/;
57
+ const REGION = /^[a-z]{2}(-[a-z]+)+-\d{1,2}$/;
58
+ const KEY_ARN = /^arn:aws(-[a-z]+)*:kms:([a-z]{2}(-[a-z]+)+-\d{1,2}):\d{12}:key\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|mrk-[0-9a-f]{32})$/;
59
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
60
+ const DATA_KEY_BYTES = 32;
61
+ const encoder = new TextEncoder();
62
+
63
+ export function base64ToBytes(value: string): Uint8Array | null {
64
+ if (value.length % 4 !== 0 || !BASE64.test(value)) return null;
65
+ let binary: string;
66
+ try {
67
+ binary = atob(value);
68
+ } catch (error) {
69
+ if (error instanceof DOMException && error.name === "InvalidCharacterError") return null;
70
+ throw error;
71
+ }
72
+ const out = new Uint8Array(binary.length);
73
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
74
+ return out;
75
+ }
76
+
77
+ export function bytesToBase64(bytes: Uint8Array): string {
78
+ let binary = "";
79
+ for (const byte of bytes) binary += String.fromCharCode(byte);
80
+ return btoa(binary);
81
+ }
82
+
83
+ function hex(bytes: ArrayBuffer | Uint8Array): string {
84
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
85
+ }
86
+
87
+ /** Lowercase hex SHA-256 of a UTF-8 string. */
88
+ export async function sha256Hex(value: string): Promise<string> {
89
+ return hex(await crypto.subtle.digest("SHA-256", encoder.encode(value) as BufferSource));
90
+ }
91
+
92
+ async function hmac(key: Uint8Array, data: string): Promise<Uint8Array> {
93
+ const imported = await crypto.subtle.importKey("raw", key as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
94
+ return new Uint8Array(await crypto.subtle.sign("HMAC", imported, encoder.encode(data) as BufferSource));
95
+ }
96
+
97
+ /** The KMS key's region, read from its ARN, or null when the ARN is not a KMS key ARN. */
98
+ function keyArnRegion(arn: string): string | null {
99
+ const match = KEY_ARN.exec(arn);
100
+ return match ? match[2] ?? null : null;
101
+ }
102
+
103
+ /** Parse the credential secret, or null when it is absent or malformed in any way. */
104
+ export function parseKmsCredential(raw: string | undefined | null): KmsCredential | null {
105
+ if (typeof raw !== "string" || raw.trim() === "") return null;
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ } catch (error) {
110
+ if (error instanceof SyntaxError) return null;
111
+ throw error;
112
+ }
113
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
114
+ const record = parsed as Record<string, unknown>;
115
+ if (Object.keys(record).some((key) => !CREDENTIAL_KEYS.has(key))) return null;
116
+ const { accessKeyId, secretAccessKey, sessionToken, region, keyArn } = record;
117
+ if (typeof accessKeyId !== "string" || !ACCESS_KEY_ID.test(accessKeyId)) return null;
118
+ if (typeof secretAccessKey !== "string" || !SECRET.test(secretAccessKey)) return null;
119
+ if (sessionToken !== undefined && (typeof sessionToken !== "string" || !SESSION_TOKEN.test(sessionToken))) return null;
120
+ if (typeof region !== "string" || !REGION.test(region)) return null;
121
+ if (typeof keyArn !== "string" || keyArnRegion(keyArn) !== region) return null;
122
+ return {
123
+ accessKeyId,
124
+ secretAccessKey,
125
+ ...(sessionToken === undefined ? {} : { sessionToken }),
126
+ region,
127
+ keyArn,
128
+ };
129
+ }
130
+
131
+ export interface SignatureV4Input {
132
+ method: string;
133
+ /** Absolute URL. Its path must already be in canonical form (KMS always uses "/"). */
134
+ url: string;
135
+ /** Headers to sign; `host` is derived from the URL. Names are matched case-insensitively. */
136
+ headers: Readonly<Record<string, string>>;
137
+ body: string;
138
+ region: string;
139
+ service: string;
140
+ accessKeyId: string;
141
+ secretAccessKey: string;
142
+ sessionToken?: string;
143
+ /** ISO basic UTC timestamp, `YYYYMMDDTHHMMSSZ`. */
144
+ amzDate: string;
145
+ }
146
+
147
+ export interface SignatureV4Result {
148
+ /** Every signed header (lowercase names), `host` included, plus `authorization`. */
149
+ headers: Record<string, string>;
150
+ canonicalRequest: string;
151
+ stringToSign: string;
152
+ }
153
+
154
+ function rfc3986(value: string): string {
155
+ return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
156
+ }
157
+
158
+ /** Pure SigV4 signer (AWS Signature Version 4, header form). */
159
+ export async function signRequestV4(input: SignatureV4Input): Promise<SignatureV4Result> {
160
+ const url = new URL(input.url);
161
+ const date = input.amzDate.slice(0, 8);
162
+ const headers: Record<string, string> = {};
163
+ for (const [name, value] of Object.entries(input.headers)) headers[name.toLowerCase()] = value.trim().replace(/\s+/g, " ");
164
+ headers.host = url.host;
165
+ headers["x-amz-date"] = input.amzDate;
166
+ if (input.sessionToken !== undefined) headers["x-amz-security-token"] = input.sessionToken;
167
+ const names = Object.keys(headers).sort();
168
+ const signedHeaders = names.join(";");
169
+ const query = [...url.searchParams.entries()]
170
+ .map(([name, value]) => [rfc3986(name), rfc3986(value)] as const)
171
+ .sort(([a, av], [b, bv]) => (a < b ? -1 : a > b ? 1 : av < bv ? -1 : av > bv ? 1 : 0))
172
+ .map(([name, value]) => `${name}=${value}`)
173
+ .join("&");
174
+ const canonicalRequest = [
175
+ input.method.toUpperCase(),
176
+ url.pathname || "/",
177
+ query,
178
+ names.map((name) => `${name}:${headers[name]}\n`).join(""),
179
+ signedHeaders,
180
+ await sha256Hex(input.body),
181
+ ].join("\n");
182
+ const scope = `${date}/${input.region}/${input.service}/aws4_request`;
183
+ const stringToSign = ["AWS4-HMAC-SHA256", input.amzDate, scope, await sha256Hex(canonicalRequest)].join("\n");
184
+ const kDate = await hmac(encoder.encode(`AWS4${input.secretAccessKey}`), date);
185
+ const kRegion = await hmac(kDate, input.region);
186
+ const kService = await hmac(kRegion, input.service);
187
+ const kSigning = await hmac(kService, "aws4_request");
188
+ const signature = hex(await hmac(kSigning, stringToSign));
189
+ headers.authorization = `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
190
+ return { headers, canonicalRequest, stringToSign };
191
+ }
192
+
193
+ function amzDate(now: Date): string {
194
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
195
+ }
196
+
197
+ function validContext(ctx: EncryptionContext): boolean {
198
+ return typeof ctx.tenantId === "string" && ctx.tenantId !== ""
199
+ && STAGES.has(ctx.stage) && PURPOSES.has(ctx.purpose);
200
+ }
201
+
202
+ /** Decode a response's Plaintext as exactly one 32-byte data key, or throw. */
203
+ function dataKeyBytes(value: unknown): Uint8Array {
204
+ const bytes = typeof value === "string" ? base64ToBytes(value) : null;
205
+ if (!bytes || bytes.length !== DATA_KEY_BYTES) {
206
+ bytes?.fill(0);
207
+ throw new VaultKeyUnavailableError();
208
+ }
209
+ return bytes;
210
+ }
211
+
212
+ /**
213
+ * A KMS client bound to one credential. `now` is injectable so tests can pin the signing date.
214
+ */
215
+ export function createKmsClient(
216
+ cred: KmsCredential | null,
217
+ fetchImpl: typeof fetch = fetch,
218
+ timeoutMs = 3000,
219
+ now: () => Date = () => new Date(),
220
+ ): KmsClient {
221
+ async function call(target: "GenerateDataKey" | "Decrypt", payload: Record<string, unknown>): Promise<Record<string, unknown>> {
222
+ if (!cred) throw new VaultKeyUnavailableError();
223
+ const url = `https://kms.${cred.region}.amazonaws.com/`;
224
+ const body = JSON.stringify(payload);
225
+ const signed = await signRequestV4({
226
+ method: "POST",
227
+ url,
228
+ headers: { "content-type": "application/x-amz-json-1.1", "x-amz-target": `TrentService.${target}` },
229
+ body,
230
+ region: cred.region,
231
+ service: "kms",
232
+ accessKeyId: cred.accessKeyId,
233
+ secretAccessKey: cred.secretAccessKey,
234
+ ...(cred.sessionToken === undefined ? {} : { sessionToken: cred.sessionToken }),
235
+ amzDate: amzDate(now()),
236
+ });
237
+ // fetch derives Host from the URL; it was signed with that same value.
238
+ const sendHeaders = { ...signed.headers };
239
+ delete sendHeaders.host;
240
+ const controller = new AbortController();
241
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
242
+ try {
243
+ const response = await fetchImpl(url, { method: "POST", headers: sendHeaders, body, signal: controller.signal });
244
+ if (!response.ok) {
245
+ await response.body?.cancel();
246
+ throw new VaultKeyUnavailableError();
247
+ }
248
+ const parsed: unknown = JSON.parse(await response.text());
249
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new VaultKeyUnavailableError();
250
+ return parsed as Record<string, unknown>;
251
+ } catch {
252
+ // Network failure, abort, unreadable body or malformed JSON: one fixed code, no detail.
253
+ throw new VaultKeyUnavailableError();
254
+ } finally {
255
+ clearTimeout(timer);
256
+ }
257
+ }
258
+
259
+ return {
260
+ async generateDataKey(ctx) {
261
+ if (!cred || !validContext(ctx)) throw new VaultKeyUnavailableError();
262
+ const result = await call("GenerateDataKey", {
263
+ KeyId: cred.keyArn,
264
+ KeySpec: "AES_256",
265
+ EncryptionContext: { tenantId: ctx.tenantId, stage: ctx.stage, purpose: ctx.purpose },
266
+ });
267
+ const plaintext = dataKeyBytes(result.Plaintext);
268
+ const blob = result.CiphertextBlob;
269
+ if (typeof blob !== "string" || !base64ToBytes(blob) || result.KeyId !== cred.keyArn) {
270
+ plaintext.fill(0);
271
+ throw new VaultKeyUnavailableError();
272
+ }
273
+ return { plaintext, ciphertextBlob: blob, keyArn: cred.keyArn };
274
+ },
275
+ async decrypt(ciphertextBlob, ctx, keyArn) {
276
+ if (!cred || !validContext(ctx) || keyArnRegion(keyArn) !== cred.region ||!base64ToBytes(ciphertextBlob)) {
277
+ throw new VaultKeyUnavailableError();
278
+ }
279
+ const result = await call("Decrypt", {
280
+ CiphertextBlob: ciphertextBlob,
281
+ KeyId: keyArn,
282
+ EncryptionContext: { tenantId: ctx.tenantId, stage: ctx.stage, purpose: ctx.purpose },
283
+ });
284
+ const plaintext = dataKeyBytes(result.Plaintext);
285
+ if (result.KeyId !== keyArn) {
286
+ plaintext.fill(0);
287
+ throw new VaultKeyUnavailableError();
288
+ }
289
+ return plaintext;
290
+ },
291
+ };
292
+ }