@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Capsule
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# @sesamy/capsule-server
|
|
2
|
+
|
|
3
|
+
Server-side encryption library for Capsule - provides envelope encryption for content and subscription server utilities.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @sesamy/capsule-server
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @sesamy/capsule-server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
The CMS server just needs a way to get keys - it doesn't care about tiers or how keys are derived.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import {
|
|
19
|
+
createCmsServer,
|
|
20
|
+
createTotpKeyProvider,
|
|
21
|
+
createSubscriptionServer,
|
|
22
|
+
} from "@sesamy/capsule-server";
|
|
23
|
+
|
|
24
|
+
// Create a TOTP key provider (derives keys from master secret)
|
|
25
|
+
const totp = createTotpKeyProvider({
|
|
26
|
+
masterSecret: process.env.MASTER_SECRET, // Base64-encoded 256-bit secret
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// CMS side: encrypt content
|
|
30
|
+
const cms = createCmsServer({
|
|
31
|
+
getKeys: (keyIds) => totp.getKeys(keyIds),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const encrypted = await cms.encrypt("article-123", premiumContent, {
|
|
35
|
+
keyIds: ["premium", "enterprise"], // Just key IDs - CMS doesn't know what they mean
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Subscription side: handle unlock requests
|
|
39
|
+
const server = createSubscriptionServer({
|
|
40
|
+
masterSecret: process.env.MASTER_SECRET,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// In your unlock endpoint
|
|
44
|
+
const result = await server.unlockForUser(wrappedKey, publicKey);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## CMS Server
|
|
48
|
+
|
|
49
|
+
The CMS server encrypts content with envelope encryption. It doesn't know or care about subscription tiers - it just works with key IDs and calls your `getKeys` function to get the actual keys.
|
|
50
|
+
|
|
51
|
+
### Creating the Server
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { createCmsServer } from "@sesamy/capsule-server";
|
|
55
|
+
|
|
56
|
+
// Option 1: Fetch keys from subscription server
|
|
57
|
+
const cms = createCmsServer({
|
|
58
|
+
getKeys: async (keyIds) => {
|
|
59
|
+
const response = await fetch("/api/keys", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: JSON.stringify({ keyIds }),
|
|
62
|
+
});
|
|
63
|
+
return response.json();
|
|
64
|
+
// Returns: [{ keyId: 'premium:123', key: 'base64...', expiresAt?: '...' }]
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Option 2: Use TOTP key provider (derive keys locally)
|
|
69
|
+
const totp = createTotpKeyProvider({ masterSecret: process.env.MASTER_SECRET });
|
|
70
|
+
const cms = createCmsServer({
|
|
71
|
+
getKeys: (keyIds) => totp.getKeys(keyIds),
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Encrypting Content
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const encrypted = await cms.encrypt("article-123", content, {
|
|
79
|
+
keyIds: ["premium", "enterprise"], // Key IDs to encrypt with
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Returns (JSON format):**
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"articleId": "article-123",
|
|
88
|
+
"encryptedContent": "base64...", // AES-256-GCM encrypted content
|
|
89
|
+
"iv": "base64...", // 12-byte initialization vector
|
|
90
|
+
"wrappedKeys": [
|
|
91
|
+
{
|
|
92
|
+
"keyId": "premium:1737158400",
|
|
93
|
+
"wrappedDek": "base64...", // DEK wrapped with this key
|
|
94
|
+
"expiresAt": "2025-01-18T01:00:00.000Z"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"keyId": "premium:1737158430",
|
|
98
|
+
"wrappedDek": "base64...",
|
|
99
|
+
"expiresAt": "2025-01-18T01:00:30.000Z"
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Output Formats
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// JSON (default) - for API responses
|
|
109
|
+
const data = await cms.encrypt(id, content, { keyIds: ["premium"] });
|
|
110
|
+
|
|
111
|
+
// HTML - ready to embed in your page
|
|
112
|
+
const html = await cms.encrypt(id, content, {
|
|
113
|
+
keyIds: ["premium"],
|
|
114
|
+
format: "html",
|
|
115
|
+
htmlClass: "premium-content",
|
|
116
|
+
placeholder: "<p>Subscribe to unlock...</p>",
|
|
117
|
+
});
|
|
118
|
+
// Result: <div class="premium-content" data-capsule='{"articleId":...}' data-capsule-id="article-123">
|
|
119
|
+
// <p>Subscribe to unlock...</p>
|
|
120
|
+
// </div>
|
|
121
|
+
|
|
122
|
+
// Template helper - get all formats at once
|
|
123
|
+
const { data, json, attribute, html } = await cms.encryptForTemplate(
|
|
124
|
+
id,
|
|
125
|
+
content,
|
|
126
|
+
{
|
|
127
|
+
keyIds: ["premium"],
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## TOTP Key Provider
|
|
133
|
+
|
|
134
|
+
For deriving time-bucket keys locally from a shared master secret:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { createTotpKeyProvider } from "@sesamy/capsule-server";
|
|
138
|
+
|
|
139
|
+
const totp = createTotpKeyProvider({
|
|
140
|
+
masterSecret: process.env.MASTER_SECRET,
|
|
141
|
+
bucketPeriodSeconds: 30, // Optional, default 30
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Get keys for given IDs (returns current + next bucket for each)
|
|
145
|
+
const keys = await totp.getKeys(["premium", "enterprise"]);
|
|
146
|
+
// Returns: [
|
|
147
|
+
// { keyId: 'premium:1737158400', key: Buffer, expiresAt: Date },
|
|
148
|
+
// { keyId: 'premium:1737158430', key: Buffer, expiresAt: Date },
|
|
149
|
+
// { keyId: 'enterprise:1737158400', key: Buffer, expiresAt: Date },
|
|
150
|
+
// { keyId: 'enterprise:1737158430', key: Buffer, expiresAt: Date },
|
|
151
|
+
// ]
|
|
152
|
+
|
|
153
|
+
// For per-article purchase keys (static, no expiration)
|
|
154
|
+
const articleKey = await totp.getArticleKey("article-123");
|
|
155
|
+
// Returns: { keyId: 'article:article-123', key: Buffer }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Combining with Article Keys
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
const cms = createCmsServer({
|
|
162
|
+
getKeys: async (keyIds) => {
|
|
163
|
+
const keys = await totp.getKeys(
|
|
164
|
+
keyIds.filter((id) => !id.startsWith("article:"))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Add article keys if requested
|
|
168
|
+
for (const id of keyIds.filter((id) => id.startsWith("article:"))) {
|
|
169
|
+
const articleId = id.slice(8);
|
|
170
|
+
keys.push(await totp.getArticleKey(articleId));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return keys;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Now you can mix time-bucket and article keys
|
|
178
|
+
await cms.encrypt("article-123", content, {
|
|
179
|
+
keyIds: ["premium", "article:article-123"],
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Subscription Server
|
|
184
|
+
|
|
185
|
+
Handles unlock requests from users.
|
|
186
|
+
|
|
187
|
+
### Creating the Server
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import { createSubscriptionServer } from "@sesamy/capsule-server";
|
|
191
|
+
|
|
192
|
+
const server = createSubscriptionServer({
|
|
193
|
+
masterSecret: process.env.MASTER_SECRET,
|
|
194
|
+
bucketPeriodSeconds: 30,
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Unlock Endpoint
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
app.post("/api/unlock", async (req) => {
|
|
202
|
+
// Validate user subscription here!
|
|
203
|
+
const { keyId, wrappedDek, publicKey } = req.body;
|
|
204
|
+
|
|
205
|
+
return server.unlockForUser(
|
|
206
|
+
{ keyId, wrappedDek },
|
|
207
|
+
publicKey,
|
|
208
|
+
// Optional: lookup for static keys (per-article purchase)
|
|
209
|
+
(keyId) => staticKeyStore.get(keyId)
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## How It Works
|
|
215
|
+
|
|
216
|
+
### Envelope Encryption
|
|
217
|
+
|
|
218
|
+
Capsule uses envelope encryption for efficient multi-recipient encryption:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
Content → [AES-256-GCM] → Encrypted Content
|
|
222
|
+
↓
|
|
223
|
+
DEK (unique per article)
|
|
224
|
+
↓
|
|
225
|
+
┌─────────┼─────────┐
|
|
226
|
+
↓ ↓ ↓
|
|
227
|
+
Key #1 Key #2 Key #3
|
|
228
|
+
↓ ↓ ↓
|
|
229
|
+
Wrapped Wrapped Wrapped
|
|
230
|
+
DEK #1 DEK #2 DEK #3
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- Content is encrypted ONCE with a unique DEK (Data Encryption Key)
|
|
234
|
+
- The DEK is wrapped with MULTIPLE key-wrapping keys
|
|
235
|
+
- Different users can unlock using different wrapped keys
|
|
236
|
+
- No need to re-encrypt content when adding access paths
|
|
237
|
+
|
|
238
|
+
### Time-Bucket Keys (TOTP)
|
|
239
|
+
|
|
240
|
+
When using `TotpKeyProvider`, keys rotate automatically:
|
|
241
|
+
|
|
242
|
+
- Keys are derived from `masterSecret + keyId + bucketId` using HKDF
|
|
243
|
+
- Bucket ID changes every `bucketPeriodSeconds` (default: 30s)
|
|
244
|
+
- Provider returns current AND next bucket (handles clock drift)
|
|
245
|
+
- When bucket expires, old wrapped keys become invalid (forward secrecy)
|
|
246
|
+
|
|
247
|
+
## Framework Examples
|
|
248
|
+
|
|
249
|
+
### Next.js
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// lib/capsule.ts
|
|
253
|
+
import { createCmsServer, createTotpKeyProvider } from "@sesamy/capsule-server";
|
|
254
|
+
|
|
255
|
+
const totp = createTotpKeyProvider({
|
|
256
|
+
masterSecret: process.env.MASTER_SECRET!,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
export const cms = createCmsServer({
|
|
260
|
+
getKeys: (keyIds) => totp.getKeys(keyIds),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// app/article/[slug]/page.tsx
|
|
264
|
+
export default async function ArticlePage({ params }) {
|
|
265
|
+
const article = await getArticle(params.slug);
|
|
266
|
+
|
|
267
|
+
const encryptedHtml = await cms.encrypt(article.id, article.premiumContent, {
|
|
268
|
+
keyIds: ["premium"],
|
|
269
|
+
format: "html",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<article>
|
|
274
|
+
<h1>{article.title}</h1>
|
|
275
|
+
<div>{article.preview}</div>
|
|
276
|
+
<div dangerouslySetInnerHTML={{ __html: encryptedHtml }} />
|
|
277
|
+
</article>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Astro
|
|
283
|
+
|
|
284
|
+
```astro
|
|
285
|
+
---
|
|
286
|
+
// src/pages/article/[slug].astro
|
|
287
|
+
import { createCmsServer, createTotpKeyProvider } from '@sesamy/capsule-server';
|
|
288
|
+
|
|
289
|
+
const totp = createTotpKeyProvider({
|
|
290
|
+
masterSecret: import.meta.env.MASTER_SECRET,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const cms = createCmsServer({
|
|
294
|
+
getKeys: (keyIds) => totp.getKeys(keyIds),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const article = await getArticle(Astro.params.slug);
|
|
298
|
+
const { attribute } = await cms.encryptForTemplate(
|
|
299
|
+
article.id,
|
|
300
|
+
article.premiumContent,
|
|
301
|
+
{ keyIds: ['premium'] }
|
|
302
|
+
);
|
|
303
|
+
---
|
|
304
|
+
<article>
|
|
305
|
+
<h1>{article.title}</h1>
|
|
306
|
+
<div set:html={article.preview} />
|
|
307
|
+
<div
|
|
308
|
+
data-capsule={attribute}
|
|
309
|
+
data-capsule-id={article.id}
|
|
310
|
+
>
|
|
311
|
+
<p>Subscribe to unlock...</p>
|
|
312
|
+
</div>
|
|
313
|
+
</article>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Express
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
import express from "express";
|
|
320
|
+
import {
|
|
321
|
+
createCmsServer,
|
|
322
|
+
createTotpKeyProvider,
|
|
323
|
+
createSubscriptionServer,
|
|
324
|
+
} from "@sesamy/capsule-server";
|
|
325
|
+
|
|
326
|
+
const app = express();
|
|
327
|
+
|
|
328
|
+
const totp = createTotpKeyProvider({
|
|
329
|
+
masterSecret: process.env.MASTER_SECRET!,
|
|
330
|
+
});
|
|
331
|
+
const cms = createCmsServer({ getKeys: (keyIds) => totp.getKeys(keyIds) });
|
|
332
|
+
const server = createSubscriptionServer({
|
|
333
|
+
masterSecret: process.env.MASTER_SECRET!,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Encrypt content
|
|
337
|
+
app.get("/api/article/:id", async (req, res) => {
|
|
338
|
+
const article = await db.getArticle(req.params.id);
|
|
339
|
+
const encrypted = await cms.encrypt(article.id, article.content, {
|
|
340
|
+
keyIds: ["premium"],
|
|
341
|
+
});
|
|
342
|
+
res.json({ ...article, encrypted });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Unlock endpoint
|
|
346
|
+
app.post("/api/unlock", async (req, res) => {
|
|
347
|
+
// Validate user subscription first!
|
|
348
|
+
const { keyId, wrappedDek, publicKey } = req.body;
|
|
349
|
+
const result = await server.unlockForUser({ keyId, wrappedDek }, publicKey);
|
|
350
|
+
res.json(result);
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Security Notes
|
|
355
|
+
|
|
356
|
+
- **Master secret**: Store in KMS (AWS Secrets Manager, HashiCorp Vault, etc.)
|
|
357
|
+
- **Bucket period**: Determines maximum revocation delay (shorter = faster revocation, more wrapped keys)
|
|
358
|
+
- **Per-article keys**: Are static (no automatic revocation) - use for permanent purchases
|
|
359
|
+
- **Key isolation**: CMS only needs key IDs, not the master secret (if using external key provider)
|
|
360
|
+
- **User validation**: Always validate subscription before calling `unlockForUser()`
|
|
361
|
+
|
|
362
|
+
## API Reference
|
|
363
|
+
|
|
364
|
+
### CmsServer
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
import { createCmsServer, CmsServer } from '@sesamy/capsule-server';
|
|
368
|
+
|
|
369
|
+
const cms = createCmsServer(options: CmsServerOptions);
|
|
370
|
+
|
|
371
|
+
interface CmsServerOptions {
|
|
372
|
+
getKeys: (keyIds: string[]) => Promise<KeyEntry[]>; // Required
|
|
373
|
+
logger?: (msg: string, level: 'info' | 'warn' | 'error') => void;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface KeyEntry {
|
|
377
|
+
keyId: string; // Key identifier
|
|
378
|
+
key: Buffer | string; // 256-bit AES key
|
|
379
|
+
expiresAt?: Date | string; // Optional expiration
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Encrypt content
|
|
383
|
+
cms.encrypt(articleId, content, { keyIds, format?, ... }): Promise<EncryptedArticle | string>;
|
|
384
|
+
|
|
385
|
+
// Get all formats for templates
|
|
386
|
+
cms.encryptForTemplate(articleId, content, { keyIds }): Promise<{ data, json, attribute, html }>;
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### TotpKeyProvider
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import { createTotpKeyProvider, TotpKeyProvider } from '@sesamy/capsule-server';
|
|
393
|
+
|
|
394
|
+
const totp = createTotpKeyProvider(options: TotpKeyProviderOptions);
|
|
395
|
+
|
|
396
|
+
interface TotpKeyProviderOptions {
|
|
397
|
+
masterSecret: Buffer | string; // Required
|
|
398
|
+
bucketPeriodSeconds?: number; // Default: 30
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Get time-bucket keys (current + next for each keyId)
|
|
402
|
+
totp.getKeys(keyIds: string[]): Promise<KeyEntry[]>;
|
|
403
|
+
|
|
404
|
+
// Get static article key
|
|
405
|
+
totp.getArticleKey(articleId: string): Promise<KeyEntry>;
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### SubscriptionServer
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
import { createSubscriptionServer, SubscriptionServer } from '@sesamy/capsule-server';
|
|
412
|
+
|
|
413
|
+
const server = createSubscriptionServer(options: SubscriptionServerOptions);
|
|
414
|
+
|
|
415
|
+
interface SubscriptionServerOptions {
|
|
416
|
+
masterSecret: string | Buffer; // Required
|
|
417
|
+
bucketPeriodSeconds?: number; // Default: 30
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// For CMS key fetching (if not using TOTP locally)
|
|
421
|
+
server.getBucketKeysResponse(keyId: string): BucketKeysResponse;
|
|
422
|
+
|
|
423
|
+
// For user unlock
|
|
424
|
+
server.unlockForUser(
|
|
425
|
+
wrappedKey: { keyId, wrappedDek },
|
|
426
|
+
userPublicKey: string,
|
|
427
|
+
staticKeyLookup?: (keyId: string) => Buffer | null
|
|
428
|
+
): Promise<UnlockResponse>;
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
See [TypeScript definitions](./src/types.ts) for full type documentation.
|