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

RoleResponsibility
PublisherEncrypts 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).
IssuerOwns an ECDH P-256 key pair. On unlock, verifies both JWTs, checks integrity proofs, unseals keys, and returns them to the client.
ClientParses 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+tag

Integrity 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:

  1. Verify resourceJWT signature (ES256) using the publisher's public key, looked up by resource.domain.
  2. Verify issuerJWT signature with the same publisher key; check that renderId matches.
  3. Verify integrity proofs β€” SHA-256 of each sealed blob must match the signed hashes in issuerJWT.
  4. Unseal keys using the issuer's ECDH private key (reverse of the sealing process).
  5. 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:

ModeKey DeliverySecurityBest For
DirectPlaintext base64url keys in HTTPS responseTLS only β€” keys visible in server logs, CDN edges, DevToolsSimple deployments, trusted infrastructure
Client-boundRSA-OAEP wrapped with client's browser public keyEnd-to-end β€” only the originating browser can unwrapHigh-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 automatically

Wrapping 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 decryption

Security Properties of Client-Bound Transport

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:

  1. Verifies request JWTs (same as normal unlock)
  2. Verifies share token signature with the publisher's ES256 key
  3. Validates type discriminator ("dca-share")
  4. Validates domain binding (token domain must match resource domain)
  5. Validates resourceId binding (token must be for this resource)
  6. Checks expiry (reject expired tokens)
  7. Invokes optional onShareToken callback (use-count, audit)
  8. Grants access to content names listed in token ∩ available sealed data
  9. 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

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.

ComponentPublic/SecretStorage
Period SecretπŸ”’ SECRETKMS only (Publisher server)
Period Derivation Algorithmβœ… PublicOpen source code
Period KeysπŸ”’ SECRETDerived on-demand, cached briefly
Content KeysπŸ”’ SECRETWrapped (never in plaintext)
User Private KeysπŸ”’ SECRETBrowser IndexedDB (non-extractable)

Access Revocation

With time-period keys, access is automatically revoked within the period duration (15 minutes):

Publisher Compromise Scenarios

If the publisher is compromised, attacker gets:

Issuer Compromise Scenarios

If the issuer is compromised, attacker gets:

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

What Capsule Does NOT Provide

Capsule is designed for honest users who want convenient access, not for preventing determined adversaries from extracting content.

Implementation Checklist