@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/LICENSE +21 -0
- package/README.md +431 -0
- package/dist/index.d.mts +729 -0
- package/dist/index.d.ts +729 -0
- package/dist/index.js +763 -0
- package/dist/index.mjs +705 -0
- package/package.json +58 -0
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, "&").replace(/'/g, "'").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
});
|