@git-stunts/git-cas 1.6.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.
@@ -0,0 +1,403 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import Manifest from '../value-objects/Manifest.js';
3
+ import CasError from '../errors/CasError.js';
4
+
5
+ /**
6
+ * Domain service for Content Addressable Storage operations.
7
+ *
8
+ * Provides chunking, encryption, and integrity verification for storing
9
+ * arbitrary data in Git's object database. Extends {@link EventEmitter} to
10
+ * emit progress events during store/restore operations.
11
+ *
12
+ * @fires CasService#chunk:stored
13
+ * @fires CasService#chunk:restored
14
+ * @fires CasService#file:stored
15
+ * @fires CasService#file:restored
16
+ * @fires CasService#integrity:pass
17
+ * @fires CasService#integrity:fail
18
+ * @fires CasService#error
19
+ */
20
+ export default class CasService extends EventEmitter {
21
+ /**
22
+ * @param {Object} options
23
+ * @param {import('../../ports/GitPersistencePort.js').default} options.persistence
24
+ * @param {import('../../ports/CodecPort.js').default} options.codec
25
+ * @param {import('../../ports/CryptoPort.js').default} options.crypto
26
+ * @param {number} [options.chunkSize=262144] - 256 KiB
27
+ */
28
+ constructor({ persistence, codec, crypto, chunkSize = 256 * 1024 }) {
29
+ super();
30
+ if (chunkSize < 1024) {
31
+ throw new Error('Chunk size must be at least 1024 bytes');
32
+ }
33
+ this.persistence = persistence;
34
+ this.codec = codec;
35
+ this.crypto = crypto;
36
+ this.chunkSize = chunkSize;
37
+ }
38
+
39
+ /**
40
+ * Generates a SHA-256 hex digest for a buffer.
41
+ * @private
42
+ * @param {Buffer} buf - Data to hash.
43
+ * @returns {Promise<string>} 64-character hex digest.
44
+ */
45
+ async _sha256(buf) {
46
+ return await this.crypto.sha256(buf);
47
+ }
48
+
49
+ /**
50
+ * Stores a single buffer chunk in Git and appends its metadata to the manifest.
51
+ * @private
52
+ * @param {Buffer} buf - The chunk data to store.
53
+ * @param {Object} manifestData - Mutable manifest accumulator.
54
+ */
55
+ async _storeChunk(buf, manifestData) {
56
+ const digest = await this._sha256(buf);
57
+ const blob = await this.persistence.writeBlob(buf);
58
+ const entry = { index: manifestData.chunks.length, size: buf.length, digest, blob };
59
+ manifestData.chunks.push(entry);
60
+ manifestData.size += buf.length;
61
+ this.emit('chunk:stored', { index: entry.index, size: entry.size, digest, blob });
62
+ }
63
+
64
+ /**
65
+ * Reads an async iterable source, splits it into fixed-size chunks, and stores each in Git.
66
+ * @private
67
+ * @param {AsyncIterable<Buffer>} source - The data source to chunk.
68
+ * @param {Object} manifestData - Mutable manifest accumulator.
69
+ * @throws {CasError} STREAM_ERROR if the source stream fails.
70
+ */
71
+ async _chunkAndStore(source, manifestData) {
72
+ let buffer = Buffer.alloc(0);
73
+
74
+ try {
75
+ for await (const chunk of source) {
76
+ buffer = Buffer.concat([buffer, chunk]);
77
+ while (buffer.length >= this.chunkSize) {
78
+ await this._storeChunk(buffer.slice(0, this.chunkSize), manifestData);
79
+ buffer = buffer.slice(this.chunkSize);
80
+ }
81
+ }
82
+ } catch (err) {
83
+ if (err instanceof CasError) { throw err; }
84
+ const casErr = new CasError(
85
+ `Stream error during store: ${err.message}`,
86
+ 'STREAM_ERROR',
87
+ { chunksWritten: manifestData.chunks.length, originalError: err },
88
+ );
89
+ if (this.listenerCount('error') > 0) {
90
+ this.emit('error', { code: casErr.code, message: casErr.message });
91
+ }
92
+ throw casErr;
93
+ }
94
+
95
+ if (buffer.length > 0) {
96
+ await this._storeChunk(buffer, manifestData);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Validates that an encryption key is a 32-byte Buffer or Uint8Array.
102
+ * @private
103
+ * @param {*} key
104
+ * @throws {CasError} INVALID_KEY_TYPE if key is not a Buffer
105
+ * @throws {CasError} INVALID_KEY_LENGTH if key is not 32 bytes
106
+ */
107
+ _validateKey(key) {
108
+ if (!Buffer.isBuffer(key) && !(key instanceof Uint8Array)) {
109
+ throw new CasError(
110
+ 'Encryption key must be a Buffer or Uint8Array',
111
+ 'INVALID_KEY_TYPE',
112
+ );
113
+ }
114
+ if (key.length !== 32) {
115
+ throw new CasError(
116
+ `Encryption key must be 32 bytes, got ${key.length}`,
117
+ 'INVALID_KEY_LENGTH',
118
+ { expected: 32, actual: key.length },
119
+ );
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Encrypts a buffer using AES-256-GCM.
125
+ * @param {Object} options
126
+ * @param {Buffer} options.buffer - Plaintext data to encrypt.
127
+ * @param {Buffer} options.key - 32-byte encryption key.
128
+ * @returns {Promise<{ buf: Buffer, meta: { algorithm: string, nonce: string, tag: string, encrypted: boolean } }>}
129
+ * @throws {CasError} INVALID_KEY_TYPE | INVALID_KEY_LENGTH if the key is invalid.
130
+ */
131
+ async encrypt({ buffer, key }) {
132
+ this._validateKey(key);
133
+ return await this.crypto.encryptBuffer(buffer, key);
134
+ }
135
+
136
+ /**
137
+ * Decrypts a buffer. Returns the buffer unchanged if `meta.encrypted` is falsy.
138
+ * @param {Object} options
139
+ * @param {Buffer} options.buffer - Ciphertext to decrypt.
140
+ * @param {Buffer} options.key - 32-byte encryption key.
141
+ * @param {{ encrypted: boolean, algorithm: string, nonce: string, tag: string }} options.meta - Encryption metadata from the manifest.
142
+ * @returns {Promise<Buffer>} Decrypted plaintext.
143
+ * @throws {CasError} INTEGRITY_ERROR if authentication tag verification fails.
144
+ */
145
+ async decrypt({ buffer, key, meta }) {
146
+ if (!meta?.encrypted) {
147
+ return buffer;
148
+ }
149
+ try {
150
+ return await this.crypto.decryptBuffer(buffer, key, meta);
151
+ } catch (err) {
152
+ if (err instanceof CasError) {throw err;}
153
+ throw new CasError('Decryption failed: Integrity check error', 'INTEGRITY_ERROR', { originalError: err });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Chunks an async iterable source and stores it in Git.
159
+ *
160
+ * If `encryptionKey` is provided, the content (and manifest) will be encrypted
161
+ * using AES-256-GCM, and the `encryption` field in the manifest will be populated.
162
+ *
163
+ * @param {Object} options
164
+ * @param {AsyncIterable<Buffer>} options.source
165
+ * @param {string} options.slug
166
+ * @param {string} options.filename
167
+ * @param {Buffer} [options.encryptionKey]
168
+ * @returns {Promise<import('../value-objects/Manifest.js').default>}
169
+ */
170
+ async store({ source, slug, filename, encryptionKey }) {
171
+ if (encryptionKey) {
172
+ this._validateKey(encryptionKey);
173
+ }
174
+
175
+ const manifestData = {
176
+ slug,
177
+ filename,
178
+ size: 0,
179
+ chunks: [],
180
+ };
181
+
182
+ if (encryptionKey) {
183
+ const { encrypt, finalize } = this.crypto.createEncryptionStream(encryptionKey);
184
+ await this._chunkAndStore(encrypt(source), manifestData);
185
+ manifestData.encryption = finalize();
186
+ } else {
187
+ await this._chunkAndStore(source, manifestData);
188
+ }
189
+
190
+ const manifest = new Manifest(manifestData);
191
+ this.emit('file:stored', {
192
+ slug, size: manifest.size, chunkCount: manifest.chunks.length, encrypted: !!encryptionKey,
193
+ });
194
+ return manifest;
195
+ }
196
+
197
+ /**
198
+ * Creates a Git tree object from a manifest.
199
+ *
200
+ * The tree contains the serialized manifest file and one blob entry per chunk,
201
+ * keyed by its SHA-256 digest.
202
+ *
203
+ * @param {Object} options
204
+ * @param {import('../value-objects/Manifest.js').default} options.manifest - The file manifest.
205
+ * @returns {Promise<string>} Git OID of the created tree.
206
+ */
207
+ async createTree({ manifest }) {
208
+ const serializedManifest = this.codec.encode(manifest.toJSON());
209
+ const manifestOid = await this.persistence.writeBlob(serializedManifest);
210
+
211
+ const treeEntries = [
212
+ `100644 blob ${manifestOid}\tmanifest.${this.codec.extension}`,
213
+ ...manifest.chunks.map((c) => `100644 blob ${c.blob}\t${c.digest}`),
214
+ ];
215
+
216
+ return await this.persistence.writeTree(treeEntries);
217
+ }
218
+
219
+ /**
220
+ * Reads chunk blobs from Git and verifies their SHA-256 digests.
221
+ * @private
222
+ * @param {import('../value-objects/Chunk.js').default[]} chunks - Chunk metadata from the manifest.
223
+ * @returns {Promise<Buffer[]>} Verified chunk buffers in order.
224
+ * @throws {CasError} INTEGRITY_ERROR if any chunk digest does not match.
225
+ */
226
+ async _readAndVerifyChunks(chunks) {
227
+ const buffers = [];
228
+ for (const chunk of chunks) {
229
+ const blob = await this.persistence.readBlob(chunk.blob);
230
+ const digest = await this._sha256(blob);
231
+ if (digest !== chunk.digest) {
232
+ const err = new CasError(
233
+ `Chunk ${chunk.index} integrity check failed`,
234
+ 'INTEGRITY_ERROR',
235
+ { chunkIndex: chunk.index, expected: chunk.digest, actual: digest },
236
+ );
237
+ if (this.listenerCount('error') > 0) {
238
+ this.emit('error', { code: err.code, message: err.message });
239
+ }
240
+ throw err;
241
+ }
242
+ buffers.push(blob);
243
+ this.emit('chunk:restored', { index: chunk.index, size: blob.length, digest: chunk.digest });
244
+ }
245
+ return buffers;
246
+ }
247
+
248
+ /**
249
+ * Restores a file from its manifest by reading and reassembling chunks.
250
+ *
251
+ * If the manifest has encryption metadata, decrypts the reassembled
252
+ * ciphertext using the provided key.
253
+ *
254
+ * @param {Object} options
255
+ * @param {import('../value-objects/Manifest.js').default} options.manifest - The file manifest.
256
+ * @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted.
257
+ * @returns {Promise<{ buffer: Buffer, bytesWritten: number }>}
258
+ * @throws {CasError} MISSING_KEY if manifest is encrypted but no key is provided.
259
+ * @throws {CasError} INTEGRITY_ERROR if chunk verification or decryption fails.
260
+ */
261
+ async restore({ manifest, encryptionKey }) {
262
+ if (encryptionKey) {
263
+ this._validateKey(encryptionKey);
264
+ }
265
+
266
+ if (manifest.encryption?.encrypted && !encryptionKey) {
267
+ throw new CasError(
268
+ 'Encryption key required to restore encrypted content',
269
+ 'MISSING_KEY',
270
+ );
271
+ }
272
+
273
+ if (manifest.chunks.length === 0) {
274
+ return { buffer: Buffer.alloc(0), bytesWritten: 0 };
275
+ }
276
+
277
+ const chunks = await this._readAndVerifyChunks(manifest.chunks);
278
+ let buffer = Buffer.concat(chunks);
279
+
280
+ if (manifest.encryption?.encrypted) {
281
+ buffer = await this.decrypt({
282
+ buffer,
283
+ key: encryptionKey,
284
+ meta: manifest.encryption,
285
+ });
286
+ }
287
+
288
+ this.emit('file:restored', {
289
+ slug: manifest.slug, size: buffer.length, chunkCount: manifest.chunks.length,
290
+ });
291
+ return { buffer, bytesWritten: buffer.length };
292
+ }
293
+
294
+ /**
295
+ * Reads a manifest from a Git tree OID.
296
+ *
297
+ * @param {Object} options
298
+ * @param {string} options.treeOid - Git tree OID to read the manifest from
299
+ * @returns {Promise<import('../value-objects/Manifest.js').default>}
300
+ * @throws {CasError} MANIFEST_NOT_FOUND if no manifest entry exists in the tree
301
+ * @throws {CasError} GIT_ERROR if the underlying Git command fails
302
+ */
303
+ async readManifest({ treeOid }) {
304
+ let entries;
305
+ try {
306
+ entries = await this.persistence.readTree(treeOid);
307
+ } catch (err) {
308
+ if (err instanceof CasError) { throw err; }
309
+ throw new CasError(
310
+ `Failed to read tree ${treeOid}: ${err.message}`,
311
+ 'GIT_ERROR',
312
+ { treeOid, originalError: err },
313
+ );
314
+ }
315
+
316
+ const manifestName = `manifest.${this.codec.extension}`;
317
+ const manifestEntry = entries.find((e) => e.name === manifestName);
318
+
319
+ if (!manifestEntry) {
320
+ throw new CasError(
321
+ `No manifest entry (${manifestName}) found in tree ${treeOid}`,
322
+ 'MANIFEST_NOT_FOUND',
323
+ { treeOid, expectedName: manifestName },
324
+ );
325
+ }
326
+
327
+ let blob;
328
+ try {
329
+ blob = await this.persistence.readBlob(manifestEntry.oid);
330
+ } catch (err) {
331
+ if (err instanceof CasError) { throw err; }
332
+ throw new CasError(
333
+ `Failed to read manifest blob ${manifestEntry.oid}: ${err.message}`,
334
+ 'GIT_ERROR',
335
+ { treeOid, manifestOid: manifestEntry.oid, originalError: err },
336
+ );
337
+ }
338
+
339
+ const decoded = this.codec.decode(blob);
340
+ return new Manifest(decoded);
341
+ }
342
+
343
+ /**
344
+ * Returns deletion metadata for an asset stored in a Git tree.
345
+ * Does not perform any destructive Git operations.
346
+ *
347
+ * @param {Object} options
348
+ * @param {string} options.treeOid - Git tree OID of the asset
349
+ * @returns {Promise<{ chunksOrphaned: number, slug: string }>}
350
+ * @throws {CasError} MANIFEST_NOT_FOUND if the tree has no manifest
351
+ */
352
+ async deleteAsset({ treeOid }) {
353
+ const manifest = await this.readManifest({ treeOid });
354
+ return {
355
+ slug: manifest.slug,
356
+ chunksOrphaned: manifest.chunks.length,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Aggregates referenced chunk blob OIDs across multiple stored assets.
362
+ * Analysis only — does not delete or modify anything.
363
+ *
364
+ * @param {Object} options
365
+ * @param {string[]} options.treeOids - Git tree OIDs to analyze
366
+ * @returns {Promise<{ referenced: Set<string>, total: number }>}
367
+ * @throws {CasError} MANIFEST_NOT_FOUND if any treeOid lacks a manifest
368
+ */
369
+ async findOrphanedChunks({ treeOids }) {
370
+ const referenced = new Set();
371
+ let total = 0;
372
+
373
+ for (const treeOid of treeOids) {
374
+ const manifest = await this.readManifest({ treeOid });
375
+ for (const chunk of manifest.chunks) {
376
+ referenced.add(chunk.blob);
377
+ total += 1;
378
+ }
379
+ }
380
+
381
+ return { referenced, total };
382
+ }
383
+
384
+ /**
385
+ * Verifies the integrity of a stored file by re-hashing its chunks.
386
+ * @param {import('../value-objects/Manifest.js').default} manifest
387
+ * @returns {Promise<boolean>}
388
+ */
389
+ async verifyIntegrity(manifest) {
390
+ for (const chunk of manifest.chunks) {
391
+ const blob = await this.persistence.readBlob(chunk.blob);
392
+ const digest = await this._sha256(blob);
393
+ if (digest !== chunk.digest) {
394
+ this.emit('integrity:fail', {
395
+ slug: manifest.slug, chunkIndex: chunk.index, expected: chunk.digest, actual: digest,
396
+ });
397
+ return false;
398
+ }
399
+ }
400
+ this.emit('integrity:pass', { slug: manifest.slug });
401
+ return true;
402
+ }
403
+ }
@@ -0,0 +1,36 @@
1
+ import { ChunkSchema } from '../schemas/ManifestSchema.js';
2
+ import { ZodError } from 'zod';
3
+
4
+ /**
5
+ * Immutable value object representing a single content chunk.
6
+ *
7
+ * Validated against {@link ChunkSchema} on construction. Properties are
8
+ * assigned via `Object.assign` and the instance is frozen.
9
+ *
10
+ * @property {number} index - Zero-based position within the manifest.
11
+ * @property {number} size - Chunk size in bytes.
12
+ * @property {string} digest - 64-character SHA-256 hex digest of the chunk data.
13
+ * @property {string} blob - Git OID of the stored blob.
14
+ */
15
+ export default class Chunk {
16
+ /**
17
+ * @param {Object} data - Raw chunk data (validated via Zod).
18
+ * @param {number} data.index - Zero-based chunk index.
19
+ * @param {number} data.size - Chunk size in bytes.
20
+ * @param {string} data.digest - SHA-256 hex digest.
21
+ * @param {string} data.blob - Git blob OID.
22
+ * @throws {Error} If data fails schema validation.
23
+ */
24
+ constructor(data) {
25
+ try {
26
+ ChunkSchema.parse(data);
27
+ Object.assign(this, data);
28
+ Object.freeze(this);
29
+ } catch (error) {
30
+ if (error instanceof ZodError) {
31
+ throw new Error(`Invalid chunk data: ${error.issues.map((i) => i.message).join(', ')}`);
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,52 @@
1
+ import { ManifestSchema } from '../schemas/ManifestSchema.js';
2
+ import Chunk from './Chunk.js';
3
+ import { ZodError } from 'zod';
4
+
5
+ /**
6
+ * Immutable value object representing a file manifest.
7
+ *
8
+ * Validated against {@link ManifestSchema} on construction. Contains the slug,
9
+ * filename, total size, an ordered array of {@link Chunk} objects, and optional
10
+ * encryption metadata.
11
+ */
12
+ export default class Manifest {
13
+ /**
14
+ * @param {Object} data - Raw manifest data (validated via Zod).
15
+ * @param {string} data.slug - Logical identifier for the stored asset.
16
+ * @param {string} data.filename - Original filename.
17
+ * @param {number} data.size - Total size in bytes.
18
+ * @param {Array<{ index: number, size: number, digest: string, blob: string }>} data.chunks - Chunk metadata.
19
+ * @param {{ algorithm: string, nonce: string, tag: string, encrypted: boolean }} [data.encryption] - Encryption metadata.
20
+ * @throws {Error} If data fails schema validation.
21
+ */
22
+ constructor(data) {
23
+ try {
24
+ ManifestSchema.parse(data);
25
+ this.slug = data.slug;
26
+ this.filename = data.filename;
27
+ this.size = data.size;
28
+ this.chunks = data.chunks.map((c) => new Chunk(c));
29
+ this.encryption = data.encryption ? { ...data.encryption } : undefined;
30
+ Object.freeze(this);
31
+ } catch (error) {
32
+ if (error instanceof ZodError) {
33
+ throw new Error(`Invalid manifest data: ${error.issues.map((i) => i.message).join(', ')}`);
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Serializes the manifest to a plain object suitable for JSON/CBOR encoding.
41
+ * @returns {{ slug: string, filename: string, size: number, chunks: Array, encryption?: Object }}
42
+ */
43
+ toJSON() {
44
+ return {
45
+ slug: this.slug,
46
+ filename: this.filename,
47
+ size: this.size,
48
+ chunks: this.chunks,
49
+ encryption: this.encryption,
50
+ };
51
+ }
52
+ }
@@ -0,0 +1,120 @@
1
+ import { CryptoHasher } from 'bun';
2
+ import CryptoPort from '../../ports/CryptoPort.js';
3
+ import CasError from '../../domain/errors/CasError.js';
4
+ // We still use node:crypto for AES-GCM because Bun's native implementation
5
+ // is heavily optimized for these specific Node APIs.
6
+ import { createCipheriv, createDecipheriv } from 'node:crypto';
7
+
8
+ /**
9
+ * Bun-native {@link CryptoPort} implementation.
10
+ *
11
+ * Uses `Bun.CryptoHasher` for fast SHA-256 hashing, `globalThis.crypto`
12
+ * for random bytes, and Node's `createCipheriv`/`createDecipheriv` for
13
+ * AES-256-GCM (Bun's Node compat layer is heavily optimized for these APIs).
14
+ */
15
+ export default class BunCryptoAdapter extends CryptoPort {
16
+ /** @override */
17
+ async sha256(buf) {
18
+ return new CryptoHasher('sha256').update(buf).digest('hex');
19
+ }
20
+
21
+ /** @override */
22
+ randomBytes(n) {
23
+ const uint8 = globalThis.crypto.getRandomValues(new Uint8Array(n));
24
+ return Buffer.from(uint8.buffer, uint8.byteOffset, uint8.byteLength);
25
+ }
26
+
27
+ /** @override */
28
+ async encryptBuffer(buffer, key) {
29
+ this.#validateKey(key);
30
+ const nonce = this.randomBytes(12);
31
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
32
+ const enc = Buffer.concat([cipher.update(buffer), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ return {
35
+ buf: enc,
36
+ meta: this.#buildMeta(nonce, tag),
37
+ };
38
+ }
39
+
40
+ /** @override */
41
+ async decryptBuffer(buffer, key, meta) {
42
+ this.#validateKey(key);
43
+ const nonce = Buffer.from(meta.nonce, 'base64');
44
+ const tag = Buffer.from(meta.tag, 'base64');
45
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce);
46
+ decipher.setAuthTag(tag);
47
+ return Buffer.concat([decipher.update(buffer), decipher.final()]);
48
+ }
49
+
50
+ /** @override */
51
+ createEncryptionStream(key) {
52
+ this.#validateKey(key);
53
+ const nonce = this.randomBytes(12);
54
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
55
+ let streamFinalized = false;
56
+
57
+ const encrypt = async function* (source) {
58
+ for await (const chunk of source) {
59
+ const encrypted = cipher.update(chunk);
60
+ if (encrypted.length > 0) {
61
+ yield encrypted;
62
+ }
63
+ }
64
+ const final = cipher.final();
65
+ if (final.length > 0) {
66
+ yield final;
67
+ }
68
+ streamFinalized = true;
69
+ };
70
+
71
+ const finalize = () => {
72
+ if (!streamFinalized) {
73
+ throw new CasError(
74
+ 'Cannot finalize before the encrypt stream is fully consumed',
75
+ 'STREAM_NOT_CONSUMED',
76
+ );
77
+ }
78
+ const tag = cipher.getAuthTag();
79
+ return this.#buildMeta(nonce, tag);
80
+ };
81
+
82
+ return { encrypt, finalize };
83
+ }
84
+
85
+ /**
86
+ * Validates that a key is a 32-byte Buffer or Uint8Array.
87
+ * @param {Buffer|Uint8Array} key
88
+ * @throws {CasError} INVALID_KEY_TYPE | INVALID_KEY_LENGTH
89
+ */
90
+ #validateKey(key) {
91
+ if (!Buffer.isBuffer(key) && !(key instanceof Uint8Array)) {
92
+ throw new CasError(
93
+ 'Encryption key must be a Buffer or Uint8Array',
94
+ 'INVALID_KEY_TYPE',
95
+ );
96
+ }
97
+ if (key.length !== 32) {
98
+ throw new CasError(
99
+ `Encryption key must be 32 bytes, got ${key.length}`,
100
+ 'INVALID_KEY_LENGTH',
101
+ { expected: 32, actual: key.length },
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Builds the encryption metadata object.
108
+ * @param {Buffer|Uint8Array} nonce - 12-byte AES-GCM nonce.
109
+ * @param {Buffer} tag - 16-byte GCM authentication tag.
110
+ * @returns {{ algorithm: string, nonce: string, tag: string, encrypted: boolean }}
111
+ */
112
+ #buildMeta(nonce, tag) {
113
+ return {
114
+ algorithm: 'aes-256-gcm',
115
+ nonce: Buffer.from(nonce).toString('base64'),
116
+ tag: tag.toString('base64'),
117
+ encrypted: true,
118
+ };
119
+ }
120
+ }