Server Implementations
Capsule implements Delegated Content Access (DCA) -- a two-role delegation model. The Publisher encrypts content and wraps keys. The Issuer verifies access and unwraps keys. The @sesamy/capsule-server package provides both roles.
Quick Start
npm install @sesamy/capsule-server
Publisher: Encrypting Content
The publisher encrypts content at render time. No network calls -- all key derivation is local from a rotationSecret:
import { createDcaPublisher } from '@sesamy/capsule-server';
const publisher = createDcaPublisher({
domain: "news.example.com",
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY!,
rotationSecret: process.env.ROTATION_SECRET!,
rotationIntervalHours: 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.manifestScript β <script> tag to embed in <head>
// result.json β JSON API variant (for SPAs/mobile)
Issuer: Unlock Endpoint
The issuer verifies JWTs, checks access, and unwraps 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: "direct", // or "wrapKey" for caching
});
res.json(result);
});
Architecture Overview
Publisher (CMS/Build Side)
- Encrypts content with AES-256-GCM + AAD
- Derives wrapKeys locally via HKDF (from
rotationSecret) - Wraps contentKeys and wrapKeys for each issuer (ECDH P-256)
- Signs
resourceJWT(ES256) - Embeds the DCA manifest and wrapped content in HTML
- Never has user keys or subscription data
Issuer (Unlock Server)
- Verifies publisher JWT signatures (trusted-publisher allowlist)
- Validates integrity via scope-bound AAD tokens (authenticated during unwrap)
- Makes access decisions (subscription check, share token, etc.)
- Unwraps 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
rotationSecret
Flow Diagram
βββββββββββββββββββββββββββ
β Publisher β
β (Content + Encryption) β
β β
β rotationSecret(local) β
β ES256 signing key β
ββββββββββββ¬ββββββββββββββββ
β
β 1. Render: encrypt content, wrap keys,
β sign resourceJWT, embed in HTML
βΌ
βββββββββββββββββββββββββββ
β Static HTML / CDN β
β β
β dca-manifest(JSON) β
β wrapped content β
β resourceJWT β
ββββββββββββ¬ββββββββββββββββ
β
β 2. Browser loads page, finds DCA content
βΌ
βββββββββββββββββββ βββββββββββββββββββββββ
β Browser ββββββΊβ Issuer β
β (DCA Client) β β (Unlock Server) β
β βββββββ β
βββββββββββββββββββ β Verifies JWTs β
3. Send unlock req β Checks access β
(wrapped keys, β Unwraps with ECDH β
JWT, kid) β private key β
ββββββββββββββββββββββββ
4. Receive unwrapped
keys, decrypt
content locally
Publisher Configuration
Key Setup
The publisher needs two independent secrets. They serve different cryptographic roles and have different trust boundaries, so they cannot be merged into one.
| Secret | Role | Primitive | Who sees it |
|---|---|---|---|
PUBLISHER_ES256_PRIVATE_KEY (signingKeyPem) | Authentication -- "this manifest came from me" | ECDSA P-256 (asymmetric) | Private key: publisher only. Public key is shared with every issuer (they need it to verify JWTs). |
ROTATION_SECRET (rotationSecret) | WrapKey schedule -- publisher's internal key rotation | 256-bit symmetric HKDF input | Publisher only. Never leaves the publisher, never shared with issuers. |
Why two separate secrets?
- Different primitives. Signing requires an asymmetric key so issuers can verify JWTs without holding a shared secret. The rotation secret is symmetric because it is used as HKDF input keying material -- there is no asymmetric analogue for this role.
- Different trust boundaries. The signing key's public half is intentionally published to issuers. The rotation secret is publisher-only by design -- if an issuer ever learned it, the issuer could derive every past and future wrapKey offline and bypass the rotation-based revocation model.
- Different rotation cadences. Signing keys rotate periodically with overlap (issuers accept old and new public keys during transition -- see Publisher Key Resolution (JWKS) for the discovery-driven path, or pin PEMs statically for smaller deployments). The rotation secret almost never rotates, because changing it invalidates every derived wrapKey and forces re-encryption or re-issuance.
- Cryptographic domain separation. Reusing one secret for two unrelated primitives (ECDSA signing and HKDF derivation) violates RFC 5869 / NIST SP 800-56C guidance and opens the door to key-confusion attacks. Distinct purposes use distinct keys.
import { generateEcdsaP256KeyPair, exportP256KeyPairPem } from '@sesamy/capsule-server';
// 1. Signing key (ES256) β generate once, keep private in KMS, share public with issuers
const keyPair = await generateEcdsaP256KeyPair();
const pem = await exportP256KeyPairPem(keyPair);
// pem.privateKeyPem β PUBLISHER_ES256_PRIVATE_KEY (KMS / env var, publisher only)
// pem.publicKeyPem β distribute to issuers (pin into their trustedPublisherKeys,
// or serve via .well-known/dca-publishers.json β see
// "Publisher Key Resolution (JWKS)" below)
// 2. Rotation secret β generate once, keep in KMS, never share
import crypto from 'crypto';
const rotationSecret = crypto.randomBytes(32).toString('base64');
// β ROTATION_SECRET (KMS / env var, publisher only)
DcaPublisherConfig
interface DcaPublisherConfig {
domain: string; // Publisher domain (e.g., "news.example.com")
signingKeyPem: string; // ES256 private key PEM
signingKeyId?: string; // Optional kid β set it when publishing via JWKS
// (see "Publisher Key Resolution" below). Emitted
// as the JWT header `kid`.
rotationSecret: string | Uint8Array; // Rotation secret (base64 or raw bytes)
rotationIntervalHours?: number; // WrapKey rotation interval (default: 1 hour)
// JWKS-resolved issuer keys (see "Issuer Key Resolution" below).
// These apply to every issuer config that sets `jwksUri`.
jwksCache?: DcaJwksCache; // Pluggable backend (default: in-memory)
jwksStaleWindowSeconds?: number; // Stale-if-error window (default: 30 days)
}
Render Options
interface DcaRenderOptions {
resourceId: string; // Unique article/resource identifier
contentItems: Array<{
contentName: string; // e.g., "bodytext", "sidebar"
scope?: string; // Access scope (defaults to contentName).
// Items sharing a scope share a wrapKey.
content: string; // Plaintext content to encrypt
contentType?: string; // MIME type (default: "text/html")
}>;
issuers: Array<{
issuerName: string; // Issuer's canonical name
// Exactly one of publicKeyPem / jwksUri must be set (mutually exclusive).
publicKeyPem?: string; // Issuer's ECDH P-256 / RSA-OAEP public key PEM
jwksUri?: string; // Or: JWKS URL (e.g., "https://β¦/.well-known/jwks.json")
algorithm?: "ECDH-P256" | "RSA-OAEP"; // Auto-detected from PEM if omitted
keyId?: string; // Required with publicKeyPem; ignored with jwksUri
unlockUrl: string; // Issuer's unlock endpoint URL
contentNames?: string[]; // Which content items this issuer gets keys for
scopes?: string[]; // Or: which scopes this issuer gets keys for
}>;
resourceData?: Record<string, unknown>; // Publisher metadata for access decisions
}
Render Result
The publisher returns a single HTML string ready to embed, plus a JSON variant for headless/SPA use. The manifest is self-contained -- per v1, ciphertext lives inside the manifest, so there is no separate content template:
const result = await publisher.render({ ... });
// HTML embedding (SSR / static site):
// Embed in <head> (or anywhere in the document):
result.html.manifestScript;
// β <script type="application/json" class="dca-manifest">{...}</script>
// Target elements for decrypted content use data-dca-content-name:
// <div data-dca-content-name="bodytext"></div>
// JSON API (headless CMS / mobile) β this IS the manifest:
result.json;
// β { version: "0.10", resourceJWT, content: { ... }, issuers: { ... } }
Manifest Shape
For reference, the manifest embedded in the dca-manifest script has this shape:
{
"version": "0.10",
"resourceJWT": "eyJhbGciOi...",
"content": {
"bodytext": {
"contentType": "text/html",
"iv": "base64url_12_bytes",
"aad": "...",
"ciphertext": "base64url_gcm_output",
"wrappedContentKey": [
{ "kid": "251023T13", "iv": "...", "ciphertext": "..." },
{ "kid": "251023T14", "iv": "...", "ciphertext": "..." }
]
}
},
"issuers": {
"sesamy": {
"unlockUrl": "https://api.sesamy.com/unlock",
"keyId": "2025-10",
"keys": [
{
"contentName": "bodytext",
"scope": "bodytext",
"kid": "2025-10",
"contentKey": "base64url_wrapped_for_issuer",
"wrapKeys": [
{ "kid": "251023T13", "key": "base64url_wrapped_for_issuer" },
{ "kid": "251023T14", "key": "base64url_wrapped_for_issuer" }
]
}
]
}
}
}
issuers[name].keyIdis echoed when the publisher usespublicKeyPem; omitted when the publisher resolves keys viajwksUri.- Each
issuers[name].keys[]entry carries its ownkididentifying which issuer public key wrapped the material. During JWKS rotation overlap, there will be multiple entries percontentNameβ one per active issuer kid. Note that thiskidis the issuer key id, distinct from the rotationkidonwrapKeys[].
Issuer Configuration
Key Setup
The issuer needs an ECDH P-256 key pair for unwrapping:
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 wrap 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: {
// Exactly one of signingKeyPem / jwksUri per entry. Bare string is
// shorthand for `{ signingKeyPem: "..." }`.
[domain: string]: string | {
signingKeyPem?: string;
jwksUri?: string;
allowedResourceIds?: (string | RegExp)[]; // Optional constraint
};
};
// Publisher-JWKS cache controls (used when any entry sets jwksUri).
jwksCache?: DcaJwksCache; // Pluggable backend (default: in-memory)
jwksStaleWindowSeconds?: number; // Stale-if-error window (default: 30 days)
jwksFetchTimeoutMs?: number; // HTTP timeout (default: 5000 ms)
}
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: pinned PEM. Publisher rotation requires redeploying.
"news.example.com": process.env.NEWS_ES256_PUB!,
// JWKS-backed: publisher rotation picked up automatically.
// See "Publisher Key Resolution (JWKS)" below.
"mag.example.com": {
jwksUri: "https://mag.example.com/.well-known/dca-publishers.json",
},
// 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 items (or scopes) to grant and how to deliver keys:
const result = await issuer.unlock(request, {
// Which content items to grant access to (by contentName)β¦
grantedContentNames: ["bodytext"],
// β¦or which scopes to grant (mutually exclusive with grantedContentNames)
// grantedScopes: ["premium"],
// Key delivery mode:
// "direct" β return the contentKey directly (most common)
// "wrapKey" β return wrapKeys (client caches and unwraps locally)
deliveryMode: "direct",
});
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: "direct",
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: "direct",
});
return Response.json(result);
}
Pre-Flight Verification
Verify request JWTs without unwrapping, useful for access checks before committing:
const verified = await issuer.verify(request);
// verified.resource β the verified DcaResource (publisher domain, resourceId, etc.)
// verified.domain β normalised publisher domain
WrapKeys and Rotation
The publisher derives wrapKeys locally using HKDF from the rotationSecret. These rotate automatically based on rotationIntervalHours, enabling subscription revocation without re-encrypting content.
How It Works
// WrapKey derivation (internal to the publisher):
// IKM = rotationSecret
// salt = scope (items sharing a scope share a wrapKey)
// info = "dca|" + kid (e.g., "dca|251023T13")
// len = 32 bytes (AES-256)
//
// The publisher wraps each contentKey with the current and next wrapKeys
// (for rotation overlap). Both are wrapped with the issuer's ECDH key.
//
// Revocation flow:
// 1. User subscription lapses
// 2. Issuer refuses to unwrap keys for that user
// 3. When the kid rotates, the browser no longer has a valid wrapKey
// 4. Even cached wrapKeys expire β no need to re-encrypt content
Rotation Table
| Setting | kid Format | Revocation Window |
|---|---|---|
rotationIntervalHours: 1 (default) | 251023T13 | Up to 1 hour |
rotationIntervalHours: 24 | 251023T00 | Up to 24 hours |
Issuer Key Resolution (JWKS)
The publisher wraps content keys with each issuer's public key. There are two ways to tell the publisher which key(s) to use:
| Form | Use when |
|---|---|
publicKeyPem + keyId | You manage one key per issuer out-of-band (env vars, KMS) and re-deploy on rotation. |
jwksUri | You want issuer key rotation to be a no-op for publishers -- the issuer publishes a JWKS, publishers fetch and cache it. |
The two are mutually exclusive per issuer config. Mixing them throws at render time.
JWKS Format
Standard RFC 7517 JWKS. The publisher selects keys where:
kidis present (required)ktyisECwithcrv: "P-256"orktyisRSAuseis"enc"or absent (keys withuse: "sig"are ignored)statusis not"retired"(a non-standard flag the publisher honors if present)
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"kid": "2026-04",
"use": "enc",
"x": "β¦",
"y": "β¦"
},
{
"kty": "EC",
"crv": "P-256",
"kid": "2026-01",
"use": "enc",
"status": "retired",
"x": "β¦",
"y": "β¦"
}
]
}
Rotation Semantics
The publisher wraps each content key for every active JWKS key -- typically two during an overlap window. Each emitted issuers[name].keys[] entry carries its own kid:
// Publisher side β JWKS with two active keys produces two wrapped entries per content item.
const result = await publisher.render({
resourceId: "article-123",
contentItems: [{ contentName: "bodytext", content: "β¦" }],
issuers: [{
issuerName: "sesamy",
jwksUri: "https://sesamy.com/.well-known/dca-issuers.json",
unlockUrl: "https://api.sesamy.com/unlock",
contentNames: ["bodytext"],
}],
});
// result.manifest.issuers["sesamy"].keys has 2 entries β one per active kid.
The issuer selects the entry matching its configured keyId. Rotation is a no-op for publishers: the issuer adds the new key to the JWKS, publishers pick it up on their next refresh, and both keys are honored during the overlap.
Caching
Freshness is driven by the JWKS response's Cache-Control: max-age header (fallback: 1 hour). Fresh entries are served from cache without a network hop.
When an entry is past freshness and the upstream refresh fails, the publisher serves the stale cached copy for up to 30 days past freshness by default (configurable via jwksStaleWindowSeconds). After that window, the next render throws with the URL in the error message. Availability beats freshness for wrap operations -- the private keys on the issuer side rotate rarely.
By default the cache is an in-memory Map scoped to the process. For multi-worker or multi-pod deployments, plug in a persistent backend:
import type { DcaJwksCache, DcaJwksCacheEntry } from '@sesamy/capsule-server';
const kvCache: DcaJwksCache = {
async get(url) {
const raw = await env.JWKS_KV.get(url);
return raw ? (JSON.parse(raw) as DcaJwksCacheEntry) : undefined;
},
async set(url, entry) {
// Use staleUntil as the KV expiration so entries self-evict after the
// stale window β no manual cleanup needed.
await env.JWKS_KV.put(url, JSON.stringify(entry), {
expiration: Math.floor(entry.staleUntil / 1000),
});
},
};
const publisher = createDcaPublisher({
domain: "news.example.com",
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY!,
rotationSecret: process.env.ROTATION_SECRET!,
jwksCache: kvCache,
jwksStaleWindowSeconds: 30 * 24 * 3600, // explicit is nice; this is the default
});
DcaJwksCache.get / set may be sync or async -- use whatever fits your backend. delete is optional.
Force-Refresh
If the issuer returns "unknown kid" on unlock, the publisher's cached JWKS is likely out of date. Force a refresh:
import { refreshJwks } from '@sesamy/capsule-server';
await refreshJwks("https://sesamy.com/.well-known/dca-issuers.json", {
cache: kvCache,
staleWindowSeconds: 30 * 24 * 3600,
});
refreshJwks bypasses the freshness check but still honors stale-fallback: if the refresh fails and a stale copy exists within the window, it's returned rather than throwing.
Publisher Key Resolution (JWKS)
Symmetric to the issuer-side story: publishers can publish their ES256 signing keys as a JWKS so issuers resolve them dynamically rather than pinning a PEM. Rotation becomes transparent -- issuers pick up the new key on their next refresh (or immediately via force-refresh on unknown kid).
When to Use It
| Pick | When |
|---|---|
signingKeyPem | One or two issuers trust the publisher; rotation is a rare, planned event. |
jwksUri | Many issuers trust the publisher; or you want rotation to avoid touching issuer configs. |
Publisher Side
Set signingKeyId on createDcaPublisher (so every signed JWT carries a kid in the header), then serve a JWKS document at /.well-known/dca-publishers.json:
import {
createDcaPublisher,
buildPublisherJwksDocument,
} from '@sesamy/capsule-server';
const publisher = createDcaPublisher({
domain: "news.example.com",
signingKeyPem: process.env.PUBLISHER_SIGNING_KEY!,
signingKeyId: process.env.PUBLISHER_SIGNING_KEY_ID!, // e.g. "sig-2026-04"
rotationSecret: process.env.PERIOD_SECRET!,
});
// Build the JWKS once at startup (the public key doesn't change per request).
const jwks = await buildPublisherJwksDocument([
{
publicKeyPem: process.env.PUBLISHER_PUBLIC_KEY!,
kid: process.env.PUBLISHER_SIGNING_KEY_ID!,
},
]);
// Express / Fastify / Next.js β whatever framework you use:
app.get("/.well-known/dca-publishers.json", (_req, res) => {
res.setHeader("Cache-Control", "public, max-age=3600");
res.json(jwks);
});
During a rotation, include both keys in the JWKS β the previous one (optionally flagged status: "retired") and the new active one:
const jwks = await buildPublisherJwksDocument([
{ publicKeyPem: previousPublicPem, kid: "sig-2026-03", status: "retired" },
{ publicKeyPem: newPublicPem, kid: "sig-2026-04" },
]);
Retired keys stay in the document for a grace window so in-flight JWTs signed with them can still verify -- until cache TTL in use. Once the rotation cutover is complete and no old JWTs are in the wild, drop the retired entry.
Issuer Side
Replace the pinned PEM with jwksUri:
const issuer = createDcaIssuer({
issuerName: "sesamy",
privateKeyPem: process.env.ISSUER_PRIVATE_KEY!,
keyId: process.env.ISSUER_KEY_ID!,
trustedPublisherKeys: {
"news.example.com": {
jwksUri: "https://news.example.com/.well-known/dca-publishers.json",
},
},
// Optional: share the same KV cache backend as the publisher-side code.
jwksCache: kvCache,
});
The issuer picks a key from the JWKS by the JWT header's kid. When the kid isn't in the cached JWKS, the cache is force-refreshed once before failing -- this handles the "publisher rotated between cache fetches" case without any manual intervention.
Selection Rules
Active entries for publisher signing:
kidis presentktyisECwithcrv: "P-256"(ES256)useis"sig"or absentstatusis not"retired"
RSA signing keys are not supported -- DCA JWTs are fixed to ES256.
Caching
Publisher-JWKS fetches use the same pluggable cache and stale-if-error machinery as issuer JWKS. Supply a persistent jwksCache on DcaIssuerServerConfig to share state across workers and survive restarts. Freshness is driven by the upstream Cache-Control: max-age (1h fallback); availability past freshness is extended by jwksStaleWindowSeconds (default 30 days) when an upstream refresh fails.
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
- Rotation 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:
import { createDcaPublisher } from '@sesamy/capsule-server';
const publisher = createDcaPublisher({
domain: "news.example.com",
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY!,
rotationSecret: process.env.ROTATION_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: "direct",
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 rotationSecret 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 wraps keys with the issuer's ECDH public key at render time, and the issuer unwraps them with its private key at unlock time. The rotationSecret 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-server
Complete Publisher Example
import { createDcaPublisher } from '@sesamy/capsule-server';
const publisher = createDcaPublisher({
domain: "news.example.com",
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY!,
rotationSecret: process.env.ROTATION_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.manifestScript} </head>
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: "direct" })
);
}
// Normal flow: check subscription, then unlock
return Response.json(
await issuer.unlock(body, {
grantedContentNames: ["bodytext"],
deliveryMode: "direct",
})
);
}
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 wrapping 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
// Wrapping (ECDH P-256 / RSA-OAEP)
wrap, // Wrap key material for an issuer
unwrap, // Unwrap key material
wrapEcdhP256, // ECDH P-256 wrap
unwrapEcdhP256, // ECDH P-256 unwrap
// Rotation (kid derivation)
formatTimeKid, // Format Date β "251023T13"
getCurrentRotationVersions, // Get current + next kid
deriveWrapKey, // HKDF wrapKey derivation
// Encoding
toBase64Url, fromBase64Url, toBase64, fromBase64,
} from '@sesamy/capsule-server';
PHP
The sesamy/capsule-publisher package is a first-class PHP port of the DCA publisher β primarily intended for WordPress plugins that need to encrypt premium content from PHP. It produces the exact same DCA v0.10 wire format as @sesamy/capsule-server, so any DCA-compatible issuer (including the JS one in this repo) can unlock manifests it generates.
Compatibility is verified in both directions on every PR: the PHP suite consumes JS-emitted fixtures, and the JS suite consumes PHP-rendered manifests. See Compatibility tests for details.
The PHP package is publisher-only at the moment β it covers the CMS side (encrypt content, wrap for issuers, sign resourceJWT, build the publisher JWKS, mint share link tokens). The unlock endpoint still runs in Node.js.
Installation
composer require sesamy/capsule-publisher
Requires PHP 8.1+ with ext-openssl, ext-json, ext-hash. RSA-OAEP-SHA256 wrapping uses phpseclib/phpseclib v3 (PHP's bundled OpenSSL only exposes OAEP-SHA1).
Complete Publisher Example
<?php
use Sesamy\Capsule\Publisher\{
Publisher,
PublisherConfig,
RenderOptions,
ContentItem,
IssuerConfig,
};
function requireEnv(string $name): string
{
$value = getenv($name);
if (!is_string($value) || $value === '') {
throw new RuntimeException("Missing required environment variable: $name");
}
return $value;
}
$signingKeyPem = requireEnv('PUBLISHER_ES256_PRIVATE_KEY');
$rotationSecret = requireEnv('ROTATION_SECRET');
$issuerPublicKeyPem = requireEnv('SESAMY_ECDH_PUBLIC_KEY');
$publisher = new Publisher(new PublisherConfig(
domain: 'news.example.com',
signingKeyPem: $signingKeyPem,
rotationSecret: $rotationSecret, // base64
signingKeyId: '2025-10',
));
$result = $publisher->render(new RenderOptions(
resourceId: 'article-123',
contentItems: [
new ContentItem('bodytext', '<p>Premium article bodyβ¦</p>', scope: 'premium'),
],
issuers: [
new IssuerConfig(
issuerName: 'sesamy',
unlockUrl: 'https:__PLACEHOLDER_1__
publicKeyPem: $issuerPublicKeyPem,
keyId: '2025-10',
scopes: ['premium'],
),
],
resourceData: ['title' => 'Hello'],
));
// Embed in your HTML template:
echo $result->manifestScript; // <script type="application/json" class="dca-manifest">β¦</script>
$json = $result->jsonString(); // canonical manifest JSON for an API endpoint
The publisher derives wrapKeys locally via HKDF β no network calls during render(). Issuer keys can also be resolved dynamically from a JWKS URL by passing jwksUri instead of publicKeyPem on the IssuerConfig, with a pluggable cache (IssuerJwksResolver / JwksCache) so issuer key rotation is invisible to the publisher.
Share Link Tokens
use Sesamy\Capsule\Publisher\ShareLinkOptions;
$token = $publisher->createShareLinkToken(new ShareLinkOptions(
resourceId: 'article-123',
contentNames: ['bodytext'],
expiresIn: 7 * 24 * 3600,
maxUses: 1000,
));
$shareUrl = "https://example.com/article/123?share={$token}";
The token is a publisher-signed ES256 JWT β DCA-compatible: the rotation secret stays on the publisher, key material still flows through the normal wrap/unwrap channel.
Publisher JWKS Endpoint
Serve the publisher's signing key as a JWKS document so JWKS-configured issuers pick up rotation automatically.
<?php
use Sesamy\Capsule\Publisher\Jwks\PublisherJwks;
// /.well-known/dca-publishers.json
header('Content-Type: application/json');
header('Cache-Control: max-age=3600');
$publicKeyPem = getenv('PUBLISHER_ES256_PUBLIC_KEY');
if (!is_string($publicKeyPem) || $publicKeyPem === '') {
throw new RuntimeException('Missing PUBLISHER_ES256_PUBLIC_KEY');
}
echo json_encode(PublisherJwks::buildPublisherJwksDocument([
[
'publicKeyPem' => $publicKeyPem,
'kid' => '2025-10',
],
// During rotation, list both keys; mark the old one as retired:
// [
// 'publicKeyPem' => $previousPublicKeyPem, // validated with is_string() the same way
// 'kid' => '2025-09',
// 'status' => 'retired',
// ],
]));
Python
Python support using the cryptography library.
Installation
pip install cryptography
Basic 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.