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 envelope encryption, combining the efficiency of symmetric encryption (AES-256-GCM) with the key management benefits of asymmetric encryption (RSA-OAEP).

Encryption Flow

1. Server-Side Pre-Encryption

Content is encrypted at build time or when published using a Data Encryption Key (DEK) associated with a subscription tier or individual article.

// Generate or retrieve DEK for subscription tier
const dek = getSubscriptionKey("premium"); // 256-bit AES key

// Generate unique IV for this article
const iv = randomBytes(12); // 96 bits for GCM

// Encrypt content with AES-256-GCM
const cipher = createCipheriv("aes-256-gcm", dek, iv);
const encrypted = Buffer.concat([
  cipher.update(content),
  cipher.final(),
  cipher.getAuthTag() // 128-bit authentication tag
]);

// Result: { encryptedContent, iv, tier }

2. HTML Embedding

Encrypted content is embedded directly in the server-rendered HTML, enabling offline access and browser caching.

<template
  id="encrypted-article-123"
  data-encrypted-content="base64-encoded-ciphertext"
  data-iv="base64-encoded-iv"
  data-tier="premium"
/>

3. Client Key Generation

On first visit, the browser generates an RSA-OAEP key pair using the Web Crypto API. The private key is stored in IndexedDB with extractable: false, ensuring it cannot be exported or accessed outside the crypto engine.

const keyPair = await crypto.subtle.generateKey(
  {
    name: "RSA-OAEP",
    modulusLength: 2048,
    publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
    hash: "SHA-256",
  },
  true, // extractable for public key export
  ["wrapKey", "unwrapKey"]
);

// Store in IndexedDB
await indexedDB.put("keypair", keyPair);

4. Key Exchange Protocol

When unlocking content, the client sends its public key to the server. The server wraps the DEK with the client's public key and returns it.

// Client → Server
POST /api/unlock
{
  "tier": "premium",
  "publicKey": "base64-encoded-spki-public-key"
}

// Server → Client
{
  "encryptedDek": "base64-rsa-oaep-wrapped-dek",
  "tier": "premium"
}

5. Client-Side Decryption

The client unwraps the DEK using its private key, then decrypts the content using AES-GCM. The unwrapped DEK is cached in memory for the session.

// Unwrap DEK with private key
const dek = await crypto.subtle.unwrapKey(
  "raw",
  encryptedDek,
  keyPair.privateKey,
  { name: "RSA-OAEP" },
  { name: "AES-GCM", length: 256 },
  false, // non-extractable
  ["decrypt"]
);

// Cache DEK for this tier
dekCache.set(tier, dek);

// Decrypt content
const decrypted = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv },
  dek,
  encryptedContent
);

Multiple DEK Models

Subscription-Based (Recommended)

One DEK per subscription tier. All articles in the tier use the same key. One key exchange unlocks all content in that tier.

Per-Article

Unique DEK for each article. Requires server request per article.

Hybrid

Combination of both. Some articles use tier-level DEKs, others use unique DEKs.

Security Considerations

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.

DEK Storage

Server-side DEKs should be stored in a secure key management system (KMS) in production. Never hardcode DEKs in source code.

Transport Security

The key exchange endpoint must use HTTPS. While the wrapped DEK 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 DEK, as this breaks AES-GCM security.

Implementation Checklist