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)

Issuer (Unlock Server)

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.

SecretRolePrimitiveWho 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 rotation256-bit symmetric HKDF inputPublisher only. Never leaves the publisher, never shared with issuers.

Why two separate secrets?

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" }
          ]
        }
      ]
    }
  }
}

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

Settingkid FormatRevocation Window
rotationIntervalHours: 1 (default)251023T13Up to 1 hour
rotationIntervalHours: 24251023T00Up 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:

FormUse when
publicKeyPem + keyIdYou manage one key per issuer out-of-band (env vars, KMS) and re-deploy on rotation.
jwksUriYou 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:

{
  "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

PickWhen
signingKeyPemOne or two issuers trust the publisher; rotation is a rare, planned event.
jwksUriMany 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:

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

Key Management

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

Want to contribute an implementation? Check out the GitHub repository.