Server Implementations
Capsule implements Delegated Content Access (DCA) — a two-role delegation model. The Publisher encrypts content and seals keys. The Issuer verifies access and unseals keys. The @sesamy/capsule-server package provides both roles.
Quick Start
npm install @sesamy/capsule-serverPublisher: Encrypting Content
The publisher encrypts content at render time. No network calls — all key derivation is local from a periodSecret:
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!,
periodDurationHours: 1, // default: 1-hour rotation
});
const result = await publisher.render({
resourceId: "article-123",
contentItems: [
{ contentName: "bodytext", content: "<p>Premium article body…</p>" },
],
issuers: [
{
issuerName: "sesamy",
publicKeyPem: process.env.SESAMY_ECDH_PUBLIC_KEY!,
keyId: "2025-10",
unlockUrl: "https://api.sesamy.com/unlock",
contentNames: ["bodytext"],
},
],
resourceData: { title: "My Article", author: "Jane Doe" },
});
// result.html.dcaDataScript → <script> tag to embed in <head>
// result.html.sealedContentTemplate → <template> with encrypted content
// result.json → JSON API variant (for SPAs/mobile)Issuer: Unlock Endpoint
The issuer verifies JWTs, checks access, and unseals keys:
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!,
},
});
// POST /api/unlock
app.post('/api/unlock', async(req, res) => {
const result = await issuer.unlock(req.body, {
grantedContentNames: ["bodytext"], // Your access decision
deliveryMode: "contentKey", // or "periodKey" for caching
});
res.json(result);
});Architecture Overview
Publisher (CMS/Build Side)
- ✅ Encrypts content with AES-256-GCM + AAD
- ✅ Derives periodKeys locally via HKDF (from
periodSecret) - ✅ Seals contentKeys and periodKeys for each issuer (ECDH P-256)
- ✅ Signs
resourceJWTandissuerJWT(ES256) - ✅ Embeds DCA data and sealed content in HTML
- ❌ Never has user keys or subscription data
Issuer (Unlock Server)
- ✅ Verifies publisher JWT signatures (trusted-publisher allowlist)
- ✅ Verifies integrity proofs for sealed blobs
- ✅ Makes access decisions (subscription check, share token, etc.)
- ✅ Unseals keys with its ECDH private key
- ✅ Optionally wraps keys with client's RSA public key (client-bound transport)
- ❌ Never sees article content or the publisher's
periodSecret
Flow Diagram
┌─────────────────────────┐
│ Publisher │
│ (Content + Encryption) │
│ │
│ periodSecret (local) │
│ ES256 signing key │
└──────────┬───────────────┘
│
│ 1. Render: encrypt content, seal keys,
│ sign JWTs, embed in HTML
▼
┌─────────────────────────┐
│ Static HTML / CDN │
│ │
│ dca-data (JSON) │
│ sealed content │
│ resourceJWT + issuerJWT │
└──────────┬───────────────┘
│
│ 2. Browser loads page, finds DCA content
▼
┌─────────────────┐ ┌─────────────────────┐
│ Browser │────►│ Issuer │
│ (DCA Client) │ │ (Unlock Server) │
│ │◄────│ │
└─────────────────┘ │ Verifies JWTs │
3. Send unlock req │ Checks access │
(sealed keys, │ Unseals with ECDH │
JWTs, keyId) │ private key │
└──────────────────────┘
4. Receive unsealed
keys, decrypt
content locally
Publisher Configuration
Key Setup
The publisher needs two secrets:
- ES256 signing key (ECDSA P-256): for signing
resourceJWT,issuerJWT, and share link tokens - Period secret: for HKDF-based periodKey derivation (never shared with the issuer)
import { generateEcdsaP256KeyPair, exportP256KeyPairPem } from '@sesamy/capsule-server';
// Generate an ES256 key pair (do this once, store securely)
const keyPair = await generateEcdsaP256KeyPair();
const pem = await exportP256KeyPairPem(keyPair);
// pem.privateKeyPem → store in KMS / env var
// pem.publicKeyPem → share with issuers
// Generate a period secret (do this once, store securely)
import crypto from 'crypto';
const periodSecret = crypto.randomBytes(32).toString('base64');DcaPublisherConfig
interface DcaPublisherConfig {
domain: string; // Publisher domain (e.g., "news.example.com")
signingKeyPem: string; // ES256 private key PEM
periodSecret: string | Uint8Array; // Period secret (base64 or raw bytes)
periodDurationHours?: number; // Period rotation interval (default: 1 hour)
}Render Options
interface DcaRenderOptions {
resourceId: string; // Unique article/resource identifier
contentItems: Array<{
contentName: string; // e.g., "bodytext", "sidebar"
content: string; // Plaintext content to encrypt
contentType?: string; // MIME type (default: "text/html")
}>;
issuers: Array<{
issuerName: string; // Issuer's canonical name
publicKeyPem: string; // Issuer's ECDH P-256 public key PEM
keyId: string; // Identifies which issuer private key matches
unlockUrl: string; // Issuer's unlock endpoint URL
contentNames: string[]; // Which content items this issuer gets keys for
}>;
resourceData?: Record<string, unknown>; // Publisher metadata for access decisions
}Render Result
The publisher returns HTML strings ready to embed, plus a JSON variant for headless/SPA use:
const result = await publisher.render({ ... });
// HTML embedding (SSR / static site):
// Embed in <head>:
result.html.dcaDataScript;
// → <script type="application/json" class="dca-data">{...}</script>
// Embed in <body> where premium content goes:
result.html.sealedContentTemplate;
// → <template class="dca-sealed-content">
// <div data-dca-content-name="bodytext">base64url_ciphertext</div>
// </template>
// JSON API (headless CMS / mobile):
result.json;
// → { version, resource, resourceJWT, issuerJWT, ..., sealedContent }Issuer Configuration
Key Setup
The issuer needs an ECDH P-256 key pair for unsealing:
import { generateEcdhP256KeyPair, exportP256KeyPairPem } from '@sesamy/capsule-server';
// Generate an ECDH P-256 key pair (do this once, store securely)
const keyPair = await generateEcdhP256KeyPair();
const pem = await exportP256KeyPairPem(keyPair);
// pem.privateKeyPem → store in KMS / env var (issuer keeps this)
// pem.publicKeyPem → share with publishers (they seal keys with it)DcaIssuerServerConfig
interface DcaIssuerServerConfig {
issuerName: string; // Must match what publishers use
privateKeyPem: string; // ECDH P-256 private key PEM
keyId: string; // Must match what publishers reference
trustedPublisherKeys: {
// Map of publisher domain → ES256 public key PEM (or extended config)
[domain: string]: string | {
signingKeyPem: string;
allowedResourceIds?: (string | RegExp)[]; // Optional constraint
};
};
}Trusted-Publisher Allowlist
Every publisher domain must be explicitly listed. Requests from unlisted domains are rejected. Domains are normalized (lowercase, trailing dots stripped):
const issuer = createDcaIssuer({
issuerName: "sesamy",
privateKeyPem: process.env.ISSUER_ECDH_P256_PRIVATE_KEY!,
keyId: "2025-10",
trustedPublisherKeys: {
// Simple: accept any resourceId from this domain
"news.example.com": process.env.NEWS_ES256_PUB!,
// Extended: restrict which resourceIds this domain can claim
"blog.example.com": {
signingKeyPem: process.env.BLOG_ES256_PUB!,
allowedResourceIds: ["article-1", /^premium-/],
},
},
});Unlock Endpoint
Access Decision
The issuer decides which content names to grant and how to deliver keys:
const result = await issuer.unlock(request, {
// Which content items to grant access to
grantedContentNames: ["bodytext"],
// Key delivery mode:
// "contentKey" — return the contentKey directly (most common)
// "periodKey" — return periodKeys (client caches and unwraps locally)
deliveryMode: "contentKey",
});Full Unlock Handler (Next.js)
import { createDcaIssuer } from '@sesamy/capsule-server';
import type { DcaUnlockRequest } from '@sesamy/capsule-server';
const issuer = createDcaIssuer({ /* config */ });
export async function POST(request: Request) {
const body = await request.json() as DcaUnlockRequest;
// Share link token flow
if(body.shareToken) {
const result = await issuer.unlockWithShareToken(body, {
deliveryMode: "contentKey",
onShareToken: async(payload) => {
console.log(`Share: ${payload.resourceId}, jti=${payload.jti}`);
// Throw to reject: throw new Error("Usage limit exceeded");
},
});
return Response.json(result);
}
// Normal subscription flow: check user access
const user = await getUserFromSession(request);
if(!user?.hasActiveSubscription) {
return Response.json({ error: "No active subscription" }, { status: 403 });
}
const result = await issuer.unlock(body, {
grantedContentNames: ["bodytext"],
deliveryMode: "contentKey",
});
return Response.json(result);
}Pre-Flight Verification
Verify request JWTs without unsealing, useful for access checks before committing:
const verified = await issuer.verify(request);
// verified.resource — the verified DcaResource (publisher domain, resourceId, etc.)
// verified.sealed — the sealed keys (authenticated via issuerJWT integrity proofs)
// verified.domain — normalised publisher domainTime-Period Keys
The publisher derives periodKeys locally using HKDF from the periodSecret. These rotate automatically based on periodDurationHours, enabling subscription revocation without re-encrypting content.
How It Works
// Period key derivation (internal to the publisher):
// IKM = periodSecret
// salt = contentName (makes keys content-specific)
// info = "dca|" + timeBucket (e.g., "dca|251023T13")
// len = 32 bytes (AES-256)
//
// The publisher wraps each contentKey with the current and next periodKeys
// (for rotation overlap). Both are sealed with the issuer's ECDH key.
//
// Revocation flow:
// 1. User subscription lapses
// 2. Issuer refuses to unseal keys for that user
// 3. When the period rotates, the browser no longer has a valid periodKey
// 4. Even cached periodKeys expire — no need to re-encrypt contentPeriod Rotation Table
| Setting | Bucket Format | Revocation Window |
|---|---|---|
periodDurationHours: 1 (default) | 251023T13 | Up to 1 hour |
periodDurationHours: 24 | 251023T00 | Up to 24 hours |
Security Best Practices
- 🔒 Store secrets in KMS (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault)
- 🔒 Use HTTPS for all API endpoints
- 🔒 Rate limit unlock endpoints
- 🔒 Log requests with redaction — never log raw keys or credentials; truncate identifiers in audit logs
- 🔒 Rotate signing keys periodically — use overlapping key IDs so outstanding JWTs remain valid during transition
Key Management
- Publisher ES256 key — store private key in KMS; share the public key PEM with each issuer
- Issuer ECDH P-256 key — store private key in KMS; share the public key PEM with each publisher
- Period secret — publisher-only; never shared with the issuer (DCA boundary)
- One key pair per role — do not reuse keys across issuers or publishers
Share Link Tokens
Share links allow pre-authenticated access to premium content. In the DCA model, share tokens are ES256-signed JWTs created by the publisher — they serve as authorization grants without carrying any key material.
Token Generation (Publisher)
The publisher creates share tokens using the same signing key that signs resourceJWT and issuerJWT:
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!,
});
const token = await publisher.createShareLinkToken({
resourceId: "article-123",
contentNames: ["bodytext"], // Which content items to grant access to
expiresIn: 7 * 24 * 3600, // 7 days (default)
maxUses: 50, // Optional: advisory usage limit
jti: "share-" + crypto.randomUUID(), // Optional: for tracking/revocation
data: { campaign: "twitter" }, // Optional: publisher metadata
});
const shareUrl = `https://news.example.com/article/123?share=${token}`;Token Validation (Issuer)
The issuer validates share tokens using the publisher's ES256 public key, which is already in the trustedPublisherKeys allowlist:
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 /api/unlock handler:
if(body.shareToken) {
const result = await issuer.unlockWithShareToken(body, {
deliveryMode: "contentKey",
onShareToken: async(payload) => {
// Optional: track usage, enforce maxUses, audit
console.log(`Share token used: ${payload.jti}`);
},
});
return Response.json(result);
}
// Standalone verification (for pre-flight checks):
const payload = await issuer.verifyShareToken(token, "news.example.com");Why No periodSecret Is Needed
The share token is purely an authorization grant — it replaces the subscription check. The key material already flows through the normal DCA channel: the publisher seals keys with the issuer's ECDH public key at render time, and the issuer unseals them with its private key at unlock time. The periodSecret never leaves the publisher.
Node.js
The @sesamy/capsule-server package uses the Web Crypto API (available in Node.js 18+) for all cryptographic operations.
Installation
npm install @sesamy/capsule-serverComplete Publisher Example
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!,
});
// Render encrypted article
const result = await publisher.render({
resourceId: "article-123",
contentItems: [
{ contentName: "bodytext", content: "<p>Premium article body…</p>" },
],
issuers: [
{
issuerName: "sesamy",
publicKeyPem: process.env.SESAMY_ECDH_PUBLIC_KEY!,
keyId: "2025-10",
unlockUrl: "/api/unlock",
contentNames: ["bodytext"],
},
],
});
// Embed in HTML template:
// <head> ${result.html.dcaDataScript} </head>
// <body> ${result.html.sealedContentTemplate} </body>Complete Issuer Example (Next.js)
// app/api/unlock/route.ts
import { createDcaIssuer } from '@sesamy/capsule-server';
import type { DcaUnlockRequest } 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!,
},
});
export async function POST(request: Request) {
const body = await request.json() as DcaUnlockRequest;
// Share link token flow
if(body.shareToken) {
return Response.json(
await issuer.unlockWithShareToken(body, { deliveryMode: "contentKey" })
);
}
// Normal flow: check subscription, then unlock
return Response.json(
await issuer.unlock(body, {
grantedContentNames: ["bodytext"],
deliveryMode: "contentKey",
})
);
}Low-Level Crypto Utilities
The package also exports low-level primitives for custom implementations:
import {
// Key generation
generateEcdsaP256KeyPair, // ES256 signing key pair
generateEcdhP256KeyPair, // ECDH P-256 sealing key pair
exportP256KeyPairPem, // Export key pair as PEM strings
generateAesKeyBytes, // Random 32-byte AES key
// Encryption
encryptContent, // AES-256-GCM encrypt with AAD
decryptContent, // AES-256-GCM decrypt with AAD
wrapContentKey, // AES-GCM key wrapping
unwrapContentKey, // AES-GCM key unwrapping
// JWT
createJwt, // Sign ES256 JWT
verifyJwt, // Verify ES256 JWT
decodeJwtPayload, // Decode without verification
// Sealing (ECDH P-256 / RSA-OAEP)
seal, // Seal key material for an issuer
unseal, // Unseal key material
// Time buckets
formatTimeBucket, // Format Date → "251023T13"
getCurrentTimeBuckets, // Get current + next bucket
deriveDcaPeriodKey, // HKDF period key derivation
// Encoding
toBase64Url, fromBase64Url, toBase64, fromBase64,
} from '@sesamy/capsule-server';Other Languages
The DCA protocol uses standard cryptographic primitives (AES-256-GCM, ECDH P-256, ES256, HKDF) available in all major languages. Below are low-level reference examples for the raw encryption and key-wrapping operations. A full DCA implementation would also need JWT signing, ECDH sealing, and the DCA data format — see the specification for details.
PHP
<?php
// Encrypt content
$contentKey = random_bytes(32); // AES-256 key
$iv = random_bytes(12); // GCM IV
$encrypted = openssl_encrypt(
$content,
'aes-256-gcm',
$contentKey,
OPENSSL_RAW_DATA,
$iv,
$tag
);
$result = [
'encryptedContent' => base64_encode($encrypted . $tag),
'iv' => base64_encode($iv),
'contentId' => 'premium'
];Key Exchange Endpoint
<?php
// api/unlock.php
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php:__PLACEHOLDER_1__
$contentId = $input['contentId'];
$publicKey = $input['publicKey'];
__PLACEHOLDER_2__
$contentKey = getContentKeyForContentId($contentId);
__PLACEHOLDER_3__
$publicKeyPem = convertSpkiToPem($publicKey);
__PLACEHOLDER_4__
$encryptedContentKey = '';
openssl_public_encrypt(
$contentKey,
$encryptedContentKey,
$publicKeyPem,
OPENSSL_PKCS1_OAEP_PADDING
);
echo json_encode([
'encryptedContentKey' => base64_encode($encryptedContentKey),
'contentId' => $contentId
]);
function convertSpkiToPem($base64Spki) {
$der = base64_decode($base64Spki);
$pem = "-----BEGIN PUBLIC KEY-----\n";
$pem .= chunk_split(base64_encode($der), 64);
$pem .= "-----END PUBLIC KEY-----";
return $pem;
}Python
Python support using the cryptography library.
Installation
pip install cryptographyBasic Usage
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
import os
import base64
# Encrypt content
def encrypt_article(content: str, contentKey: bytes) -> dict:
iv = os.urandom(12)
aesgcm = AESGCM(contentKey)
ciphertext = aesgcm.encrypt(iv, content.encode(), None)
return {
'encryptedContent': base64.b64encode(ciphertext).decode(),
'iv': base64.b64encode(iv).decode(),
'contentId': 'premium'
}
# Wrap content key
def wrap_content_key(content_key: bytes, public_key_spki: str) -> str:
# Load public key from SPKI
public_key = serialization.load_der_public_key(
base64.b64decode(public_key_spki)
)
# Wrap with RSA-OAEP
encrypted = public_key.encrypt(
content_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return base64.b64encode(encrypted).decode()Coming Soon
- 🔨 Go implementation
- 🔨 Ruby implementation
- 🔨 Rust implementation
- 🔨 .NET implementation
Want to contribute an implementation? Check out the GitHub repository.