Specification
Capsule is an open standard for client-side article encryption using envelope encryption. It enables secure content delivery without requiring server-side authentication or permission systems.
Architecture Overview
Capsule uses the Delegated Content Access (DCA) protocol, which separates content encryption (publisher) from access control (issuer). The publisher encrypts content with AES-256-GCM and seals keys for each issuer using ECDH P-256. Issuers unseal keys only when access is granted, and the client decrypts content locally in the browser.
Roles
| Role | Responsibility |
|---|---|
| Publisher | Encrypts content at render time. Seals per-content keys for each issuer with ECDH P-256. Signs a resourceJWT (ES256) binding metadata and an issuerJWT proving sealed-blob integrity (SHA-256 hashes). |
| Issuer | Owns an ECDH P-256 key pair. On unlock, verifies both JWTs, checks integrity proofs, unseals keys, and returns them to the client. |
| Client | Parses DCA data from the page, calls the issuer's unlock endpoint, receives keys, and decrypts content locally with AES-256-GCM. |
Encryption Flow
Content Encryption
The publisher generates a random contentKey (256-bit AES) and optional rotating periodKeys per content item, then encrypts content with AES-256-GCM using a random nonce and an AAD string. The contentKey is additionally wrapped with each periodKey so the issuer can grant either content-level or period-level access.
// Publisher render (server-side)
const result = await publisher.render({
resourceId: "article-123",
contentItems: [
{ contentName: "bodytext", content: "<p>Premium contentβ¦</p>" },
],
issuers: [
{
issuerName: "sesamy",
publicKeyPem: ISSUER_ECDH_PUBLIC_KEY_PEM,
keyId: "issuer-key-1",
unlockUrl: "https://issuer.example.com/api/unlock",
contentNames: ["bodytext"],
},
],
});
// result.html.dcaDataScript β <script class="dca-data">β¦</script>
// result.html.sealedContentTemplate β <template class="dca-sealed-content">β¦</template>Key Sealing (Publisher β Issuer)
For each issuer, the publisher uses ECDH P-256 key agreement to derive a shared secret, then wraps the contentKey and periodKeys with AES-256-GCM. The resulting opaque blobs are stored in issuerData. Only the matching issuer private key can unseal them.
// Sealing internals (automatic during render)
// 1. Ephemeral ECDH P-256 key pair generated per seal operation
// 2. ECDH shared secret derived: ephemeralPrivate Γ issuerPublic
// 3. HKDF-SHA256(secret, salt="dca-seal", info="dca-seal-aes256gcm") β 256-bit wrapping key
// 4. AES-256-GCM wrap each key with a unique 12-byte nonce
// 5. Sealed blob = ephemeralPublicKey(65B) β nonce(12B) β ciphertext+tagIntegrity Proofs
The publisher signs an issuerJWT for each issuer containing SHA-256 hashes of every sealed blob. On unlock, the issuer verifies these hashes before unsealing β preventing a tampered page from tricking the issuer into revealing keys for content it didn't encrypt.
// issuerJWT payload (signed by publisher's ES256 key)
{
"renderId": "abc123β¦", // Binds to resourceJWT
"issuerName": "sesamy",
"proof": {
"bodytext": {
"contentKey": "base64url(SHA-256(sealedContentKeyBlob))",
"periodKeys": {
"251023T13": "base64url(SHA-256(sealedPeriodKeyBlob))"
}
}
}
}DCA HTML Embedding
DCA data and sealed content are embedded using two elements. The <script> tag holds all metadata, JWTs, and sealed keys. The <template> tag holds the encrypted content blobs (inert β no scripts execute, no images load).
<!-- DCA metadata + sealed keys -->
<script type="application/json" class="dca-data">
{
"version": "1",
"resource": {
"renderId": "abc123",
"domain": "news.example.com",
"resourceId": "article-123",
"issuedAt": "2025-01-15T12:00:00Z",
"data": { "section": "politics", "date.published": "2025-10-20T10:30:00Z" }
},
"resourceJWT": "eyJβ¦",
"issuerJWT": { "sesamy": "eyJβ¦" },
"contentSealData": {
"bodytext": { "contentType": "text/html", "nonce": "β¦", "aad": "β¦" }
},
"sealedContentKeys": {
"bodytext": [{ "t": "251023T13", "nonce": "β¦", "key": "β¦" }]
},
"issuerData": {
"sesamy": {
"sealed": {
"bodytext": {
"contentKey": "base64url-sealed-blob",
"periodKeys": { "251023T13": "base64url-sealed-blob" }
}
},
"unlockUrl": "https://issuer.example.com/api/unlock",
"keyId": "issuer-key-1"
}
}
}
</script>
<!-- Encrypted content(inert) -->
<template class="dca-sealed-content">
<div data-dca-content-name="bodytext">base64url-encrypted-contentβ¦</div>
</template>Unlock Flow
When the client calls the issuer's unlock endpoint, the issuer performs a multi-step verification before returning keys:
- Verify
resourceJWTsignature (ES256) using the publisher's public key, looked up byresource.domain. - Verify
issuerJWTsignature with the same publisher key; check thatrenderIdmatches. - Verify integrity proofs β SHA-256 of each sealed blob must match the signed hashes in
issuerJWT. - Unseal keys using the issuer's ECDH private key (reverse of the sealing process).
- Return keys to the client β either as plaintext (direct) or RSA-OAEP wrapped (client-bound).
// Client β Issuer
POST /api/unlock
{
"resource": { "domain": "news.example.com", "resourceId": "article-123", β¦ },
"resourceJWT": "eyJβ¦",
"issuerJWT": "eyJβ¦",
"sealed": { "bodytext": { "contentKey": "β¦", "periodKeys": { "251023T13": "β¦" } } },
"keyId": "issuer-key-1",
"issuerName": "sesamy",
"clientPublicKey": "base64url-SPKI-RSA-public-key" // β enables client-bound mode
}
// Issuer β Client
{
"keys": {
"bodytext": {
"contentKey": "base64url-key-or-wrapped-key",
"periodKeys": { "251023T13": "base64url-key-or-wrapped-key" }
}
},
"transport": "client-bound" // or "direct" (default)
}Transport Modes
DCA deliberately leaves the issuer β client transport unspecified. Capsule implements two modes:
| Mode | Key Delivery | Security | Best For |
|---|---|---|---|
| Direct | Plaintext base64url keys in HTTPS response | TLS only β keys visible in server logs, CDN edges, DevTools | Simple deployments, trusted infrastructure |
| Client-bound | RSA-OAEP wrapped with client's browser public key | End-to-end β only the originating browser can unwrap | High-security content, zero-trust environments |
Client-Bound Transport
Client-bound transport adds an RSA-OAEP encryption layer on the issuer β client leg. The client generates an RSA key pair once and stores the non-extractable private key in IndexedDB. The public key is sent with every unlock request.
Key Pair Lifecycle
// DcaClient with client-bound transport enabled
const client = new DcaClient({
clientBound: true, // Enable RSA key wrapping
rsaKeySize: 2048, // RSA modulus length (default: 2048)
keyDbName: "dca-keys", // IndexedDB database name
});
// First unlock triggers key pair generation:
// 1. crypto.subtle.generateKey({ name: "RSA-OAEP", modulusLength: 2048, β¦ })
// 2. Private key re-imported as non-extractable (extractable: false)
// 3. Key pair stored in IndexedDB
// Subsequent visits: key pair loaded from IndexedDB automaticallyWrapping Flow
// Client-bound unlock sequence:
// 1. Client includes RSA public key in unlock request
POST /api/unlock {
β¦dcaFields,
"clientPublicKey": "base64url(SPKI-encoded RSA-OAEP public key)"
}
// 2. Issuer unseals keys normally, then wraps each with client's public key
for each key in unsealedKeys:
wrappedKey = RSA-OAEP-Encrypt(clientPublicKey, rawKeyBytes)
response.keys[contentName][keyType] = base64url(wrappedKey)
response.transport = "client-bound"
// 3. Client receives wrapped keys β opaque ciphertext, useless without private key
// 4. Client unwraps each key with its non-extractable private key
rawKey = RSA-OAEP-Decrypt(privateKey, wrappedKeyBytes)
// β AES-256 key material, ready for content decryptionSecurity Properties of Client-Bound Transport
- β End-to-end encryption: Key material is never in plaintext outside the browser's crypto engine
- β Non-extractable private key: Even XSS or DevTools cannot read the raw RSA private key bytes
- β Server-side opacity: The issuer sees only the client's public key β it cannot observe which keys the client actually uses
- β Replay resistance: Wrapped keys are bound to one browser's key pair
- β
Backward compatible: If
clientPublicKeyis absent, the issuer falls back to direct transport - β οΈ Device-bound: Keys cannot be transferred between browsers/devices (by design)
Client-Side Decryption
After receiving keys (direct or unwrapped), the client decrypts content using AES-256-GCM with the original nonce and AAD from contentSealData:
// 1. Parse DCA data from the page
const page = client.parsePage();
// 2. Unlock via issuer (sends sealed keys + optional clientPublicKey)
const response = await client.unlock("sesamy");
// 3. Decrypt content (handles unwrapping if client-bound)
const html = await client.decrypt("sesamy", "bodytext", response);
// 4. Replace placeholder with decrypted content
document.querySelector('[data-dca-content-name="bodytext"]')
.innerHTML = html;Handling Decrypted Content in Scripts
Since content is decrypted client-side after the initial page load, any scripts that need to process the content (syntax highlighting, analytics, interactive widgets, etc.) must run after decryption completes. There are two approaches:
Option A: Listen for the capsule:unlocked Event
Capsule dispatches a custom event when content is decrypted and added to the DOM:
document.addEventListener("capsule:unlocked", (event) => {
const { resourceId, element, keyId } = event.detail;
// element is the DOM container with the decrypted content
// Run your initialization code here
highlightCodeBlocks(element);
initializeWidgets(element);
console.log(`Article "${resourceId}" unlocked with key: ${keyId}`);
});Option B: Use a MutationObserver
For more generic DOM change detection, use a MutationObserver:
const observer = new MutationObserver((mutations) => {
for(const mutation of mutations) {
for(const node of mutation.addedNodes) {
if(node instanceof HTMLElement) {
// Check if this is unlocked content
if(node.classList.contains("premium-content")) {
initializeContent(node);
}
}
}
}
});
// Observe the container where encrypted sections appear
observer.observe(document.body, {
childList: true,
subtree: true
});Share Link Tokens
Share links allow pre-authenticated access to premium content without requiring the recipient to have a subscription. This enables social media sharing, email distribution, and promotional campaigns.
DCA-Compatible Design
The critical design insight: a share link token is purely an authorization grant, not a key-delivery mechanism. The publisher's periodSecret never leaves the publisher. Key material flows through the normal DCA seal/unseal channel β the sealed keys are already embedded in the page's DCA data, and the issuer unseals them as usual.
This is DCA-compatible because the issuer never needs the publisher's periodSecret. The publisher creates a signed JWT that says βthis bearer may access these content items for this resource.β The issuer validates the token signature (the publisher already has a trusted signing key in the allowlist), uses the token's claims as the access decision, and returns unsealed keys from the normal DCA sealed data.
// Share Link Flow (DCA-compatible)
//
// 1. Publisher signs a share token (ES256 JWT) granting access
// 2. User clicks the share link β loads page with normal DCA-sealed content
// 3. Client includes the share token in the unlock request
// 4. Issuer verifies token (publisher-signed, trusted key) β access decision
// 5. Issuer unseals keys from normal DCA sealed data β returns to client
// 6. Client decrypts content locally
//
// Key insight: periodSecret never leaves the publisher.
// The token is authorization only β key material uses normal DCA channels.Token Structure
Share link tokens are ES256 (ECDSA P-256) signed JWTs, using the same publisher signing key that signs resourceJWT and issuerJWT. The issuer already trusts this key via its trustedPublisherKeys allowlist.
// DcaShareLinkTokenPayload (ES256 JWT payload)
{
"type": "dca-share", // Type discriminator
"domain": "news.example.com", // Publisher domain (must match resource)
"resourceId": "article-123", // Resource this token grants access to
"contentNames": ["bodytext"], // Content items to unlock
"iat": 1707400800, // Issued at (Unix timestamp)
"exp": 1708005600, // Expires at (Unix timestamp)
"maxUses": 100, // Optional: usage limit (advisory)
"jti": "share-abc123", // Optional: unique ID (for tracking/revocation)
"data": { "campaign": "twitter" } // Optional: publisher-defined metadata
}Token Generation (Publisher)
The publisher creates share tokens using the same createDcaPublisher instance that renders pages:
import { createDcaPublisher } from '@sesamy/capsule-server';
const publisher = createDcaPublisher({
domain: "news.example.com",
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY!,
periodSecret: process.env.PERIOD_SECRET!,
});
// Generate a share link token
const token = await publisher.createShareLinkToken({
resourceId: "article-123",
contentNames: ["bodytext"],
expiresIn: 7 * 24 * 3600, // 7 days (default)
maxUses: 50, // Optional
jti: "share-" + crypto.randomUUID(), // Optional: for tracking
data: { sharedBy: "user-42" }, // Optional: metadata
});
// Create shareable URL
const shareUrl = `https://news.example.com/article/123?share=${token}`;Issuer-Side Validation
The issuer validates the share token using the publisher's signing key (already in trustedPublisherKeys). No new secrets or key material are needed:
import { createDcaIssuer } from '@sesamy/capsule-server';
const issuer = createDcaIssuer({
issuerName: "sesamy",
privateKeyPem: process.env.ISSUER_ECDH_P256_PRIVATE_KEY!,
keyId: "2025-10",
trustedPublisherKeys: {
"news.example.com": process.env.PUBLISHER_ES256_PUBLIC_KEY!,
},
});
// In unlock endpoint:
export async function POST(request: Request) {
const body = await request.json();
if(body.shareToken) {
// Share link flow: token IS the access decision
const result = await issuer.unlockWithShareToken(body, {
deliveryMode: "contentKey", // or "periodKey" for caching
onShareToken: async(payload, resource) => {
// Optional: use-count tracking, audit logging
console.log(`Share token used: ${payload.jti}`);
// Throw to reject: throw new Error("Usage limit exceeded");
},
});
return Response.json(result);
}
// Normal subscription flow...
}The issuer performs these validation steps:
- Verifies request JWTs (same as normal unlock)
- Verifies share token signature with the publisher's ES256 key
- Validates type discriminator (
"dca-share") - Validates domain binding (token domain must match resource domain)
- Validates resourceId binding (token must be for this resource)
- Checks expiry (reject expired tokens)
- Invokes optional
onShareTokencallback (use-count, audit) - Grants access to content names listed in token β© available sealed data
- Unseals keys from normal DCA sealed blobs and returns them
Unlock Request with Share Token
// Client β Issuer
POST /api/unlock
{
"resource": { "domain": "news.example.com", "resourceId": "article-123", β¦ },
"resourceJWT": "eyJβ¦",
"issuerJWT": "eyJβ¦",
"sealed": { "bodytext": { "contentKey": "β¦", "periodKeys": { β¦ } } },
"keyId": "issuer-key-1",
"issuerName": "sesamy",
"shareToken": "eyJβ¦", // β Share link token
"clientPublicKey": "base64url-SPKIβ¦" // Optional: client-bound transport
}
// Issuer β Client (same response format as normal unlock)
{
"keys": {
"bodytext": {
"contentKey": "base64url-key-or-wrapped-key"
}
},
"transport": "client-bound"
}Client-Side Share Link Handling
import { DcaClient } from '@sesamy/capsule';
const client = new DcaClient();
const page = client.parsePage();
// Check for share token in URL
const shareToken = DcaClient.getShareTokenFromUrl(); // reads ?share= param
if(shareToken) {
// Unlock with share token (auto-includes token in unlock request)
const keys = await client.unlockWithShareToken(page, "sesamy", shareToken);
const html = await client.decrypt(page, "bodytext", keys);
document.querySelector('[data-dca-content-name="bodytext"]')!.innerHTML = html;
// Clean up URL (cosmetic)
const url = new URL(window.location.href);
url.searchParams.delete("share");
history.replaceState({}, "", url);
}Use-Count Tracking
The maxUses field is advisory β enforcement is the issuer's responsibility. Use the onShareToken callback to implement tracking:
// Example: Redis-based use-count tracking
const result = await issuer.unlockWithShareToken(body, {
onShareToken: async(payload) => {
if(!payload.jti) return; // No tracking without token ID
const key = `share-uses:${payload.jti}`;
const count = await redis.incr(key);
// Set TTL on first use
if(count === 1) {
await redis.expire(key, payload.exp - Math.floor(Date.now() / 1000));
}
if(payload.maxUses && count > payload.maxUses) {
throw new Error("Share link usage limit exceeded");
}
},
});Standalone Token Verification
The issuer can verify a share token without performing a full unlock, useful for pre-flight checks:
const payload = await issuer.verifyShareToken(shareToken, "news.example.com");
// payload: { type, domain, resourceId, contentNames, iat, exp, jti?, maxUses?, data? }Security Considerations for Share Links
- β Tokens are ES256-signed using the publisher's existing signing key
- β Issuer validates signature via the trusted-publisher allowlist (no new secrets)
- β
periodSecretnever leaves the publisher β DCA boundary intact - β Expiration limits exposure window
- β
Usage limits via
maxUses+onShareTokencallback - β Resource and domain binding prevent token reuse across content
- β Content-name scoping limits what each token can unlock
- β
Full audit trail via
jti,data, and callback - β Key material uses the same DCA seal/unseal channel (no new attack surface)
- β οΈ Tokens are bearer credentials β anyone with the URL has access
- β οΈ Publisher signing key must be protected (same requirement as normal DCA)
Security Considerations
Period Secret Protection
The period secret is the root of all security. If compromised, attackers can derive all future period keys. Only the publisher should hold the period secret.
| Component | Public/Secret | Storage |
|---|---|---|
| Period Secret | π SECRET | KMS only (Publisher server) |
| Period Derivation Algorithm | β Public | Open source code |
| Period Keys | π SECRET | Derived on-demand, cached briefly |
| Content Keys | π SECRET | Wrapped (never in plaintext) |
| User Private Keys | π SECRET | Browser IndexedDB (non-extractable) |
Access Revocation
With time-period keys, access is automatically revoked within the period duration (15 minutes):
- User's browser caches unwrapped content key until period expires
- When subscription cancelled, issuer refuses new unlock requests
- Cached content key expires β user can no longer decrypt new content
- No content re-encryption needed
Publisher Compromise Scenarios
If the publisher is compromised, attacker gets:
- β Plaintext content (publisher already has this)
- β Period secret and derived period keys
- β Cannot unseal keys without issuer private key
- β Cannot decrypt for other users (no user private keys)
Issuer Compromise Scenarios
If the issuer is compromised, attacker gets:
- β ECDH private key β can unseal content keys and period keys
- β Can decrypt content if they also have the encrypted content
- β Cannot access content without the encrypted HTML (publisher-side)
- β Cannot forge publisher JWTs (no ES256 signing key)
Mitigation: Use separate infrastructure, rotate issuer key pairs, audit logs
Private Key Protection
Private keys must be stored with extractable: false in the Web Crypto API. This prevents JavaScript from accessing the raw key material.
Key Storage
The period secret and signing keys should be stored in a secure key management system (KMS) in production. Never hardcode secrets in source code.
Transport Security
The key exchange endpoint must use HTTPS. While the wrapped content key is encrypted, HTTPS prevents MITM attacks on the public key exchange.
IV Uniqueness
Each encrypted article must use a unique initialization vector (IV). Never reuse IVs with the same content key, as this breaks AES-GCM security.
Security Properties
What Capsule Provides
- β Confidentiality: Content encrypted at rest and in transit
- β Integrity: AES-GCM authentication detects tampering
- β Forward Secrecy: Time periods limit exposure window
- β Secure Key Transport: ECDH P-256 sealing + optional RSA-OAEP client-bound wrapping
- β Offline Access: Cached keys work without network
- β No Server-Side User Tracking: Keys are bearer tokens
What Capsule Does NOT Provide
- β DRM: Determined users can extract decrypted content
- β Copy Protection: Once decrypted, content can be copied
- β Watermarking: No user-specific content marking
Capsule is designed for honest users who want convenient access, not for preventing determined adversaries from extracting content.
Implementation Checklist
- β AES-256-GCM for content encryption
- β ECDH P-256 for key sealing
- β ES256 (ECDSA P-256) for JWT signing
- β Unique 96-bit IV per encrypted content
- β 128-bit authentication tag (GCM)
- β Private keys stored with extractable: false
- β HTTPS for key exchange endpoint
- β Proper error handling and validation