@nestm/storage 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (92) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +200 -3
  3. package/dist/artifacts/artifact-storage.d.ts +161 -0
  4. package/dist/artifacts/artifact-storage.d.ts.map +1 -0
  5. package/dist/artifacts/artifact-storage.js +452 -0
  6. package/dist/artifacts/artifact-storage.js.map +1 -0
  7. package/dist/artifacts/crypto/codec.d.ts +44 -0
  8. package/dist/artifacts/crypto/codec.d.ts.map +1 -0
  9. package/dist/artifacts/crypto/codec.js +273 -0
  10. package/dist/artifacts/crypto/codec.js.map +1 -0
  11. package/dist/artifacts/crypto/context.d.ts +48 -0
  12. package/dist/artifacts/crypto/context.d.ts.map +1 -0
  13. package/dist/artifacts/crypto/context.js +37 -0
  14. package/dist/artifacts/crypto/context.js.map +1 -0
  15. package/dist/artifacts/crypto/dek-cache.d.ts +38 -0
  16. package/dist/artifacts/crypto/dek-cache.d.ts.map +1 -0
  17. package/dist/artifacts/crypto/dek-cache.js +137 -0
  18. package/dist/artifacts/crypto/dek-cache.js.map +1 -0
  19. package/dist/artifacts/crypto/index.d.ts +25 -0
  20. package/dist/artifacts/crypto/index.d.ts.map +1 -0
  21. package/dist/artifacts/crypto/index.js +29 -0
  22. package/dist/artifacts/crypto/index.js.map +1 -0
  23. package/dist/artifacts/crypto/key-provider.d.ts +44 -0
  24. package/dist/artifacts/crypto/key-provider.d.ts.map +1 -0
  25. package/dist/artifacts/crypto/key-provider.js +36 -0
  26. package/dist/artifacts/crypto/key-provider.js.map +1 -0
  27. package/dist/artifacts/crypto/kms-provider.d.ts +16 -0
  28. package/dist/artifacts/crypto/kms-provider.d.ts.map +1 -0
  29. package/dist/artifacts/crypto/kms-provider.js +90 -0
  30. package/dist/artifacts/crypto/kms-provider.js.map +1 -0
  31. package/dist/artifacts/crypto/local-provider.d.ts +17 -0
  32. package/dist/artifacts/crypto/local-provider.d.ts.map +1 -0
  33. package/dist/artifacts/crypto/local-provider.js +58 -0
  34. package/dist/artifacts/crypto/local-provider.js.map +1 -0
  35. package/dist/artifacts/env-config.d.ts +15 -0
  36. package/dist/artifacts/env-config.d.ts.map +1 -0
  37. package/dist/artifacts/env-config.js +95 -0
  38. package/dist/artifacts/env-config.js.map +1 -0
  39. package/dist/artifacts/index.d.ts +6 -0
  40. package/dist/artifacts/index.d.ts.map +1 -0
  41. package/dist/artifacts/index.js +6 -0
  42. package/dist/artifacts/index.js.map +1 -0
  43. package/dist/artifacts/nest/index.d.ts +29 -0
  44. package/dist/artifacts/nest/index.d.ts.map +1 -0
  45. package/dist/artifacts/nest/index.js +129 -0
  46. package/dist/artifacts/nest/index.js.map +1 -0
  47. package/dist/artifacts/object-store.d.ts +27 -0
  48. package/dist/artifacts/object-store.d.ts.map +1 -0
  49. package/dist/artifacts/object-store.js +198 -0
  50. package/dist/artifacts/object-store.js.map +1 -0
  51. package/dist/artifacts/storage-driver.d.ts +48 -0
  52. package/dist/artifacts/storage-driver.d.ts.map +1 -0
  53. package/dist/artifacts/storage-driver.js +89 -0
  54. package/dist/artifacts/storage-driver.js.map +1 -0
  55. package/dist/files-sdk/files-sdk.driver.d.ts +18 -18
  56. package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
  57. package/dist/files-sdk/fs/index.d.ts +21 -0
  58. package/dist/files-sdk/fs/index.d.ts.map +1 -0
  59. package/dist/files-sdk/fs/index.js +20 -0
  60. package/dist/files-sdk/fs/index.js.map +1 -0
  61. package/dist/files-sdk/provider/index.d.ts +54 -0
  62. package/dist/files-sdk/provider/index.d.ts.map +1 -0
  63. package/dist/files-sdk/provider/index.js +80 -0
  64. package/dist/files-sdk/provider/index.js.map +1 -0
  65. package/dist/files-sdk/s3/index.d.ts +13 -2
  66. package/dist/files-sdk/s3/index.d.ts.map +1 -1
  67. package/dist/files-sdk/s3/index.js +19 -8
  68. package/dist/files-sdk/s3/index.js.map +1 -1
  69. package/dist/gateway/storage-gateway-fastify-parser.d.ts.map +1 -1
  70. package/dist/gateway/storage-gateway-fastify-parser.js.map +1 -1
  71. package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
  72. package/dist/gateway/storage-gateway.controller.js.map +1 -1
  73. package/dist/gateway/storage-gateway.guard.d.ts.map +1 -1
  74. package/dist/gateway/storage-gateway.guard.js.map +1 -1
  75. package/dist/gateway/storage-gateway.module.d.ts.map +1 -1
  76. package/dist/gateway/storage-gateway.module.js.map +1 -1
  77. package/dist/gateway/storage-gateway.types.d.ts +10 -10
  78. package/dist/gateway/storage-gateway.types.d.ts.map +1 -1
  79. package/dist/inject-storage.decorator.js.map +1 -1
  80. package/dist/storage-upload-control.d.ts.map +1 -1
  81. package/dist/storage.client.d.ts.map +1 -1
  82. package/dist/storage.client.js.map +1 -1
  83. package/dist/storage.error.d.ts +10 -10
  84. package/dist/storage.error.d.ts.map +1 -1
  85. package/dist/storage.error.js.map +1 -1
  86. package/dist/storage.module.d.ts.map +1 -1
  87. package/dist/storage.module.js.map +1 -1
  88. package/dist/storage.service.d.ts.map +1 -1
  89. package/dist/storage.service.js.map +1 -1
  90. package/dist/storage.tokens.js.map +1 -1
  91. package/dist/testing/index.js.map +1 -1
  92. package/package.json +34 -10
@@ -0,0 +1,273 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { canonicalJson, SCOPE_FROM_HEADER, } from './context.js';
3
+ /**
4
+ * Envelope v1 framing (ADR-0001 D2, normative):
5
+ *
6
+ * bytes 0-3 magic "CAE1"
7
+ * bytes 4-7 u32 BE headerLen
8
+ * bytes 8-… headerJSON (UTF-8, exactly headerLen bytes)
9
+ * rest AES-256-GCM ciphertext ‖ 16-byte auth tag
10
+ *
11
+ * The GCM AAD is canonicalJson(ctx); ctx is duplicated in the header for operability (rewrap
12
+ * jobs, debugging) but the BINDING is the AAD — a tampered header fails the tag check.
13
+ */
14
+ const MAGIC = Buffer.from('CAE1');
15
+ const TAG_LEN = 16;
16
+ const IV_LEN = 12;
17
+ /** Headers are small (~400 bytes); anything larger is hostile or corrupt. */
18
+ const MAX_HEADER_LEN = 64 * 1024;
19
+ /** Maximum bytes added around plaintext by a valid CAE1 envelope. Useful for bounded raw reads. */
20
+ export const ENVELOPE_MAX_OVERHEAD_BYTES = 8 + MAX_HEADER_LEN + TAG_LEN;
21
+ export const ENVELOPE_WRITE_VERSION = 1;
22
+ export const ENVELOPE_SUPPORTED_VERSIONS = [
23
+ ENVELOPE_WRITE_VERSION,
24
+ ];
25
+ /**
26
+ * Structured decrypt failure. Carries only address metadata, never key material or plaintext —
27
+ * safe to log as-is (the failure-reason counter in the runbook keys off `reason`).
28
+ */
29
+ export class EnvelopeError extends Error {
30
+ reason;
31
+ artifactId;
32
+ path;
33
+ constructor(reason, artifactId, path, detail) {
34
+ super(`envelope ${reason} (artifact=${artifactId ?? '-'} path=${path})${detail ? `: ${detail}` : ''}`);
35
+ this.reason = reason;
36
+ this.artifactId = artifactId;
37
+ this.path = path;
38
+ this.name = 'EnvelopeError';
39
+ }
40
+ }
41
+ /**
42
+ * Payload AAD. ADR-0001 D2 specifies canonicalJson(ctx); we additionally bind `ct` (deliberate
43
+ * strengthening, called out in the PR): the content type steers how bytes are SERVED, and it is
44
+ * stable across SEC-04's rewrap (which rewrites only kid/wdk — those stay unbound so rewrap
45
+ * never re-encrypts payloads; their integrity comes from the context-bound wrap itself). If a
46
+ * future v2 coexists with v1, bind `v` too — two live versions make downgrade flips meaningful.
47
+ */
48
+ function aadFor(ctx, contentType) {
49
+ // NUL separator: canonicalJson is self-delimiting JSON and NUL cannot appear in a JSON string
50
+ // or a content type, so distinct (ctx, ct) pairs can never collide into one AAD. FROZEN — the
51
+ // golden-vector spec pins these exact bytes; changing them bricks every stored object.
52
+ return Buffer.from(contentType === undefined
53
+ ? canonicalJson(ctx)
54
+ : `${canonicalJson(ctx)}\0${contentType}`);
55
+ }
56
+ /** Encrypt one object. Mints a fresh DEK per write; the DEK never outlives the call. */
57
+ export async function seal(plain, ctx, provider, contentType) {
58
+ validateContextShape(ctx, (detail) => {
59
+ throw new Error(`seal: invalid context (${detail})`);
60
+ });
61
+ if (contentType !== undefined &&
62
+ (typeof contentType !== 'string' || contentType.length === 0)) {
63
+ throw new Error('seal: content type must be non-empty when provided');
64
+ }
65
+ // Reject obviously impossible user metadata before asking KMS to mint a data key. The exact
66
+ // serialized header is checked again once provider-owned kid/wdk fields are available.
67
+ const userMetadataLength = Buffer.byteLength(JSON.stringify({ ctx, ...(contentType ? { ct: contentType } : {}) }), 'utf8');
68
+ if (userMetadataLength > MAX_HEADER_LEN) {
69
+ throw new Error(`seal: envelope header exceeds ${MAX_HEADER_LEN} bytes`);
70
+ }
71
+ const generated = await provider.generateDataKey(ctx);
72
+ const { dek, wdk, kid } = generated;
73
+ try {
74
+ if (!Buffer.isBuffer(dek) || dek.length !== 32) {
75
+ throw new Error('seal: key provider returned an invalid 32-byte DEK');
76
+ }
77
+ if (!Buffer.isBuffer(wdk) || wdk.length === 0) {
78
+ throw new Error('seal: key provider returned an empty wrapped data key');
79
+ }
80
+ if (typeof kid !== 'string' || kid.length === 0) {
81
+ throw new Error('seal: key provider returned an empty key id');
82
+ }
83
+ const iv = randomBytes(IV_LEN);
84
+ const header = {
85
+ v: ENVELOPE_WRITE_VERSION,
86
+ alg: 'A256GCM',
87
+ kid,
88
+ wdk: wdk.toString('base64'),
89
+ iv: iv.toString('base64'),
90
+ ctx,
91
+ ...(contentType ? { ct: contentType } : {}),
92
+ };
93
+ const headerJson = Buffer.from(JSON.stringify(header), 'utf8');
94
+ if (headerJson.length > MAX_HEADER_LEN) {
95
+ throw new Error(`seal: envelope header exceeds ${MAX_HEADER_LEN} bytes`);
96
+ }
97
+ const cipher = createCipheriv('aes-256-gcm', dek, iv);
98
+ cipher.setAAD(aadFor(ctx, contentType));
99
+ const body = Buffer.concat([
100
+ cipher.update(plain),
101
+ cipher.final(),
102
+ cipher.getAuthTag(),
103
+ ]);
104
+ const lenBuf = Buffer.alloc(4);
105
+ lenBuf.writeUInt32BE(headerJson.length, 0);
106
+ return Buffer.concat([MAGIC, lenBuf, headerJson, body]);
107
+ }
108
+ finally {
109
+ if (Buffer.isBuffer(dek))
110
+ dek.fill(0);
111
+ }
112
+ }
113
+ /** True when the bytes carry the envelope magic (cheap pre-check; open() revalidates). */
114
+ export function isEnvelope(bytes) {
115
+ return (bytes.length >= MAGIC.length &&
116
+ bytes.subarray(0, MAGIC.length).equals(MAGIC));
117
+ }
118
+ /**
119
+ * Decrypt one object, fail-closed (ADR-0001 D2/D6). Order matters and is normative:
120
+ * frame → header shape → CONTEXT ASSERTION → key unwrap → tag check. The context assertion runs
121
+ * before any key operation so a swapped object costs no KMS call and leaks no oracle; `scope`
122
+ * alone may come from the header (SCOPE_FROM_HEADER) because the wrap layer binds it.
123
+ */
124
+ export async function open(bytes, expect, provider, opts = {}) {
125
+ const fail = (reason, detail) => {
126
+ throw new EnvelopeError(reason, expect.artifactId, expect.path, detail);
127
+ };
128
+ if (!isEnvelope(bytes)) {
129
+ if (opts.allowLegacyPlaintext)
130
+ return { plain: bytes, ctx: legacyCtx(expect), legacy: true };
131
+ fail('bad-magic');
132
+ }
133
+ if (bytes.length < 8)
134
+ fail('truncated', 'no header length');
135
+ const headerLen = bytes.readUInt32BE(4);
136
+ if (headerLen > MAX_HEADER_LEN)
137
+ fail('malformed-header', 'header length out of range');
138
+ if (8 + headerLen + TAG_LEN > bytes.length)
139
+ fail('truncated', 'body shorter than header + tag');
140
+ let header;
141
+ try {
142
+ header = JSON.parse(bytes.subarray(8, 8 + headerLen).toString('utf8'));
143
+ }
144
+ catch {
145
+ return fail('malformed-header', 'header is not valid JSON');
146
+ }
147
+ validateHeaderShape(header, fail);
148
+ if (header.v !== ENVELOPE_WRITE_VERSION) {
149
+ // Reads must support every version in ENVELOPE_SUPPORTED_VERSIONS; with only v1 defined,
150
+ // anything else is unsupported. A future v2 branches here — writes stay on the newest
151
+ // version and never silently downgrade.
152
+ fail('unsupported-version', `v=${String(header.v)}`);
153
+ }
154
+ if (header.alg !== 'A256GCM')
155
+ fail('unsupported-alg', header.alg);
156
+ const ctx = header.ctx;
157
+ // The anti-swap assertion: every field the reader independently knows must match the header
158
+ // BEFORE any key operation. Only `scope` may be delegated to the header (sandbox), where the
159
+ // wrap binding enforces it instead.
160
+ if (ctx.artifactId !== expect.artifactId)
161
+ fail('context-mismatch', 'artifactId');
162
+ if (ctx.path !== expect.path)
163
+ fail('context-mismatch', 'path');
164
+ if (ctx.version !== expect.version)
165
+ fail('context-mismatch', 'version');
166
+ if (expect.scope !== SCOPE_FROM_HEADER && ctx.scope !== expect.scope) {
167
+ fail('context-mismatch', 'scope');
168
+ }
169
+ const iv = Buffer.from(header.iv, 'base64');
170
+ if (iv.length !== IV_LEN)
171
+ fail('malformed-header', 'iv must be 12 bytes');
172
+ const wdk = Buffer.from(header.wdk, 'base64');
173
+ if (wdk.length === 0)
174
+ fail('malformed-header', 'empty wrapped data key');
175
+ let unwrapped;
176
+ try {
177
+ unwrapped = await provider.unwrapDataKey(wdk, header.kid, ctx);
178
+ }
179
+ catch {
180
+ // Wrong wrap context, foreign kid, or an unavailable key service — indistinguishable by
181
+ // design; serving plaintext because the key layer failed is the vulnerability (ADR D7).
182
+ return fail('key-unavailable');
183
+ }
184
+ if (!Buffer.isBuffer(unwrapped) || unwrapped.length !== 32) {
185
+ if (Buffer.isBuffer(unwrapped))
186
+ unwrapped.fill(0);
187
+ return fail('key-unavailable');
188
+ }
189
+ const dek = unwrapped;
190
+ try {
191
+ const body = bytes.subarray(8 + headerLen);
192
+ const tag = body.subarray(body.length - TAG_LEN);
193
+ const decipher = createDecipheriv('aes-256-gcm', dek, iv);
194
+ decipher.setAAD(aadFor(ctx, header.ct));
195
+ decipher.setAuthTag(tag);
196
+ let plain;
197
+ try {
198
+ plain = Buffer.concat([
199
+ decipher.update(body.subarray(0, body.length - TAG_LEN)),
200
+ decipher.final(),
201
+ ]);
202
+ }
203
+ catch {
204
+ return fail('auth-failed');
205
+ }
206
+ return {
207
+ plain,
208
+ ctx,
209
+ ...(header.ct === undefined ? {} : { contentType: header.ct }),
210
+ legacy: false,
211
+ };
212
+ }
213
+ finally {
214
+ dek.fill(0);
215
+ }
216
+ }
217
+ /** Header-shape validation: exact key sets, exact types. Any surplus key is hostile (ADR D2). */
218
+ function validateHeaderShape(header, fail) {
219
+ if (typeof header !== 'object' || header === null)
220
+ fail('malformed-header', 'not an object');
221
+ const allowed = new Set(['v', 'alg', 'kid', 'wdk', 'iv', 'ctx', 'ct']);
222
+ for (const key of Object.keys(header)) {
223
+ if (!allowed.has(key))
224
+ fail('malformed-header', `unexpected header key "${key}"`);
225
+ }
226
+ if (typeof header.v !== 'number')
227
+ fail('malformed-header', 'v');
228
+ if (typeof header.alg !== 'string')
229
+ fail('malformed-header', 'alg');
230
+ if (typeof header.kid !== 'string' || header.kid === '')
231
+ fail('malformed-header', 'kid');
232
+ if (typeof header.wdk !== 'string' || header.wdk === '')
233
+ fail('malformed-header', 'wdk');
234
+ if (typeof header.iv !== 'string')
235
+ fail('malformed-header', 'iv');
236
+ if (header.ct !== undefined &&
237
+ (typeof header.ct !== 'string' || header.ct.length === 0))
238
+ fail('malformed-header', 'ct');
239
+ validateContextShape(header.ctx, (detail) => fail('malformed-header', detail));
240
+ }
241
+ function validateContextShape(ctx, fail) {
242
+ if (typeof ctx !== 'object' || ctx === null)
243
+ fail('ctx');
244
+ const ctxKeys = Object.keys(ctx);
245
+ const expected = ['scope', 'artifactId', 'version', 'path'];
246
+ if (ctxKeys.length !== expected.length ||
247
+ expected.some((k) => !ctxKeys.includes(k))) {
248
+ // Exactly these four keys: a surplus field could otherwise ride the AAD unexamined.
249
+ fail('ctx key set');
250
+ }
251
+ const c = ctx;
252
+ if (typeof c.scope !== 'string' ||
253
+ c.scope === '' ||
254
+ c.scope === SCOPE_FROM_HEADER) {
255
+ fail('ctx.scope');
256
+ }
257
+ if (c.artifactId !== null && typeof c.artifactId !== 'string')
258
+ fail('ctx.artifactId');
259
+ if (c.version !== null && typeof c.version !== 'string')
260
+ fail('ctx.version');
261
+ if (typeof c.path !== 'string')
262
+ fail('ctx.path');
263
+ }
264
+ /** Descriptive ctx for a legacy plaintext pass-through (nothing was verified). */
265
+ function legacyCtx(expect) {
266
+ return {
267
+ scope: expect.scope === SCOPE_FROM_HEADER ? 'legacy:unverified' : expect.scope,
268
+ artifactId: expect.artifactId,
269
+ version: expect.version,
270
+ path: expect.path,
271
+ };
272
+ }
273
+ //# sourceMappingURL=codec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.js","sourceRoot":"","sources":["../../../src/artifacts/crypto/codec.ts"],"names":[],"mappings":"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","sourcesContent":["import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';\n\nimport {\n canonicalJson,\n SCOPE_FROM_HEADER,\n type EnvelopeContext,\n type ExpectedContext,\n} from './context.js';\nimport type { KeyProvider } from './key-provider.js';\n\n/**\n * Envelope v1 framing (ADR-0001 D2, normative):\n *\n * bytes 0-3 magic \"CAE1\"\n * bytes 4-7 u32 BE headerLen\n * bytes 8-… headerJSON (UTF-8, exactly headerLen bytes)\n * rest AES-256-GCM ciphertext ‖ 16-byte auth tag\n *\n * The GCM AAD is canonicalJson(ctx); ctx is duplicated in the header for operability (rewrap\n * jobs, debugging) but the BINDING is the AAD — a tampered header fails the tag check.\n */\nconst MAGIC = Buffer.from('CAE1');\nconst TAG_LEN = 16;\nconst IV_LEN = 12;\n/** Headers are small (~400 bytes); anything larger is hostile or corrupt. */\nconst MAX_HEADER_LEN = 64 * 1024;\n/** Maximum bytes added around plaintext by a valid CAE1 envelope. Useful for bounded raw reads. */\nexport const ENVELOPE_MAX_OVERHEAD_BYTES = 8 + MAX_HEADER_LEN + TAG_LEN;\n\nexport const ENVELOPE_WRITE_VERSION = 1;\nexport const ENVELOPE_SUPPORTED_VERSIONS: readonly number[] = [\n ENVELOPE_WRITE_VERSION,\n];\n\nexport type EnvelopeFailureReason =\n | 'bad-magic'\n | 'truncated'\n | 'malformed-header'\n | 'unsupported-version'\n | 'unsupported-alg'\n | 'context-mismatch'\n | 'key-unavailable'\n | 'auth-failed';\n\n/**\n * Structured decrypt failure. Carries only address metadata, never key material or plaintext —\n * safe to log as-is (the failure-reason counter in the runbook keys off `reason`).\n */\nexport class EnvelopeError extends Error {\n constructor(\n readonly reason: EnvelopeFailureReason,\n readonly artifactId: string | null,\n readonly path: string,\n detail?: string,\n ) {\n super(\n `envelope ${reason} (artifact=${artifactId ?? '-'} path=${path})${detail ? `: ${detail}` : ''}`,\n );\n this.name = 'EnvelopeError';\n }\n}\n\ninterface EnvelopeHeader {\n v: number;\n alg: string;\n kid: string;\n wdk: string;\n iv: string;\n ctx: EnvelopeContext;\n ct?: string;\n}\n\nexport interface OpenResult {\n plain: Buffer;\n ctx: EnvelopeContext;\n contentType?: string;\n /** True only when legacy plaintext passed through under the explicit migration flag. */\n legacy: boolean;\n}\n\nexport interface OpenOptions {\n /**\n * SEC-04's migration-window escape hatch, default off. Consulted ONLY when the magic bytes\n * are absent (a pre-envelope plaintext object) — never for a valid-magic object that fails\n * validation, so the flag cannot downgrade a tampered envelope to a plaintext read.\n */\n allowLegacyPlaintext?: boolean;\n}\n\n/**\n * Payload AAD. ADR-0001 D2 specifies canonicalJson(ctx); we additionally bind `ct` (deliberate\n * strengthening, called out in the PR): the content type steers how bytes are SERVED, and it is\n * stable across SEC-04's rewrap (which rewrites only kid/wdk — those stay unbound so rewrap\n * never re-encrypts payloads; their integrity comes from the context-bound wrap itself). If a\n * future v2 coexists with v1, bind `v` too — two live versions make downgrade flips meaningful.\n */\nfunction aadFor(ctx: EnvelopeContext, contentType?: string): Buffer {\n // NUL separator: canonicalJson is self-delimiting JSON and NUL cannot appear in a JSON string\n // or a content type, so distinct (ctx, ct) pairs can never collide into one AAD. FROZEN — the\n // golden-vector spec pins these exact bytes; changing them bricks every stored object.\n return Buffer.from(\n contentType === undefined\n ? canonicalJson(ctx)\n : `${canonicalJson(ctx)}\\0${contentType}`,\n );\n}\n\n/** Encrypt one object. Mints a fresh DEK per write; the DEK never outlives the call. */\nexport async function seal(\n plain: Buffer,\n ctx: EnvelopeContext,\n provider: KeyProvider,\n contentType?: string,\n): Promise<Buffer> {\n validateContextShape(ctx, (detail) => {\n throw new Error(`seal: invalid context (${detail})`);\n });\n if (\n contentType !== undefined &&\n (typeof contentType !== 'string' || contentType.length === 0)\n ) {\n throw new Error('seal: content type must be non-empty when provided');\n }\n // Reject obviously impossible user metadata before asking KMS to mint a data key. The exact\n // serialized header is checked again once provider-owned kid/wdk fields are available.\n const userMetadataLength = Buffer.byteLength(\n JSON.stringify({ ctx, ...(contentType ? { ct: contentType } : {}) }),\n 'utf8',\n );\n if (userMetadataLength > MAX_HEADER_LEN) {\n throw new Error(`seal: envelope header exceeds ${MAX_HEADER_LEN} bytes`);\n }\n const generated = await provider.generateDataKey(ctx);\n const { dek, wdk, kid } = generated;\n try {\n if (!Buffer.isBuffer(dek) || dek.length !== 32) {\n throw new Error('seal: key provider returned an invalid 32-byte DEK');\n }\n if (!Buffer.isBuffer(wdk) || wdk.length === 0) {\n throw new Error('seal: key provider returned an empty wrapped data key');\n }\n if (typeof kid !== 'string' || kid.length === 0) {\n throw new Error('seal: key provider returned an empty key id');\n }\n const iv = randomBytes(IV_LEN);\n const header: EnvelopeHeader = {\n v: ENVELOPE_WRITE_VERSION,\n alg: 'A256GCM',\n kid,\n wdk: wdk.toString('base64'),\n iv: iv.toString('base64'),\n ctx,\n ...(contentType ? { ct: contentType } : {}),\n };\n const headerJson = Buffer.from(JSON.stringify(header), 'utf8');\n if (headerJson.length > MAX_HEADER_LEN) {\n throw new Error(`seal: envelope header exceeds ${MAX_HEADER_LEN} bytes`);\n }\n const cipher = createCipheriv('aes-256-gcm', dek, iv);\n cipher.setAAD(aadFor(ctx, contentType));\n const body = Buffer.concat([\n cipher.update(plain),\n cipher.final(),\n cipher.getAuthTag(),\n ]);\n const lenBuf = Buffer.alloc(4);\n lenBuf.writeUInt32BE(headerJson.length, 0);\n return Buffer.concat([MAGIC, lenBuf, headerJson, body]);\n } finally {\n if (Buffer.isBuffer(dek)) dek.fill(0);\n }\n}\n\n/** True when the bytes carry the envelope magic (cheap pre-check; open() revalidates). */\nexport function isEnvelope(bytes: Buffer): boolean {\n return (\n bytes.length >= MAGIC.length &&\n bytes.subarray(0, MAGIC.length).equals(MAGIC)\n );\n}\n\n/**\n * Decrypt one object, fail-closed (ADR-0001 D2/D6). Order matters and is normative:\n * frame → header shape → CONTEXT ASSERTION → key unwrap → tag check. The context assertion runs\n * before any key operation so a swapped object costs no KMS call and leaks no oracle; `scope`\n * alone may come from the header (SCOPE_FROM_HEADER) because the wrap layer binds it.\n */\nexport async function open(\n bytes: Buffer,\n expect: ExpectedContext,\n provider: KeyProvider,\n opts: OpenOptions = {},\n): Promise<OpenResult> {\n const fail = (reason: EnvelopeFailureReason, detail?: string): never => {\n throw new EnvelopeError(reason, expect.artifactId, expect.path, detail);\n };\n\n if (!isEnvelope(bytes)) {\n if (opts.allowLegacyPlaintext)\n return { plain: bytes, ctx: legacyCtx(expect), legacy: true };\n fail('bad-magic');\n }\n if (bytes.length < 8) fail('truncated', 'no header length');\n const headerLen = bytes.readUInt32BE(4);\n if (headerLen > MAX_HEADER_LEN)\n fail('malformed-header', 'header length out of range');\n if (8 + headerLen + TAG_LEN > bytes.length)\n fail('truncated', 'body shorter than header + tag');\n\n let header: EnvelopeHeader;\n try {\n header = JSON.parse(\n bytes.subarray(8, 8 + headerLen).toString('utf8'),\n ) as EnvelopeHeader;\n } catch {\n return fail('malformed-header', 'header is not valid JSON');\n }\n validateHeaderShape(header, fail);\n if (header.v !== ENVELOPE_WRITE_VERSION) {\n // Reads must support every version in ENVELOPE_SUPPORTED_VERSIONS; with only v1 defined,\n // anything else is unsupported. A future v2 branches here — writes stay on the newest\n // version and never silently downgrade.\n fail('unsupported-version', `v=${String(header.v)}`);\n }\n if (header.alg !== 'A256GCM') fail('unsupported-alg', header.alg);\n\n const ctx = header.ctx;\n // The anti-swap assertion: every field the reader independently knows must match the header\n // BEFORE any key operation. Only `scope` may be delegated to the header (sandbox), where the\n // wrap binding enforces it instead.\n if (ctx.artifactId !== expect.artifactId)\n fail('context-mismatch', 'artifactId');\n if (ctx.path !== expect.path) fail('context-mismatch', 'path');\n if (ctx.version !== expect.version) fail('context-mismatch', 'version');\n if (expect.scope !== SCOPE_FROM_HEADER && ctx.scope !== expect.scope) {\n fail('context-mismatch', 'scope');\n }\n\n const iv = Buffer.from(header.iv, 'base64');\n if (iv.length !== IV_LEN) fail('malformed-header', 'iv must be 12 bytes');\n const wdk = Buffer.from(header.wdk, 'base64');\n if (wdk.length === 0) fail('malformed-header', 'empty wrapped data key');\n\n let unwrapped: unknown;\n try {\n unwrapped = await provider.unwrapDataKey(wdk, header.kid, ctx);\n } catch {\n // Wrong wrap context, foreign kid, or an unavailable key service — indistinguishable by\n // design; serving plaintext because the key layer failed is the vulnerability (ADR D7).\n return fail('key-unavailable');\n }\n if (!Buffer.isBuffer(unwrapped) || unwrapped.length !== 32) {\n if (Buffer.isBuffer(unwrapped)) unwrapped.fill(0);\n return fail('key-unavailable');\n }\n const dek = unwrapped;\n try {\n const body = bytes.subarray(8 + headerLen);\n const tag = body.subarray(body.length - TAG_LEN);\n const decipher = createDecipheriv('aes-256-gcm', dek, iv);\n decipher.setAAD(aadFor(ctx, header.ct));\n decipher.setAuthTag(tag);\n let plain: Buffer;\n try {\n plain = Buffer.concat([\n decipher.update(body.subarray(0, body.length - TAG_LEN)),\n decipher.final(),\n ]);\n } catch {\n return fail('auth-failed');\n }\n return {\n plain,\n ctx,\n ...(header.ct === undefined ? {} : { contentType: header.ct }),\n legacy: false,\n };\n } finally {\n dek.fill(0);\n }\n}\n\n/** Header-shape validation: exact key sets, exact types. Any surplus key is hostile (ADR D2). */\nfunction validateHeaderShape(\n header: EnvelopeHeader,\n fail: (reason: EnvelopeFailureReason, detail?: string) => never,\n): void {\n if (typeof header !== 'object' || header === null)\n fail('malformed-header', 'not an object');\n const allowed = new Set(['v', 'alg', 'kid', 'wdk', 'iv', 'ctx', 'ct']);\n for (const key of Object.keys(header)) {\n if (!allowed.has(key))\n fail('malformed-header', `unexpected header key \"${key}\"`);\n }\n if (typeof header.v !== 'number') fail('malformed-header', 'v');\n if (typeof header.alg !== 'string') fail('malformed-header', 'alg');\n if (typeof header.kid !== 'string' || header.kid === '')\n fail('malformed-header', 'kid');\n if (typeof header.wdk !== 'string' || header.wdk === '')\n fail('malformed-header', 'wdk');\n if (typeof header.iv !== 'string') fail('malformed-header', 'iv');\n if (\n header.ct !== undefined &&\n (typeof header.ct !== 'string' || header.ct.length === 0)\n )\n fail('malformed-header', 'ct');\n validateContextShape(header.ctx, (detail) =>\n fail('malformed-header', detail),\n );\n}\n\nfunction validateContextShape(\n ctx: unknown,\n fail: (detail: string) => never,\n): asserts ctx is EnvelopeContext {\n if (typeof ctx !== 'object' || ctx === null) fail('ctx');\n const ctxKeys = Object.keys(ctx);\n const expected = ['scope', 'artifactId', 'version', 'path'];\n if (\n ctxKeys.length !== expected.length ||\n expected.some((k) => !ctxKeys.includes(k))\n ) {\n // Exactly these four keys: a surplus field could otherwise ride the AAD unexamined.\n fail('ctx key set');\n }\n const c = ctx as Record<string, unknown>;\n if (\n typeof c.scope !== 'string' ||\n c.scope === '' ||\n c.scope === SCOPE_FROM_HEADER\n ) {\n fail('ctx.scope');\n }\n if (c.artifactId !== null && typeof c.artifactId !== 'string')\n fail('ctx.artifactId');\n if (c.version !== null && typeof c.version !== 'string') fail('ctx.version');\n if (typeof c.path !== 'string') fail('ctx.path');\n}\n\n/** Descriptive ctx for a legacy plaintext pass-through (nothing was verified). */\nfunction legacyCtx(expect: ExpectedContext): EnvelopeContext {\n return {\n scope:\n expect.scope === SCOPE_FROM_HEADER ? 'legacy:unverified' : expect.scope,\n artifactId: expect.artifactId,\n version: expect.version,\n path: expect.path,\n };\n}\n"]}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The encryption context that binds every stored object to its address (ADR-0001 D2). It is the
3
+ * GCM AAD of the payload AND the wrap context of the data key, so a ciphertext moved to another
4
+ * scope/artifact/path fails closed at decrypt — object swapping and cross-artifact replay are
5
+ * structurally impossible, not policy-checked.
6
+ */
7
+ export interface EnvelopeContext {
8
+ /** Owning tenant: `org:<organizationId>` | `user:<ownerUserId>` | `upload:<uploadId>`. */
9
+ scope: string;
10
+ /** The artifact the object belongs to; `null` for non-artifact ObjectStore objects. */
11
+ artifactId: string | null;
12
+ /** Immutable version id; `null` until VER-02 introduces versions. */
13
+ version: string | null;
14
+ /** Storage-relative path AS READ (e.g. `index.html` on the fs directory rewrite — ADR D6.1). */
15
+ path: string;
16
+ }
17
+ /**
18
+ * Read-side sentinel for callers that cannot independently know the owning scope — the sandbox,
19
+ * which has only the artifactId from the URL and no DB. The reader still asserts every field it
20
+ * DOES know (artifactId, path, version) against the cleartext header before any key operation;
21
+ * `scope` is then taken from the header and enforced cryptographically by the wrap layer: the
22
+ * KMS EncryptionContext / local wrap-AAD includes it, so a forged header scope fails at
23
+ * `unwrapDataKey`, not silently.
24
+ */
25
+ export declare const SCOPE_FROM_HEADER = "@from-header";
26
+ /** What a reader knows independently about the object it expects (the anti-swap assertion). */
27
+ export interface ExpectedContext {
28
+ scope: string | typeof SCOPE_FROM_HEADER;
29
+ artifactId: string | null;
30
+ version: string | null;
31
+ path: string;
32
+ }
33
+ /** Scope for an artifact: the owning org when there is one, else the owning user. */
34
+ export declare function artifactScope(owner: {
35
+ organizationId?: string | null;
36
+ ownerUserId: string;
37
+ }): string;
38
+ /** Scope for organization-owned non-artifact objects (logos). */
39
+ export declare function orgScope(organizationId: string): string;
40
+ /** Scope for staged upload objects (`_staging/*`) — short-lived but still customer plaintext. */
41
+ export declare function uploadScope(uploadId: string): string;
42
+ /**
43
+ * Canonical serialization used as the payload AAD and the local wrap AAD: fixed sorted key
44
+ * order, no whitespace. Rebuilt from parsed values so header key ORDER never matters, while the
45
+ * key SET is pinned by the codec's strict header validation.
46
+ */
47
+ export declare function canonicalJson(ctx: EnvelopeContext): string;
48
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../../src/artifacts/crypto/context.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC;IACd,uFAAuF;IACvF,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,qEAAqE;IACrE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,gGAAgG;IAChG,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;AAEhD,+FAA+F;AAC/F,MAAM,WAAW,eAAe;IAI9B,KAAK,EAAE,MAAM,GAAG,OAAO,iBAAiB,CAAC;IACzC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,KAAK,EAAE;IACnC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,MAAM,CAIT;AAED,iEAAiE;AACjE,wBAAgB,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,iGAAiG;AACjG,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAO1D"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read-side sentinel for callers that cannot independently know the owning scope — the sandbox,
3
+ * which has only the artifactId from the URL and no DB. The reader still asserts every field it
4
+ * DOES know (artifactId, path, version) against the cleartext header before any key operation;
5
+ * `scope` is then taken from the header and enforced cryptographically by the wrap layer: the
6
+ * KMS EncryptionContext / local wrap-AAD includes it, so a forged header scope fails at
7
+ * `unwrapDataKey`, not silently.
8
+ */
9
+ export const SCOPE_FROM_HEADER = '@from-header';
10
+ /** Scope for an artifact: the owning org when there is one, else the owning user. */
11
+ export function artifactScope(owner) {
12
+ return owner.organizationId
13
+ ? `org:${owner.organizationId}`
14
+ : `user:${owner.ownerUserId}`;
15
+ }
16
+ /** Scope for organization-owned non-artifact objects (logos). */
17
+ export function orgScope(organizationId) {
18
+ return `org:${organizationId}`;
19
+ }
20
+ /** Scope for staged upload objects (`_staging/*`) — short-lived but still customer plaintext. */
21
+ export function uploadScope(uploadId) {
22
+ return `upload:${uploadId}`;
23
+ }
24
+ /**
25
+ * Canonical serialization used as the payload AAD and the local wrap AAD: fixed sorted key
26
+ * order, no whitespace. Rebuilt from parsed values so header key ORDER never matters, while the
27
+ * key SET is pinned by the codec's strict header validation.
28
+ */
29
+ export function canonicalJson(ctx) {
30
+ return JSON.stringify({
31
+ artifactId: ctx.artifactId,
32
+ path: ctx.path,
33
+ scope: ctx.scope,
34
+ version: ctx.version,
35
+ });
36
+ }
37
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../../../src/artifacts/crypto/context.ts"],"names":[],"mappings":"AAiBA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,cAAc,CAAC;AAahD,qFAAqF;AACrF,MAAM,UAAU,aAAa,CAAC,KAG7B;IACC,OAAO,KAAK,CAAC,cAAc;QACzB,CAAC,CAAC,OAAO,KAAK,CAAC,cAAc,EAAE;QAC/B,CAAC,CAAC,QAAQ,KAAK,CAAC,WAAW,EAAE,CAAC;AAClC,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,QAAQ,CAAC,cAAsB;IAC7C,OAAO,OAAO,cAAc,EAAE,CAAC;AACjC,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,WAAW,CAAC,QAAgB;IAC1C,OAAO,UAAU,QAAQ,EAAE,CAAC;AAC9B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,GAAoB;IAChD,OAAO,IAAI,CAAC,SAAS,CAAC;QACpB,UAAU,EAAE,GAAG,CAAC,UAAU;QAC1B,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,OAAO,EAAE,GAAG,CAAC,OAAO;KACrB,CAAC,CAAC;AACL,CAAC","sourcesContent":["/**\n * The encryption context that binds every stored object to its address (ADR-0001 D2). It is the\n * GCM AAD of the payload AND the wrap context of the data key, so a ciphertext moved to another\n * scope/artifact/path fails closed at decrypt — object swapping and cross-artifact replay are\n * structurally impossible, not policy-checked.\n */\nexport interface EnvelopeContext {\n /** Owning tenant: `org:<organizationId>` | `user:<ownerUserId>` | `upload:<uploadId>`. */\n scope: string;\n /** The artifact the object belongs to; `null` for non-artifact ObjectStore objects. */\n artifactId: string | null;\n /** Immutable version id; `null` until VER-02 introduces versions. */\n version: string | null;\n /** Storage-relative path AS READ (e.g. `index.html` on the fs directory rewrite — ADR D6.1). */\n path: string;\n}\n\n/**\n * Read-side sentinel for callers that cannot independently know the owning scope — the sandbox,\n * which has only the artifactId from the URL and no DB. The reader still asserts every field it\n * DOES know (artifactId, path, version) against the cleartext header before any key operation;\n * `scope` is then taken from the header and enforced cryptographically by the wrap layer: the\n * KMS EncryptionContext / local wrap-AAD includes it, so a forged header scope fails at\n * `unwrapDataKey`, not silently.\n */\nexport const SCOPE_FROM_HEADER = '@from-header';\n\n/** What a reader knows independently about the object it expects (the anti-swap assertion). */\nexport interface ExpectedContext {\n // `string` absorbs the literal for the checker, but the sentinel is domain vocabulary\n // here — collapsing the union would erase what @from-header means to a reader.\n // eslint-disable-next-line typescript/no-redundant-type-constituents\n scope: string | typeof SCOPE_FROM_HEADER;\n artifactId: string | null;\n version: string | null;\n path: string;\n}\n\n/** Scope for an artifact: the owning org when there is one, else the owning user. */\nexport function artifactScope(owner: {\n organizationId?: string | null;\n ownerUserId: string;\n}): string {\n return owner.organizationId\n ? `org:${owner.organizationId}`\n : `user:${owner.ownerUserId}`;\n}\n\n/** Scope for organization-owned non-artifact objects (logos). */\nexport function orgScope(organizationId: string): string {\n return `org:${organizationId}`;\n}\n\n/** Scope for staged upload objects (`_staging/*`) — short-lived but still customer plaintext. */\nexport function uploadScope(uploadId: string): string {\n return `upload:${uploadId}`;\n}\n\n/**\n * Canonical serialization used as the payload AAD and the local wrap AAD: fixed sorted key\n * order, no whitespace. Rebuilt from parsed values so header key ORDER never matters, while the\n * key SET is pinned by the codec's strict header validation.\n */\nexport function canonicalJson(ctx: EnvelopeContext): string {\n return JSON.stringify({\n artifactId: ctx.artifactId,\n path: ctx.path,\n scope: ctx.scope,\n version: ctx.version,\n });\n}\n"]}
@@ -0,0 +1,38 @@
1
+ import { type EnvelopeContext } from './context.js';
2
+ import type { DataKey, KeyProvider } from './key-provider.js';
3
+ export interface DekCacheOptions {
4
+ /** Max cached unwrapped DEKs (default 1024). */
5
+ max?: number;
6
+ /** Entry lifetime; ADR-0001 D6.2 caps this at 5 minutes (the default). */
7
+ ttlMs?: number;
8
+ }
9
+ /** Hard policy bounds for cached plaintext data-encryption keys. */
10
+ export declare const MAX_DEK_CACHE_ENTRIES = 65536;
11
+ export declare const MAX_DEK_CACHE_TTL_MS: number;
12
+ /**
13
+ * Bounded in-memory LRU of unwrapped DEKs so the sandbox's per-request reads don't turn into a
14
+ * KMS Decrypt per object (ADR-0001 D6.2). Never persisted; cleared on shutdown. Deliberate
15
+ * T4-shaped trade: a compromised runtime could read this cache, but it can already call unwrap.
16
+ *
17
+ * Deviation from the ADR's literal wording, on purpose: the key is a length-prefixed hash of
18
+ * (kid, wdk, ctx), not sha256(wdk) — keying on wdk alone would let a cache hit bypass the very
19
+ * context check (KMS EncryptionContext / local wrap-AAD) that makes header-sourced scope
20
+ * trustworthy. Length prefixes are required because kid may contain NUL and wdk is arbitrary
21
+ * binary; delimiter concatenation would let distinct tuples collide before hashing.
22
+ */
23
+ export declare class CachingKeyProvider implements KeyProvider {
24
+ private readonly inner;
25
+ private readonly cache;
26
+ private readonly inFlight;
27
+ private readonly max;
28
+ private readonly ttlMs;
29
+ private generation;
30
+ private nextToken;
31
+ constructor(inner: KeyProvider, opts?: DekCacheOptions);
32
+ generateDataKey(ctx: EnvelopeContext): Promise<DataKey>;
33
+ unwrapDataKey(wdk: Buffer, kid: string, ctx: EnvelopeContext): Promise<Buffer>;
34
+ private loadAndCache;
35
+ clear(): void;
36
+ private evict;
37
+ }
38
+ //# sourceMappingURL=dek-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dek-cache.d.ts","sourceRoot":"","sources":["../../../src/artifacts/crypto/dek-cache.ts"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAE9D,MAAM,WAAW,eAAe;IAC9B,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,oEAAoE;AACpE,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAC5C,eAAO,MAAM,oBAAoB,QAAa,CAAC;AAE/C;;;;;;;;;;GAUG;AACH,qBAAa,kBAAmB,YAAW,WAAW;IAoBlD,OAAO,CAAC,QAAQ,CAAC,KAAK;IAnBxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAQlB;IACJ,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAGrB;IACJ,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,SAAS,CAAK;IAEtB,YACmB,KAAK,EAAE,WAAW,EACnC,IAAI,GAAE,eAAoB,EAsB3B;IAED,eAAe,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,OAAO,CAAC,CAGtD;IAEK,aAAa,CACjB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,eAAe,GACnB,OAAO,CAAC,MAAM,CAAC,CAgDjB;YAEa,YAAY;IAmC1B,KAAK,IAAI,IAAI,CAKZ;IAED,OAAO,CAAC,KAAK;CAQd"}
@@ -0,0 +1,137 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './context.js';
3
+ /** Hard policy bounds for cached plaintext data-encryption keys. */
4
+ export const MAX_DEK_CACHE_ENTRIES = 65_536;
5
+ export const MAX_DEK_CACHE_TTL_MS = 5 * 60_000;
6
+ /**
7
+ * Bounded in-memory LRU of unwrapped DEKs so the sandbox's per-request reads don't turn into a
8
+ * KMS Decrypt per object (ADR-0001 D6.2). Never persisted; cleared on shutdown. Deliberate
9
+ * T4-shaped trade: a compromised runtime could read this cache, but it can already call unwrap.
10
+ *
11
+ * Deviation from the ADR's literal wording, on purpose: the key is a length-prefixed hash of
12
+ * (kid, wdk, ctx), not sha256(wdk) — keying on wdk alone would let a cache hit bypass the very
13
+ * context check (KMS EncryptionContext / local wrap-AAD) that makes header-sourced scope
14
+ * trustworthy. Length prefixes are required because kid may contain NUL and wdk is arbitrary
15
+ * binary; delimiter concatenation would let distinct tuples collide before hashing.
16
+ */
17
+ export class CachingKeyProvider {
18
+ inner;
19
+ cache = new Map();
20
+ inFlight = new Map();
21
+ max;
22
+ ttlMs;
23
+ generation = 0;
24
+ nextToken = 0;
25
+ constructor(inner, opts = {}) {
26
+ this.inner = inner;
27
+ this.max = opts.max ?? 1024;
28
+ this.ttlMs = opts.ttlMs ?? MAX_DEK_CACHE_TTL_MS;
29
+ if (!Number.isSafeInteger(this.max) ||
30
+ this.max < 1 ||
31
+ this.max > MAX_DEK_CACHE_ENTRIES) {
32
+ throw new RangeError(`DEK cache max must be an integer between 1 and ${MAX_DEK_CACHE_ENTRIES}`);
33
+ }
34
+ if (!Number.isFinite(this.ttlMs) ||
35
+ this.ttlMs <= 0 ||
36
+ this.ttlMs > MAX_DEK_CACHE_TTL_MS) {
37
+ throw new RangeError(`DEK cache ttlMs must be greater than 0 and at most ${MAX_DEK_CACHE_TTL_MS}`);
38
+ }
39
+ }
40
+ generateDataKey(ctx) {
41
+ // Writes always mint a fresh DEK (one per object write) — nothing to cache.
42
+ return this.inner.generateDataKey(ctx);
43
+ }
44
+ async unwrapDataKey(wdk, kid, ctx) {
45
+ const hash = createHash('sha256');
46
+ for (const part of [
47
+ Buffer.from(kid, 'utf8'),
48
+ wdk,
49
+ Buffer.from(canonicalJson(ctx), 'utf8'),
50
+ ]) {
51
+ const length = Buffer.allocUnsafe(8);
52
+ length.writeBigUInt64BE(BigInt(part.byteLength));
53
+ hash.update(length).update(part);
54
+ }
55
+ const key = hash.digest('hex');
56
+ const hit = this.cache.get(key);
57
+ if (hit && hit.expiresAt > Date.now()) {
58
+ // Refresh recency (Map preserves insertion order — oldest entry is the first key).
59
+ this.cache.delete(key);
60
+ this.cache.set(key, hit);
61
+ return Buffer.from(hit.dek);
62
+ }
63
+ if (hit)
64
+ this.evict(key, hit);
65
+ const existingFlight = this.inFlight.get(key);
66
+ if (existingFlight !== undefined &&
67
+ existingFlight.generation === this.generation) {
68
+ const dek = await existingFlight.promise;
69
+ if (existingFlight.generation !== this.generation) {
70
+ throw new Error('DEK cache was cleared during unwrap');
71
+ }
72
+ return Buffer.from(dek);
73
+ }
74
+ const generation = this.generation;
75
+ const flight = {
76
+ generation,
77
+ promise: this.loadAndCache(key, wdk, kid, ctx, generation),
78
+ };
79
+ this.inFlight.set(key, flight);
80
+ try {
81
+ const dek = await flight.promise;
82
+ if (generation !== this.generation) {
83
+ throw new Error('DEK cache was cleared during unwrap');
84
+ }
85
+ return Buffer.from(dek);
86
+ }
87
+ finally {
88
+ if (this.inFlight.get(key) === flight)
89
+ this.inFlight.delete(key);
90
+ }
91
+ }
92
+ async loadAndCache(key, wdk, kid, ctx, generation) {
93
+ const dek = await this.inner.unwrapDataKey(wdk, kid, ctx);
94
+ if (!Buffer.isBuffer(dek) || dek.length !== 32) {
95
+ if (Buffer.isBuffer(dek))
96
+ dek.fill(0);
97
+ throw new Error('inner key provider returned an invalid 32-byte DEK');
98
+ }
99
+ if (generation !== this.generation) {
100
+ dek.fill(0);
101
+ throw new Error('DEK cache was cleared during unwrap');
102
+ }
103
+ if (this.cache.size >= this.max) {
104
+ const oldest = this.cache.keys().next().value;
105
+ if (oldest !== undefined)
106
+ this.evict(oldest, this.cache.get(oldest));
107
+ }
108
+ const token = ++this.nextToken;
109
+ const timer = setTimeout(() => {
110
+ const entry = this.cache.get(key);
111
+ if (entry?.token === token)
112
+ this.evict(key, entry);
113
+ }, this.ttlMs);
114
+ timer.unref?.();
115
+ this.cache.set(key, {
116
+ dek,
117
+ expiresAt: Date.now() + this.ttlMs,
118
+ timer,
119
+ token,
120
+ });
121
+ return dek;
122
+ }
123
+ clear() {
124
+ for (const [key, entry] of this.cache)
125
+ this.evict(key, entry);
126
+ this.generation++;
127
+ this.inFlight.clear();
128
+ this.inner.clear();
129
+ }
130
+ evict(key, entry) {
131
+ if (entry !== undefined)
132
+ clearTimeout(entry.timer);
133
+ entry?.dek.fill(0);
134
+ this.cache.delete(key);
135
+ }
136
+ }
137
+ //# sourceMappingURL=dek-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dek-cache.js","sourceRoot":"","sources":["../../../src/artifacts/crypto/dek-cache.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'node:crypto';\n\nimport { canonicalJson, type EnvelopeContext } from './context.js';\nimport type { DataKey, KeyProvider } from './key-provider.js';\n\nexport interface DekCacheOptions {\n /** Max cached unwrapped DEKs (default 1024). */\n max?: number;\n /** Entry lifetime; ADR-0001 D6.2 caps this at 5 minutes (the default). */\n ttlMs?: number;\n}\n\n/** Hard policy bounds for cached plaintext data-encryption keys. */\nexport const MAX_DEK_CACHE_ENTRIES = 65_536;\nexport const MAX_DEK_CACHE_TTL_MS = 5 * 60_000;\n\n/**\n * Bounded in-memory LRU of unwrapped DEKs so the sandbox's per-request reads don't turn into a\n * KMS Decrypt per object (ADR-0001 D6.2). Never persisted; cleared on shutdown. Deliberate\n * T4-shaped trade: a compromised runtime could read this cache, but it can already call unwrap.\n *\n * Deviation from the ADR's literal wording, on purpose: the key is a length-prefixed hash of\n * (kid, wdk, ctx), not sha256(wdk) — keying on wdk alone would let a cache hit bypass the very\n * context check (KMS EncryptionContext / local wrap-AAD) that makes header-sourced scope\n * trustworthy. Length prefixes are required because kid may contain NUL and wdk is arbitrary\n * binary; delimiter concatenation would let distinct tuples collide before hashing.\n */\nexport class CachingKeyProvider implements KeyProvider {\n private readonly cache = new Map<\n string,\n {\n dek: Buffer;\n expiresAt: number;\n timer: ReturnType<typeof setTimeout>;\n token: number;\n }\n >();\n private readonly inFlight = new Map<\n string,\n { generation: number; promise: Promise<Buffer> }\n >();\n private readonly max: number;\n private readonly ttlMs: number;\n private generation = 0;\n private nextToken = 0;\n\n constructor(\n private readonly inner: KeyProvider,\n opts: DekCacheOptions = {},\n ) {\n this.max = opts.max ?? 1024;\n this.ttlMs = opts.ttlMs ?? MAX_DEK_CACHE_TTL_MS;\n if (\n !Number.isSafeInteger(this.max) ||\n this.max < 1 ||\n this.max > MAX_DEK_CACHE_ENTRIES\n ) {\n throw new RangeError(\n `DEK cache max must be an integer between 1 and ${MAX_DEK_CACHE_ENTRIES}`,\n );\n }\n if (\n !Number.isFinite(this.ttlMs) ||\n this.ttlMs <= 0 ||\n this.ttlMs > MAX_DEK_CACHE_TTL_MS\n ) {\n throw new RangeError(\n `DEK cache ttlMs must be greater than 0 and at most ${MAX_DEK_CACHE_TTL_MS}`,\n );\n }\n }\n\n generateDataKey(ctx: EnvelopeContext): Promise<DataKey> {\n // Writes always mint a fresh DEK (one per object write) — nothing to cache.\n return this.inner.generateDataKey(ctx);\n }\n\n async unwrapDataKey(\n wdk: Buffer,\n kid: string,\n ctx: EnvelopeContext,\n ): Promise<Buffer> {\n const hash = createHash('sha256');\n for (const part of [\n Buffer.from(kid, 'utf8'),\n wdk,\n Buffer.from(canonicalJson(ctx), 'utf8'),\n ]) {\n const length = Buffer.allocUnsafe(8);\n length.writeBigUInt64BE(BigInt(part.byteLength));\n hash.update(length).update(part);\n }\n const key = hash.digest('hex');\n const hit = this.cache.get(key);\n if (hit && hit.expiresAt > Date.now()) {\n // Refresh recency (Map preserves insertion order — oldest entry is the first key).\n this.cache.delete(key);\n this.cache.set(key, hit);\n return Buffer.from(hit.dek);\n }\n if (hit) this.evict(key, hit);\n\n const existingFlight = this.inFlight.get(key);\n if (\n existingFlight !== undefined &&\n existingFlight.generation === this.generation\n ) {\n const dek = await existingFlight.promise;\n if (existingFlight.generation !== this.generation) {\n throw new Error('DEK cache was cleared during unwrap');\n }\n return Buffer.from(dek);\n }\n\n const generation = this.generation;\n const flight = {\n generation,\n promise: this.loadAndCache(key, wdk, kid, ctx, generation),\n };\n this.inFlight.set(key, flight);\n try {\n const dek = await flight.promise;\n if (generation !== this.generation) {\n throw new Error('DEK cache was cleared during unwrap');\n }\n return Buffer.from(dek);\n } finally {\n if (this.inFlight.get(key) === flight) this.inFlight.delete(key);\n }\n }\n\n private async loadAndCache(\n key: string,\n wdk: Buffer,\n kid: string,\n ctx: EnvelopeContext,\n generation: number,\n ): Promise<Buffer> {\n const dek = await this.inner.unwrapDataKey(wdk, kid, ctx);\n if (!Buffer.isBuffer(dek) || dek.length !== 32) {\n if (Buffer.isBuffer(dek)) dek.fill(0);\n throw new Error('inner key provider returned an invalid 32-byte DEK');\n }\n if (generation !== this.generation) {\n dek.fill(0);\n throw new Error('DEK cache was cleared during unwrap');\n }\n if (this.cache.size >= this.max) {\n const oldest = this.cache.keys().next().value;\n if (oldest !== undefined) this.evict(oldest, this.cache.get(oldest));\n }\n const token = ++this.nextToken;\n const timer = setTimeout(() => {\n const entry = this.cache.get(key);\n if (entry?.token === token) this.evict(key, entry);\n }, this.ttlMs);\n timer.unref?.();\n this.cache.set(key, {\n dek,\n expiresAt: Date.now() + this.ttlMs,\n timer,\n token,\n });\n return dek;\n }\n\n clear(): void {\n for (const [key, entry] of this.cache) this.evict(key, entry);\n this.generation++;\n this.inFlight.clear();\n this.inner.clear();\n }\n\n private evict(\n key: string,\n entry?: { dek: Buffer; timer: ReturnType<typeof setTimeout> },\n ): void {\n if (entry !== undefined) clearTimeout(entry.timer);\n entry?.dek.fill(0);\n this.cache.delete(key);\n }\n}\n"]}