@sesamy/capsule-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,763 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AES_KEY_SIZE: () => AES_KEY_SIZE,
24
+ CapsuleServer: () => CapsuleServer,
25
+ CmsEncryptor: () => CmsEncryptor,
26
+ CmsServer: () => CmsServer,
27
+ DEFAULT_BUCKET_PERIOD_SECONDS: () => DEFAULT_BUCKET_PERIOD_SECONDS,
28
+ GCM_IV_SIZE: () => GCM_IV_SIZE,
29
+ GCM_TAG_LENGTH: () => GCM_TAG_LENGTH,
30
+ SubscriptionServer: () => SubscriptionServer,
31
+ TotpKeyProvider: () => TotpKeyProvider,
32
+ createApiEncryptor: () => createApiEncryptor,
33
+ createCapsule: () => createCapsule,
34
+ createCmsServer: () => createCmsServer,
35
+ createSubscriptionServer: () => createSubscriptionServer,
36
+ createTotpEncryptor: () => createTotpEncryptor,
37
+ createTotpKeyProvider: () => createTotpKeyProvider,
38
+ decryptContent: () => decryptContent,
39
+ deriveBucketKey: () => deriveBucketKey,
40
+ encryptContent: () => encryptContent,
41
+ generateDek: () => generateDek,
42
+ generateIv: () => generateIv,
43
+ generateMasterSecret: () => generateMasterSecret,
44
+ getBucketExpiration: () => getBucketExpiration,
45
+ getBucketId: () => getBucketId,
46
+ getBucketKey: () => getBucketKey,
47
+ getBucketKeys: () => getBucketKeys,
48
+ getCurrentBucket: () => getCurrentBucket,
49
+ getNextBucket: () => getNextBucket,
50
+ getPreviousBucket: () => getPreviousBucket,
51
+ hkdf: () => hkdf,
52
+ isBucketValid: () => isBucketValid,
53
+ unwrapDek: () => unwrapDek,
54
+ wrapDek: () => wrapDek
55
+ });
56
+ module.exports = __toCommonJS(index_exports);
57
+
58
+ // src/encryption.ts
59
+ var import_crypto = require("crypto");
60
+ var GCM_IV_SIZE = 12;
61
+ var GCM_TAG_LENGTH = 16;
62
+ var AES_KEY_SIZE = 32;
63
+ function generateDek() {
64
+ return (0, import_crypto.randomBytes)(AES_KEY_SIZE);
65
+ }
66
+ function generateIv() {
67
+ return (0, import_crypto.randomBytes)(GCM_IV_SIZE);
68
+ }
69
+ function encryptContent(content, dek, iv) {
70
+ const actualIv = iv ?? generateIv();
71
+ const plaintext = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
72
+ const cipher = (0, import_crypto.createCipheriv)("aes-256-gcm", dek, actualIv, {
73
+ authTagLength: GCM_TAG_LENGTH
74
+ });
75
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
76
+ const authTag = cipher.getAuthTag();
77
+ const combined = Buffer.concat([encrypted, authTag]);
78
+ return {
79
+ encryptedContent: combined,
80
+ iv: actualIv
81
+ };
82
+ }
83
+ function decryptContent(encryptedContent, dek, iv) {
84
+ const ciphertext = encryptedContent.subarray(0, -GCM_TAG_LENGTH);
85
+ const authTag = encryptedContent.subarray(-GCM_TAG_LENGTH);
86
+ const decipher = (0, import_crypto.createDecipheriv)("aes-256-gcm", dek, iv, {
87
+ authTagLength: GCM_TAG_LENGTH
88
+ });
89
+ decipher.setAuthTag(authTag);
90
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
91
+ }
92
+ function wrapDek(dek, wrappingKey) {
93
+ const iv = generateIv();
94
+ const { encryptedContent } = encryptContent(dek, wrappingKey, iv);
95
+ return Buffer.concat([iv, encryptedContent]);
96
+ }
97
+ function unwrapDek(wrappedDek, wrappingKey) {
98
+ const iv = wrappedDek.subarray(0, GCM_IV_SIZE);
99
+ const encryptedContent = wrappedDek.subarray(GCM_IV_SIZE);
100
+ return decryptContent(encryptedContent, wrappingKey, iv);
101
+ }
102
+
103
+ // src/time-buckets.ts
104
+ var import_crypto2 = require("crypto");
105
+ var DEFAULT_BUCKET_PERIOD_SECONDS = 30;
106
+ function hkdfExtract(ikm, salt) {
107
+ return (0, import_crypto2.createHmac)("sha256", salt).update(ikm).digest();
108
+ }
109
+ function hkdfExpand(prk, info, length) {
110
+ const hashLen = 32;
111
+ const n = Math.ceil(length / hashLen);
112
+ const okm = Buffer.alloc(n * hashLen);
113
+ let t = Buffer.alloc(0);
114
+ for (let i = 1; i <= n; i++) {
115
+ const hmac = (0, import_crypto2.createHmac)("sha256", prk);
116
+ hmac.update(t);
117
+ hmac.update(info);
118
+ hmac.update(Buffer.from([i]));
119
+ t = hmac.digest();
120
+ t.copy(okm, (i - 1) * hashLen);
121
+ }
122
+ return okm.subarray(0, length);
123
+ }
124
+ function hkdf(ikm, salt, info, length) {
125
+ const saltBuf = typeof salt === "string" ? Buffer.from(salt) : salt;
126
+ const infoBuf = typeof info === "string" ? Buffer.from(info) : info;
127
+ const prk = hkdfExtract(ikm, saltBuf);
128
+ return hkdfExpand(prk, infoBuf, length);
129
+ }
130
+ function getBucketId(timestampMs = Date.now(), bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
131
+ const timestampSec = Math.floor(timestampMs / 1e3);
132
+ const bucketNum = Math.floor(timestampSec / bucketPeriodSeconds);
133
+ return bucketNum.toString();
134
+ }
135
+ function getCurrentBucket(bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
136
+ return getBucketId(Date.now(), bucketPeriodSeconds);
137
+ }
138
+ function getNextBucket(bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
139
+ const current = parseInt(getCurrentBucket(bucketPeriodSeconds));
140
+ return (current + 1).toString();
141
+ }
142
+ function getPreviousBucket(bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
143
+ const current = parseInt(getCurrentBucket(bucketPeriodSeconds));
144
+ return (current - 1).toString();
145
+ }
146
+ function getBucketExpiration(bucketId, bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
147
+ const bucketNum = parseInt(bucketId);
148
+ const expiresAtMs = (bucketNum + 1) * bucketPeriodSeconds * 1e3;
149
+ return new Date(expiresAtMs);
150
+ }
151
+ function isBucketValid(bucketId, bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
152
+ const current = getCurrentBucket(bucketPeriodSeconds);
153
+ const next = getNextBucket(bucketPeriodSeconds);
154
+ const previous = getPreviousBucket(bucketPeriodSeconds);
155
+ return bucketId === current || bucketId === next || bucketId === previous;
156
+ }
157
+ function deriveBucketKey(masterSecret, keyId, bucketId) {
158
+ const info = `capsule-bucket-${keyId}`;
159
+ return hkdf(masterSecret, bucketId, info, 32);
160
+ }
161
+ function getBucketKey(masterSecret, keyId, bucketId, bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
162
+ return {
163
+ bucketId,
164
+ key: deriveBucketKey(masterSecret, keyId, bucketId),
165
+ expiresAt: getBucketExpiration(bucketId, bucketPeriodSeconds)
166
+ };
167
+ }
168
+ function getBucketKeys(masterSecret, keyId, bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
169
+ const currentBucketId = getCurrentBucket(bucketPeriodSeconds);
170
+ const nextBucketId = getNextBucket(bucketPeriodSeconds);
171
+ return {
172
+ current: getBucketKey(masterSecret, keyId, currentBucketId, bucketPeriodSeconds),
173
+ next: getBucketKey(masterSecret, keyId, nextBucketId, bucketPeriodSeconds)
174
+ };
175
+ }
176
+ function generateMasterSecret() {
177
+ return (0, import_crypto2.randomBytes)(32);
178
+ }
179
+
180
+ // src/capsule.ts
181
+ var CmsServer = class {
182
+ constructor(options) {
183
+ if (!options.getKeys) {
184
+ throw new Error("CmsServer requires a getKeys function");
185
+ }
186
+ this.getKeys = options.getKeys;
187
+ this.logger = options.logger ?? (() => {
188
+ });
189
+ }
190
+ /**
191
+ * Encrypt content with envelope encryption.
192
+ *
193
+ * The content is encrypted once with a unique DEK, then the DEK is wrapped
194
+ * with multiple key-wrapping keys (one for each keyId).
195
+ *
196
+ * @param articleId - Unique article identifier
197
+ * @param content - Plaintext content to encrypt
198
+ * @param options - Encryption options
199
+ * @returns Encrypted article data
200
+ *
201
+ * @example
202
+ * ```typescript
203
+ * const encrypted = await cms.encrypt('article-123', '<p>Premium content...</p>', {
204
+ * keyIds: ['premium', 'enterprise'],
205
+ * });
206
+ * ```
207
+ *
208
+ * Returns (format: 'json'):
209
+ * ```json
210
+ * {
211
+ * "articleId": "article-123",
212
+ * "encryptedContent": "base64...", // AES-256-GCM encrypted content
213
+ * "iv": "base64...", // 12-byte initialization vector
214
+ * "wrappedKeys": [
215
+ * {
216
+ * "keyId": "premium:1737158400",
217
+ * "wrappedDek": "base64...", // DEK wrapped with this key
218
+ * "expiresAt": "2025-01-18T01:00:00.000Z"
219
+ * },
220
+ * {
221
+ * "keyId": "premium:1737158430",
222
+ * "wrappedDek": "base64...",
223
+ * "expiresAt": "2025-01-18T01:00:30.000Z"
224
+ * }
225
+ * ]
226
+ * }
227
+ * ```
228
+ */
229
+ async encrypt(articleId, content, options) {
230
+ const {
231
+ keyIds,
232
+ format = "json",
233
+ htmlTag = "div",
234
+ htmlClass,
235
+ placeholder = "Loading encrypted content..."
236
+ } = options;
237
+ if (!keyIds || keyIds.length === 0) {
238
+ throw new Error("At least one keyId is required");
239
+ }
240
+ this.logger(
241
+ `Encrypting article: ${articleId} with keys: ${keyIds.join(", ")}`,
242
+ "info"
243
+ );
244
+ const keyEntries = await this.getKeys(keyIds);
245
+ if (keyEntries.length === 0) {
246
+ throw new Error(`No keys returned for keyIds: ${keyIds.join(", ")}`);
247
+ }
248
+ const keyConfigs = keyEntries.map((entry) => ({
249
+ keyId: entry.keyId,
250
+ key: Buffer.isBuffer(entry.key) ? entry.key : Buffer.from(entry.key, "base64"),
251
+ expiresAt: entry.expiresAt ? entry.expiresAt instanceof Date ? entry.expiresAt : new Date(entry.expiresAt) : void 0
252
+ }));
253
+ this.logger(`Got ${keyConfigs.length} keys from provider`, "info");
254
+ const dek = generateDek();
255
+ const { encryptedContent, iv } = encryptContent(content, dek);
256
+ const wrappedKeys = keyConfigs.map((config) => ({
257
+ keyId: config.keyId,
258
+ wrappedDek: wrapDek(dek, config.key).toString("base64"),
259
+ expiresAt: config.expiresAt?.toISOString()
260
+ }));
261
+ const result = {
262
+ articleId,
263
+ encryptedContent: encryptedContent.toString("base64"),
264
+ iv: iv.toString("base64"),
265
+ wrappedKeys
266
+ };
267
+ this.logger(`Encrypted with ${wrappedKeys.length} wrapped keys`, "info");
268
+ if (format === "html") {
269
+ const json = JSON.stringify(result);
270
+ const classAttr = htmlClass ? ` class="${htmlClass}"` : "";
271
+ return `<${htmlTag}${classAttr} data-capsule='${this.escapeHtml(
272
+ json
273
+ )}' data-capsule-id="${articleId}">${placeholder}</${htmlTag}>`;
274
+ }
275
+ if (format === "html-template") {
276
+ return JSON.stringify(result);
277
+ }
278
+ return result;
279
+ }
280
+ /**
281
+ * Encrypt and return data in multiple formats for templates.
282
+ *
283
+ * @returns Object with all template formats:
284
+ * - data: The EncryptedArticle object
285
+ * - json: JSON string
286
+ * - attribute: HTML-escaped JSON for data attributes
287
+ * - html: Complete HTML element
288
+ */
289
+ async encryptForTemplate(articleId, content, options) {
290
+ const data = await this.encrypt(articleId, content, {
291
+ ...options,
292
+ format: "json"
293
+ });
294
+ const json = JSON.stringify(data);
295
+ return {
296
+ data,
297
+ json,
298
+ attribute: this.escapeHtml(json),
299
+ html: await this.encrypt(articleId, content, {
300
+ ...options,
301
+ format: "html"
302
+ })
303
+ };
304
+ }
305
+ /**
306
+ * Escape HTML special characters for safe attribute embedding.
307
+ */
308
+ escapeHtml(str) {
309
+ return str.replace(/&/g, "&amp;").replace(/'/g, "&#39;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
310
+ }
311
+ };
312
+ function createCmsServer(options) {
313
+ return new CmsServer(options);
314
+ }
315
+ var TotpKeyProvider = class {
316
+ constructor(options) {
317
+ this.masterSecret = Buffer.isBuffer(options.masterSecret) ? options.masterSecret : Buffer.from(options.masterSecret, "base64");
318
+ this.bucketPeriodSeconds = options.bucketPeriodSeconds ?? DEFAULT_BUCKET_PERIOD_SECONDS;
319
+ }
320
+ /**
321
+ * Get keys for the given key IDs.
322
+ *
323
+ * For each keyId, returns two keys:
324
+ * - Current bucket key (e.g., "premium:1737158400")
325
+ * - Next bucket key (e.g., "premium:1737158430")
326
+ *
327
+ * This ensures content encrypted near a bucket boundary
328
+ * can still be decrypted after the bucket rotates.
329
+ */
330
+ async getKeys(keyIds) {
331
+ const entries = [];
332
+ for (const keyId of keyIds) {
333
+ const bucketKeys = getBucketKeys(
334
+ this.masterSecret,
335
+ keyId,
336
+ this.bucketPeriodSeconds
337
+ );
338
+ entries.push({
339
+ keyId: `${keyId}:${bucketKeys.current.bucketId}`,
340
+ key: bucketKeys.current.key,
341
+ expiresAt: bucketKeys.current.expiresAt
342
+ });
343
+ entries.push({
344
+ keyId: `${keyId}:${bucketKeys.next.bucketId}`,
345
+ key: bucketKeys.next.key,
346
+ expiresAt: bucketKeys.next.expiresAt
347
+ });
348
+ }
349
+ return entries;
350
+ }
351
+ /**
352
+ * Derive a static key for an article (no time bucket).
353
+ * Useful for per-article purchase access.
354
+ */
355
+ async getArticleKey(articleId) {
356
+ const key = deriveBucketKey(this.masterSecret, "article", articleId);
357
+ return {
358
+ keyId: `article:${articleId}`,
359
+ key
360
+ };
361
+ }
362
+ };
363
+ function createTotpKeyProvider(options) {
364
+ return new TotpKeyProvider(options);
365
+ }
366
+ var CapsuleServer = CmsServer;
367
+ var createCapsule = createCmsServer;
368
+
369
+ // src/cms.ts
370
+ var CmsEncryptor = class {
371
+ constructor(options = {}) {
372
+ this.masterSecret = options.masterSecret ? Buffer.from(options.masterSecret, "base64") : null;
373
+ this.subscriptionServerUrl = options.subscriptionServerUrl ?? null;
374
+ this.apiKey = options.apiKey ?? null;
375
+ this.bucketPeriodSeconds = options.bucketPeriodSeconds ?? DEFAULT_BUCKET_PERIOD_SECONDS;
376
+ if (!this.masterSecret && !this.subscriptionServerUrl) {
377
+ throw new Error(
378
+ "CmsEncryptor requires either masterSecret (TOTP mode) or subscriptionServerUrl (API mode)"
379
+ );
380
+ }
381
+ }
382
+ /**
383
+ * Get bucket keys for a key ID.
384
+ *
385
+ * In TOTP mode: derives from master secret locally.
386
+ * In API mode: fetches from subscription server.
387
+ */
388
+ async getBucketKeys(keyId) {
389
+ if (this.masterSecret) {
390
+ return getBucketKeys(this.masterSecret, keyId, this.bucketPeriodSeconds);
391
+ }
392
+ if (!this.subscriptionServerUrl || !this.apiKey) {
393
+ throw new Error("API mode requires subscriptionServerUrl and apiKey");
394
+ }
395
+ const response = await fetch(
396
+ `${this.subscriptionServerUrl}/api/cms/bucket-keys`,
397
+ {
398
+ method: "POST",
399
+ headers: {
400
+ "Content-Type": "application/json",
401
+ Authorization: `Bearer ${this.apiKey}`
402
+ },
403
+ body: JSON.stringify({ keyId })
404
+ }
405
+ );
406
+ if (!response.ok) {
407
+ const error = await response.text();
408
+ throw new Error(`Failed to fetch bucket keys: ${error}`);
409
+ }
410
+ const data = await response.json();
411
+ return {
412
+ current: {
413
+ bucketId: data.current.bucketId,
414
+ key: Buffer.from(data.current.key, "base64"),
415
+ expiresAt: new Date(data.current.expiresAt)
416
+ },
417
+ next: {
418
+ bucketId: data.next.bucketId,
419
+ key: Buffer.from(data.next.key, "base64"),
420
+ expiresAt: new Date(data.next.expiresAt)
421
+ }
422
+ };
423
+ }
424
+ /**
425
+ * Encrypt article content with envelope encryption.
426
+ *
427
+ * The content is encrypted once with a unique DEK, then the DEK is wrapped
428
+ * with multiple key-wrapping keys for different unlock paths.
429
+ *
430
+ * @param articleId - Unique article identifier
431
+ * @param content - Plaintext content to encrypt
432
+ * @param keyConfigs - Array of key-wrapping configurations
433
+ * @returns Encrypted article with wrapped keys
434
+ */
435
+ encryptArticle(articleId, content, keyConfigs) {
436
+ if (keyConfigs.length === 0) {
437
+ throw new Error("At least one key configuration is required");
438
+ }
439
+ const dek = generateDek();
440
+ const { encryptedContent, iv } = encryptContent(content, dek);
441
+ const wrappedKeys = keyConfigs.map((config) => ({
442
+ keyId: config.keyId,
443
+ wrappedDek: wrapDek(dek, config.key).toString("base64"),
444
+ expiresAt: config.expiresAt?.toISOString()
445
+ }));
446
+ return {
447
+ articleId,
448
+ encryptedContent: encryptedContent.toString("base64"),
449
+ iv: iv.toString("base64"),
450
+ wrappedKeys
451
+ };
452
+ }
453
+ /**
454
+ * Encrypt article with tier-based time-bucket keys.
455
+ *
456
+ * Automatically gets current and next bucket keys for the specified tier,
457
+ * plus any additional static keys (e.g., per-article keys).
458
+ *
459
+ * @param articleId - Unique article identifier
460
+ * @param content - Plaintext content to encrypt
461
+ * @param tier - Subscription tier (e.g., "premium")
462
+ * @param additionalKeys - Optional additional key configurations (e.g., per-article keys)
463
+ */
464
+ async encryptArticleWithTier(articleId, content, tier, additionalKeys = []) {
465
+ const bucketKeys = await this.getBucketKeys(tier);
466
+ const keyConfigs = [
467
+ // Current bucket key
468
+ {
469
+ keyId: `${tier}:${bucketKeys.current.bucketId}`,
470
+ key: bucketKeys.current.key,
471
+ expiresAt: bucketKeys.current.expiresAt
472
+ },
473
+ // Next bucket key (handles clock drift)
474
+ {
475
+ keyId: `${tier}:${bucketKeys.next.bucketId}`,
476
+ key: bucketKeys.next.key,
477
+ expiresAt: bucketKeys.next.expiresAt
478
+ },
479
+ // Additional keys (e.g., per-article access)
480
+ ...additionalKeys
481
+ ];
482
+ return this.encryptArticle(articleId, content, keyConfigs);
483
+ }
484
+ };
485
+ function createTotpEncryptor(masterSecret, bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
486
+ const secret = typeof masterSecret === "string" ? masterSecret : masterSecret.toString("base64");
487
+ return new CmsEncryptor({
488
+ masterSecret: secret,
489
+ bucketPeriodSeconds
490
+ });
491
+ }
492
+ function createApiEncryptor(subscriptionServerUrl, apiKey) {
493
+ return new CmsEncryptor({
494
+ subscriptionServerUrl,
495
+ apiKey
496
+ });
497
+ }
498
+
499
+ // src/subscription-server.ts
500
+ var import_crypto3 = require("crypto");
501
+ function isNumericBucketId(str) {
502
+ return /^\d+$/.test(str);
503
+ }
504
+ var SubscriptionServer = class {
505
+ constructor(options) {
506
+ this.masterSecret = Buffer.isBuffer(options.masterSecret) ? options.masterSecret : Buffer.from(options.masterSecret, "base64");
507
+ this.bucketPeriodSeconds = options.bucketPeriodSeconds ?? DEFAULT_BUCKET_PERIOD_SECONDS;
508
+ }
509
+ /**
510
+ * Get bucket keys for a key ID (for CMS).
511
+ *
512
+ * Returns current and next bucket keys so CMS can encrypt
513
+ * content that works across bucket boundaries.
514
+ */
515
+ getBucketKeysForCms(keyId) {
516
+ return getBucketKeys(this.masterSecret, keyId, this.bucketPeriodSeconds);
517
+ }
518
+ /**
519
+ * Get bucket keys formatted for API response.
520
+ */
521
+ getBucketKeysResponse(keyId) {
522
+ const keys = this.getBucketKeysForCms(keyId);
523
+ return {
524
+ current: {
525
+ bucketId: keys.current.bucketId,
526
+ key: keys.current.key.toString("base64"),
527
+ expiresAt: keys.current.expiresAt.toISOString()
528
+ },
529
+ next: {
530
+ bucketId: keys.next.bucketId,
531
+ key: keys.next.key.toString("base64"),
532
+ expiresAt: keys.next.expiresAt.toISOString()
533
+ }
534
+ };
535
+ }
536
+ /**
537
+ * Validate that a bucket ID is current or adjacent.
538
+ */
539
+ isBucketValid(bucketId) {
540
+ return isBucketValid(bucketId, this.bucketPeriodSeconds);
541
+ }
542
+ /**
543
+ * Unwrap a DEK and re-wrap it with a user's RSA public key.
544
+ *
545
+ * This is the core unlock operation:
546
+ * 1. Parse the wrapped key to extract keyId and bucket info
547
+ * 2. Derive the key-wrapping key from master secret
548
+ * 3. Unwrap the DEK
549
+ * 4. Re-wrap with user's RSA public key
550
+ *
551
+ * @param wrappedKey - The wrapped key entry from the article
552
+ * @param userPublicKeyB64 - User's RSA public key (Base64 SPKI format)
553
+ * @param staticKeyLookup - Optional function to look up static keys (for per-article keys). Can be sync or async.
554
+ */
555
+ async unlockForUser(wrappedKey, userPublicKeyB64, staticKeyLookup) {
556
+ const { keyId, wrappedDek } = wrappedKey;
557
+ const wrappedDekBuffer = Buffer.from(wrappedDek, "base64");
558
+ let keyWrappingKey;
559
+ let expiresAt;
560
+ if (staticKeyLookup) {
561
+ const staticKey = await Promise.resolve(staticKeyLookup(keyId));
562
+ if (staticKey) {
563
+ keyWrappingKey = staticKey;
564
+ const currentBucket = getCurrentBucket(this.bucketPeriodSeconds);
565
+ expiresAt = getBucketExpiration(
566
+ currentBucket,
567
+ this.bucketPeriodSeconds
568
+ );
569
+ return this.unwrapAndRewrap(
570
+ wrappedDekBuffer,
571
+ keyWrappingKey,
572
+ userPublicKeyB64,
573
+ keyId,
574
+ void 0,
575
+ expiresAt
576
+ );
577
+ }
578
+ }
579
+ const colonIndex = keyId.lastIndexOf(":");
580
+ if (colonIndex === -1) {
581
+ throw new Error(
582
+ `Invalid keyId format: ${keyId}. Expected 'tier:bucketId' or use staticKeyLookup for static keys.`
583
+ );
584
+ }
585
+ const baseKeyId = keyId.substring(0, colonIndex);
586
+ const suffix = keyId.substring(colonIndex + 1);
587
+ if (!isNumericBucketId(suffix)) {
588
+ throw new Error(
589
+ `No static key found for '${keyId}' and suffix '${suffix}' is not a valid bucket ID. Provide a staticKeyLookup function.`
590
+ );
591
+ }
592
+ const bucketId = suffix;
593
+ if (!this.isBucketValid(bucketId)) {
594
+ throw new Error(`Bucket ${bucketId} is expired or invalid`);
595
+ }
596
+ keyWrappingKey = deriveBucketKey(this.masterSecret, baseKeyId, bucketId);
597
+ expiresAt = getBucketExpiration(bucketId, this.bucketPeriodSeconds);
598
+ return this.unwrapAndRewrap(
599
+ wrappedDekBuffer,
600
+ keyWrappingKey,
601
+ userPublicKeyB64,
602
+ keyId,
603
+ bucketId,
604
+ expiresAt
605
+ );
606
+ }
607
+ /**
608
+ * Internal helper to unwrap DEK and re-wrap with user's public key.
609
+ */
610
+ async unwrapAndRewrap(wrappedDekBuffer, keyWrappingKey, userPublicKeyB64, keyId, bucketId, expiresAt) {
611
+ const dek = unwrapDek(wrappedDekBuffer, keyWrappingKey);
612
+ const publicKeyPem = this.convertToPem(userPublicKeyB64);
613
+ const pubKey = (0, import_crypto3.createPublicKey)(publicKeyPem);
614
+ const encryptedDek = (0, import_crypto3.publicEncrypt)(
615
+ {
616
+ key: pubKey,
617
+ padding: import_crypto3.constants.RSA_PKCS1_OAEP_PADDING,
618
+ oaepHash: "sha256"
619
+ },
620
+ dek
621
+ );
622
+ return {
623
+ encryptedDek: encryptedDek.toString("base64"),
624
+ keyId,
625
+ bucketId,
626
+ expiresAt: expiresAt.toISOString()
627
+ };
628
+ }
629
+ /**
630
+ * Simple unlock when you already have the key-wrapping key.
631
+ * Used when the unlock logic is separate from bucket key derivation.
632
+ */
633
+ wrapDekForUser(dek, userPublicKeyB64, keyId, expiresAt) {
634
+ const publicKeyPem = this.convertToPem(userPublicKeyB64);
635
+ const pubKey = (0, import_crypto3.createPublicKey)(publicKeyPem);
636
+ const encryptedDek = (0, import_crypto3.publicEncrypt)(
637
+ {
638
+ key: pubKey,
639
+ padding: import_crypto3.constants.RSA_PKCS1_OAEP_PADDING,
640
+ oaepHash: "sha256"
641
+ },
642
+ dek
643
+ );
644
+ let bucketId;
645
+ const colonIndex = keyId.lastIndexOf(":");
646
+ if (colonIndex !== -1) {
647
+ const suffix = keyId.substring(colonIndex + 1);
648
+ if (isNumericBucketId(suffix)) {
649
+ bucketId = suffix;
650
+ }
651
+ }
652
+ return {
653
+ encryptedDek: encryptedDek.toString("base64"),
654
+ keyId,
655
+ bucketId,
656
+ expiresAt: expiresAt.toISOString()
657
+ };
658
+ }
659
+ /**
660
+ * Get the key-wrapping key for a bucket key ID.
661
+ * Useful when you need the raw key for custom logic.
662
+ */
663
+ getBucketKey(keyId, bucketId) {
664
+ return deriveBucketKey(this.masterSecret, keyId, bucketId);
665
+ }
666
+ /**
667
+ * Get the key-wrapping key for a tier, wrapped with user's RSA public key.
668
+ *
669
+ * This enables "unlock once, access all" for tier content:
670
+ * - Client receives the AES-KW key (not the DEK)
671
+ * - Client can unwrap any article's DEK locally
672
+ * - No per-article unlock requests needed
673
+ *
674
+ * @param tier - The tier name (e.g., "premium")
675
+ * @param bucketId - The bucket ID to get the key for
676
+ * @param userPublicKeyB64 - User's RSA public key (Base64 SPKI format)
677
+ */
678
+ getTierKeyForUser(tier, bucketId, userPublicKeyB64) {
679
+ if (!this.isBucketValid(bucketId)) {
680
+ throw new Error(`Bucket ${bucketId} is expired or invalid`);
681
+ }
682
+ const keyWrappingKey = deriveBucketKey(this.masterSecret, tier, bucketId);
683
+ const expiresAt = getBucketExpiration(bucketId, this.bucketPeriodSeconds);
684
+ const publicKeyPem = this.convertToPem(userPublicKeyB64);
685
+ const pubKey = (0, import_crypto3.createPublicKey)(publicKeyPem);
686
+ const encryptedKey = (0, import_crypto3.publicEncrypt)(
687
+ {
688
+ key: pubKey,
689
+ padding: import_crypto3.constants.RSA_PKCS1_OAEP_PADDING,
690
+ oaepHash: "sha256"
691
+ },
692
+ keyWrappingKey
693
+ );
694
+ return {
695
+ encryptedDek: encryptedKey.toString("base64"),
696
+ // Actually the KEK, not DEK
697
+ keyId: `${tier}:${bucketId}`,
698
+ bucketId,
699
+ expiresAt: expiresAt.toISOString()
700
+ };
701
+ }
702
+ /**
703
+ * Convert Base64 SPKI to PEM format for Node.js crypto.
704
+ */
705
+ convertToPem(publicKeyB64) {
706
+ const keyDer = Buffer.from(publicKeyB64, "base64");
707
+ const base64Lines = [];
708
+ const base64 = keyDer.toString("base64");
709
+ for (let i = 0; i < base64.length; i += 64) {
710
+ base64Lines.push(base64.slice(i, i + 64));
711
+ }
712
+ return `-----BEGIN PUBLIC KEY-----
713
+ ${base64Lines.join(
714
+ "\n"
715
+ )}
716
+ -----END PUBLIC KEY-----`;
717
+ }
718
+ };
719
+ function createSubscriptionServer(optionsOrSecret, bucketPeriodSeconds = DEFAULT_BUCKET_PERIOD_SECONDS) {
720
+ if (typeof optionsOrSecret === "object" && !Buffer.isBuffer(optionsOrSecret)) {
721
+ return new SubscriptionServer(optionsOrSecret);
722
+ }
723
+ const secret = typeof optionsOrSecret === "string" ? optionsOrSecret : optionsOrSecret.toString("base64");
724
+ return new SubscriptionServer({
725
+ masterSecret: secret,
726
+ bucketPeriodSeconds
727
+ });
728
+ }
729
+ // Annotate the CommonJS export names for ESM import in node:
730
+ 0 && (module.exports = {
731
+ AES_KEY_SIZE,
732
+ CapsuleServer,
733
+ CmsEncryptor,
734
+ CmsServer,
735
+ DEFAULT_BUCKET_PERIOD_SECONDS,
736
+ GCM_IV_SIZE,
737
+ GCM_TAG_LENGTH,
738
+ SubscriptionServer,
739
+ TotpKeyProvider,
740
+ createApiEncryptor,
741
+ createCapsule,
742
+ createCmsServer,
743
+ createSubscriptionServer,
744
+ createTotpEncryptor,
745
+ createTotpKeyProvider,
746
+ decryptContent,
747
+ deriveBucketKey,
748
+ encryptContent,
749
+ generateDek,
750
+ generateIv,
751
+ generateMasterSecret,
752
+ getBucketExpiration,
753
+ getBucketId,
754
+ getBucketKey,
755
+ getBucketKeys,
756
+ getCurrentBucket,
757
+ getNextBucket,
758
+ getPreviousBucket,
759
+ hkdf,
760
+ isBucketValid,
761
+ unwrapDek,
762
+ wrapDek
763
+ });