sesamy / capsule-publisher
PHP port of the Capsule (DCA) CMS publisher. Encrypts content, derives wrap keys, wraps for issuers (ECDH-P256 / RSA-OAEP), and signs ES256 resource JWTs.
Package info
Language:TypeScript
pkg:composer/sesamy/capsule-publisher
Requires
- php: >=8.1
- ext-hash: *
- ext-json: *
- ext-openssl: *
- phpseclib/phpseclib: ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-05-04 07:45:24 UTC
README
An open standard for secure article encryption using envelope encryption (RSA-OAEP + AES-256-GCM) via the Web Crypto API.
Overview
Capsule provides a complete solution for encrypting and decrypting premium content using envelope encryption:
- Server-side: Encrypts articles using AES-256-GCM with unique Data Encryption Keys (DEKs), then wraps DEKs with tier-level time-bucket keys
- Client-side: Fetches the tier key once, then locally unwraps every article's DEK — no per-article server round-trips
Two Key Modes
| Mode | What the client receives | Per-article network? | Use case |
|---|---|---|---|
| Tier Key (KEK) | Tier's key-wrapping key (keyType: "kek") |
No — one call, then all articles in that tier decrypt locally | Subscribers with tier access |
| Per-article DEK | Individual article's DEK (keyType: "dek") |
Yes, every article | Single-article purchase, share links |
Three Unlock Flows
Capsule supports three ways to unlock content:
| Flow | Use Case | User Auth Required | Key Mode |
|---|---|---|---|
| Subscription Flow | Logged-in subscribers unlock content | ✅ Yes | Tier Key (KEK) |
| Share Link Flow | Anyone with a link can unlock | ❌ No | Per-article DEK |
| Single Purchase | One-time article purchase | ✅ Yes | Per-article DEK |
Share Links (Pre-signed Tokens)
Publishers can generate shareable links that unlock content without requiring user authentication. Perfect for:
- 📱 Social Media - Share articles on Facebook, Twitter, LinkedIn
- 📧 Email Campaigns - Direct article access in newsletters
- 🎁 Gift Articles - "Send this article to a friend"
- ⏰ Promotions - Time-limited free access
How Share Links Work
┌─────────────────────────────────────────────────────────────────────────────┐
│ SHARE LINK FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. PUBLISHER GENERATES TOKEN │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ POST /api/share { tier: "premium", expiresIn: "7d" } │ │
│ │ ↓ │ │
│ │ Token: eyJhbGc... (signed with server secret) │ │
│ │ URL: https://example.com/article/xyz?token=eyJhbGc... │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. READER CLICKS LINK (no login required) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Browser loads page with encrypted content │ │
│ │ Client generates ephemeral RSA key pair │ │
│ │ Client extracts token from URL │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. CLIENT REQUESTS UNLOCK │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ POST /api/unlock { │ │
│ │ token: "eyJhbGc...", // Proves access │ │
│ │ wrappedDek: "...", // From encrypted article │ │
│ │ publicKey: "..." // Client's ephemeral key │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. SERVER VALIDATES & UNLOCKS │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ✓ Validate token signature │ │
│ │ ✓ Check expiration │ │
│ │ ✓ Log unlock for analytics (article, token, timestamp, IP) │ │
│ │ → Derive KEK from tier + bucket │ │
│ │ → Unwrap DEK, re-wrap for client's public key │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 5. CLIENT DECRYPTS │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Unwrap DEK with private key → Decrypt content with AES-GCM │ │
│ │ ✨ Article displayed! │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Quick Example
// Server: Generate share link import { createTokenManager } from "@sesamy/capsule-server"; const tokens = createTokenManager({ secret: process.env.TOKEN_SECRET }); const token = tokens.generate({ tier: "premium", expiresIn: "7d", maxUses: 1000, // Optional: limit uses resourceId: "my-article", // Optional: restrict to article }); const shareUrl = `https://example.com/article/my-article?token=${token}`; // → Share this URL on social media!
// Client: Auto-unlock when token in URL const capsule = new CapsuleClient({ unlock: async (params) => { // Token is automatically included if present in URL return fetch("/api/unlock", { method: "POST", body: JSON.stringify(params), }).then((r) => r.json()); }, }); // Check for token and unlock const urlParams = new URLSearchParams(window.location.search); const token = urlParams.get("token"); if (token) { await capsule.unlockWithToken(article, token); }
Analytics & Tracking
Every share link unlock is logged with:
- Token ID - Unique identifier for the token
- Tier - Which tier was accessed
- Article ID - Which article was unlocked
- Timestamp - When the unlock occurred
- IP Address - Reader's location (for geo analytics)
This gives publishers full visibility: "Share link X was used 847 times, peaked at 3pm when the Twitter post went viral."
See detailed documentation:
- @sesamy/capsule-server - Token generation and validation
- @sesamy/capsule - Client-side token handling
Monorepo Structure
This is a pnpm workspace monorepo containing:
capsule/
├── apps/
│ └── demo/ # Next.js demo application
├── packages/
│ ├── capsule-client/ # Browser decryption library
│ ├── capsule-server/ # Server-side encryption & token management
│ └── capsule-publisher-php/ # PHP port of the CMS publisher (for WordPress)
├── package.json # Workspace root
└── pnpm-workspace.yaml
Packages
| Package | Language | Description | Location |
|---|---|---|---|
@sesamy/capsule |
TypeScript | Browser client-side decryption library | packages/capsule-client |
@sesamy/capsule-server |
TypeScript | Server encryption, tokens & unlock | packages/capsule-server |
sesamy/capsule-publisher |
PHP | PHP port of the CMS publisher — for WordPress plugins (Composer) | packages/capsule-publisher-php |
Apps
| App | Description | Location |
|---|---|---|
capsule-demo |
Next.js demo with encrypted articles | apps/demo |
Development
Prerequisites
- Node.js 18+
- pnpm 8+
Setup
# Install dependencies pnpm install # Build all packages pnpm build # Build specific package pnpm client build # Run demo pnpm demo dev # Run specific app/package commands pnpm demo <command> # Run command in demo app pnpm client <command> # Run command in client package
Available Scripts
pnpm dev # Start demo dev server pnpm build # Build all packages pnpm build:client # Build capsule-client pnpm build:demo # Build demo app pnpm test # Run tests in all packages pnpm lint # Lint all packages pnpm clean # Clean all node_modules and build outputs
Security Model
Tier Key Flow (Recommended for Subscribers)
Subscribers fetch the tier key once, then decrypt every article in that tier locally:
┌───────────────────────────────────────────────────────────────────────────────┐
│ TIER KEY FLOW ("Unlock Once, Access All") │
├───────────────────────────────────────────────────────────────────────────────┤
│ │
│ CMS (build/publish time) SUBSCRIPTION SERVER │
│ ┌─────────────────────────┐ ┌────────────────────────────────────┐ │
│ │ 1. Generate unique DEK │ │ Derives time-bucket keys from │ │
│ │ 2. Encrypt article with │ │ period secret + tier + bucket ID │ │
│ │ DEK (AES-256-GCM) │ │ using HKDF │ │
│ │ 3. Wrap DEK with tier's │ └────────────────────────────────────┘ │
│ │ bucket key (AES-GCM) │ │ │
│ │ 4. Embed in HTML │ │ │
│ └─────────────────────────┘ │ │
│ │ │
│ CLIENT (Browser) │ │
│ ┌─────────────────────────────────────────────────────┼─────────────────────┐ │
│ │ │ │ │
│ │ First article in tier: │ │ │
│ │ ┌─────────────────────────────┐ ┌───────────────┴──────────────────┐ │ │
│ │ │ Send publicKey + keyId │───►│ Derive bucket key for tier │ │ │
│ │ │ (e.g., "premium:123456") │ │ RSA-encrypt it for user │ │ │
│ │ │ mode: "tier" │◄───│ Return { encryptedDek, │ │ │
│ │ └─────────────────────────────┘ │ keyType: "kek" } │ │ │
│ │ │ └─────────────────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ RSA-unwrap → AES tier key │ │ │
│ │ │ Cache tier key in memory │ │ │
│ │ │ AES-GCM unwrap article's wrappedDek │ ← LOCAL, no network │ │
│ │ │ AES-GCM decrypt article content │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │
│ │ Every subsequent article in same tier + bucket: │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ Use cached tier key │ ← NO SERVER CALL │ │
│ │ │ AES-GCM unwrap wrappedDek locally │ │ │
│ │ │ AES-GCM decrypt content │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
Per-Article DEK Flow (Share Links & Purchases)
For single-article access, the server unwraps and re-wraps one DEK at a time:
┌─────────────────────────────────────────────────────────────────────────┐
│ PER-ARTICLE DEK FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT (Browser) SERVER │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Send: │ │ │ │
│ │ publicKey │────────────► │ 1. Derive bucket key │ │
│ │ wrappedDek │ │ 2. Unwrap DEK with bucket key │ │
│ │ keyId │ ◄──────── │ 3. RSA-wrap DEK for user │ │
│ └─────────────────┘ │ 4. Return { encryptedDek, │ │
│ │ │ keyType: "dek" } │ │
│ ▼ └─────────────────────────────────┘ │
│ ┌─────────────────┐ │
│ │ RSA-unwrap DEK │ │
│ │ Decrypt content │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Security Constraints
- Private RSA key never leaves the client - stored as non-extractable in IndexedDB
- AES DEK only exists in memory - during decryption process only
- Each article has a unique DEK - compromise of one doesn't affect others
- Dual key model - Tier keys (unlock all tier articles) + Article-specific keys (single article)
- Tier key caching - subscriber fetches tier key once, decrypts all articles locally
- RSA-OAEP (2048-bit, SHA-256) for key wrapping
- AES-256-GCM for content encryption with authentication
DEK Storage Security Modes
Capsule supports two security modes for storing Data Encryption Keys (DEKs), configurable via the securityMode prop on <EncryptedSection>:
Persist Mode (Default)
<EncryptedSection securityMode="persist" {...props} />
| Aspect | Description |
|---|---|
| Storage | Non-extractable CryptoKey stored directly in IndexedDB |
| Page Refresh | ✅ No network request needed - instant decryption |
| Exfiltration | ✅ Key material cannot be exported via exportKey() |
| Local Attack | ⚠️ Attacker with IndexedDB access can use the key locally |
| Best For | Typical premium content, performance-critical apps |
How it works: The CryptoKey object is stored directly in IndexedDB (it's structured-cloneable). On page refresh, the key is loaded and used immediately without any network requests. The key is marked as extractable: false, meaning crypto.subtle.exportKey() will throw an error - an attacker cannot export the raw key bytes to send to their server.
Session Mode
<EncryptedSection securityMode="session" {...props} />
| Aspect | Description |
|---|---|
| Storage | DEK kept in memory only (not persisted) |
| Page Refresh | ⚠️ Requires network request to fetch new DEK |
| Exfiltration | ✅ Key vanishes when tab closes |
| Local Attack | ✅ Key only exists while tab is open |
| Best For | Highly sensitive content, security-critical apps |
How it works: The DEK is only stored in JavaScript memory and expires when the page is closed or refreshed. Each page load requires a new network request to obtain a fresh DEK.
Security Comparison
┌────────────────────┬─────────────────┬─────────────────┐
│ Threat │ Persist Mode │ Session Mode │
├────────────────────┼─────────────────┼─────────────────┤
│ Network sniffing │ ✅ Protected │ ✅ Protected │
│ Server compromise │ ✅ Protected │ ✅ Protected │
│ Key exfiltration │ ✅ Protected │ ✅ Protected │
│ Local key usage │ ⚠️ Vulnerable │ ✅ Protected* │
│ Performance │ ✅ Fast │ ⚠️ Network req │
└────────────────────┴─────────────────┴─────────────────┘
* While tab is closed
Important: Client-Side Decryption Limitations
No browser mechanism can fully protect against a compromised browser. If an attacker can execute JavaScript in your origin (via XSS, malicious extension, or physical access), they can:
- Intercept decrypted content after decryption
- Use stored keys (even non-extractable ones) for local decryption
- Modify the page to exfiltrate content
Defense in depth is essential:
- Content Security Policy (CSP) to prevent script injection
- Subresource Integrity (SRI) for all scripts
- Time-limited DEKs with bucket rotation
- Server-side entitlement checks as the primary gate
Client Library Usage
Installation
npm install @sesamy/capsule
# or
pnpm add @sesamy/capsule
Basic Usage
import { CapsuleClient } from "@sesamy/capsule"; const client = new CapsuleClient(); // First time: generate and store keys const publicKey = await client.generateKeyPair(); // Send publicKey to server for registration // Later: decrypt an article const content = await client.decryptArticle(encryptedPayload);
Server-side Encryption (Example)
Server-side implementations can use the Web Crypto API in Node.js or any language with RSA-OAEP and AES-GCM support:
// Node.js example (see demo for full implementation) import { subtle } from "crypto"; // 1. Generate random AES-256 key (DEK) const dek = await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ "encrypt", ]); // 2. Encrypt content with DEK const iv = crypto.getRandomValues(new Uint8Array(12)); const encryptedContent = await subtle.encrypt( { name: "AES-GCM", iv }, dek, new TextEncoder().encode(content), ); // 3. Wrap DEK with recipient's public RSA key const publicKey = await subtle.importKey(/* ... */); const wrappedDek = await subtle.wrapKey("raw", dek, publicKey, { name: "RSA-OAEP", }); // 4. Return payload return { encryptedContent: base64(encryptedContent), iv: base64(iv), encryptedDek: base64(wrappedDek), };
Demo Features
The demo application showcases:
- Encrypted articles with tier-based and article-specific keys
- Key management UI - view and remove stored keys
- Developer console - inspect encryption operations
- Interactive unlock - choose between tier or article-specific keys
- Syntax-highlighted code blocks - documentation with examples
Publishing
To publish the client package to npm:
cd packages/capsule-client
pnpm build
pnpm publish --access public
License
MIT