Capsule Cryptography Glossary

Understanding the cryptographic concepts and key hierarchy used in Capsule.

๐Ÿ”‘ Key Hierarchy

Period Secret

The root secret from which all period keys are derived. Stored securely on the publisher's server (ideally in a KMS like AWS Secrets Manager, HashiCorp Vault, etc.). Called periodSecret in the codebase.

Size: 256 bitsStorage: Server-side onlyRotation: Rarely (causes key migration)

โš ๏ธ Never expose the period secret to clients or embed it in client code.

Period Key

A time-derived AES-256 key that wraps (encrypts) the content key. Derived from the period secret using HKDF with a time-based label as context. Clients can cache period keys to enable offline access and โ€œunlock once, access allโ€ within a time window.

Algorithm: AES-256Purpose: Wrap content keysScope: Per-content-name, per-time-bucketClient caching: Yes (enables offline access)

In the DCA model, period keys are content-specific by construction โ€” the content name is used as the HKDF salt, so "bodytext" and "sidebar" produce different period keys even for the same time bucket.

// Period key derivation (DCA)
periodKey = HKDF(
  IKM:  periodSecret,
  salt: "bodytext",          // content name
  info: "dca|251023T13",     // "dca|" + time bucket
  len:  32                   // AES-256
)

Content Key

The key that actually encrypts article content. Each article (or content item) gets its own unique content key, generated randomly at encryption time. In envelope encryption terminology this is the Data Encryption Key (DEK).

Algorithm: AES-256-GCMPurpose: Encrypt contentScope: Per-article / per-content-itemGeneration: Random (crypto.getRandomValues)

The content key is wrapped with one or more period keys and stored alongside the encrypted content. Clients unwrap the content key using a period key they received from the issuer.

// Content key usage
contentKey = randomBytes(32)
ciphertext = AES-GCM(contentKey, plaintext, iv, aad)
wrappedKey = AES-GCM(periodKey, contentKey, wrapIv)

Issuer Key Pair

Each issuer (subscription provider) holds an asymmetric key pair used for sealing. The publisher encrypts content keys and period keys with the issuer's public key so only that issuer can unseal them.

Algorithm: ECDH P-256 or RSA-OAEPStorage: Issuer serverPurpose: Key sealing / unsealing

ECDH P-256 is the preferred algorithm for DCA. Each seal operation generates a fresh ephemeral key pair, producing a self-contained blob that only the issuer's private key can decrypt.

Publisher Signing Key

An ECDSA P-256 key pair used by the publisher to sign JWTs (ES256). The publisher signs a resourceJWT and per-issuer issuerJWTs. Issuers verify these signatures using the publisher's public key (looked up by domain).

Algorithm: ECDSA P-256 (ES256)Storage: Publisher server (private key)Purpose: JWT signing & verification

๐Ÿ” Encryption Algorithms

AES-256-GCM

Symmetric authenticated encryption (AEAD). AES-256-GCM is used for both content encryption and key wrapping in Capsule. It provides confidentiality and authenticity in a single operation.

Key size: 256 bitsIV size: 96 bits (12 bytes)Auth tag: 128 bitsAAD: Optional (used in DCA)

In the DCA model, content encryption includes Additional Authenticated Data (AAD) that binds the ciphertext to its context โ€” preventing content from being relocated to a different page or domain.

AAD (Additional Authenticated Data)

An AES-GCM feature that authenticates extra context alongside the ciphertext. The AAD is not encrypted, but decryption will fail if the AAD provided at decrypt time doesn't match what was used at encrypt time.

Format: domain|resourceId|contentName|versionEncoding: UTF-8 bytesStorage: In contentSealData.aad
// AAD example
aad = "www.news-site.com|article-123|bodytext|1"

// Encrypt with AAD
ciphertext = AES-GCM(contentKey, plaintext, iv, aad)

// Decrypt โ€” must provide the same AAD
plaintext = AES-GCM-Decrypt(contentKey, ciphertext, iv, aad)
// Fails if AAD doesn't match โ†’ prevents content relocation

ECDH P-256 (Elliptic Curve Diffie-Hellman)

Asymmetric key agreement used for sealing key material for issuers. For each seal operation a fresh ephemeral key pair is generated, and the shared secret is used directly as an AES-256-GCM key.

Curve: P-256 (secp256r1)Shared secret: 32 bytes (x-coordinate)Ephemeral: Fresh key per seal
// ECDH P-256 sealed blob format
| 0-64  | Ephemeral public key (65 bytes, uncompressed) |
| 65-76 | AES-GCM IV (12 bytes)                         |
| 77+   | Ciphertext + 16-byte GCM auth tag             |

RSA-OAEP

Asymmetric encryption using RSA with Optimal Asymmetric Encryption Padding. Used as an alternative sealing algorithm for DCA issuers.

Key size: 2048+ bitsPadding: OAEPHash: SHA-256Max payload: ~190 bytes (2048-bit key)

ECDSA P-256 (ES256)

Elliptic curve digital signature algorithm used for signing DCA JWTs. The publisher signs resourceJWT and issuerJWT tokens with ES256; issuers verify them before unsealing keys.

Curve: P-256Hash: SHA-256Signature: 64 bytes (IEEE P1363 format, r||s)JWT header: {"alg":"ES256","typ":"JWT"}

๐Ÿงฎ Key Derivation

HKDF (HMAC-based Key Derivation Function)

RFC 5869 standard for deriving cryptographic keys from a master secret. Capsule uses HKDF-SHA256 to derive period keys from the period secret.

Hash: SHA-256Input: Period secret + contextOutput: 256-bit keys
// DCA period key derivation
periodKey = HKDF-SHA256(
  IKM:  periodSecret,
  salt: contentName,        // e.g., "bodytext"
  info: "dca|251023T13",    // "dca|" + time bucket label
  len:  32
)

In the DCA model, the salt is the content name, making period keys content-specific by construction. The info parameter encodes the time bucket, ensuring each time window gets a unique key.

Time-Based Key Rotation

Capsule rotates keys automatically using time periods. Each period has its own derived key, providing forward secrecy โ€” old period keys can't decrypt future content.

Hourly buckets (YYMMDDTHH format)Window: Current + next bucket always available
// Time bucket format
"251023T13"     // Oct 23, 2025 at 13:00 UTC (hourly)
"251023T1430"   // Sub-hour variant (30-min)

๐Ÿ“ฆ Key Wrapping & Sealing

Content Key Wrapping

The content key is wrapped (encrypted) with a period key using AES-256-GCM. Each article stores multiple wrapped copies of its content key โ€” one per active time bucket โ€” so clients can unwrap using whichever period key they have cached.

Algorithm: AES-256-GCMNonce: Unique 12-byte IV per wrapWrapped copies: 2 (current + next period)
// DCA sealedContentKeys structure
sealedContentKeys: {
  "bodytext": [
    { t: "251023T13", nonce: "...", key: "..." },
    { t: "251023T14", nonce: "...", key: "..." }
  ]
}

Issuer Sealing

Key material (content keys and period keys) is sealed with the issuer's public key. Only the issuer holding the matching private key can unseal them. Each issuer gets its own sealed copies, enabling multi-issuer support.

ECDH P-256: Ephemeral key per sealRSA-OAEP: Standard ciphertextAuto-detection: From PEM key type
// Issuer sealed structure
issuerData: {
  "sesamy": {
    sealed: {
      "bodytext": {
        contentKey: "base64url...",   // sealed with issuer pubkey
        periodKeys: {
          "251023T13": "base64url...",
          "251023T14": "base64url..."
        }
      }
    },
    unlockUrl: "https://api.sesamy.com/unlock",
    keyId: "2025-10"
  }
}

Envelope Encryption

The pattern of encrypting data with a content key, then wrapping the content key with a period key. This enables โ€œunlock once, access allโ€ โ€” a single period key can unwrap the content key for any article encrypted in that time window.

  • Content encrypted with random content key (fast, symmetric)
  • Content key wrapped with period key (enables time-based access)
  • Period key sealed with issuer's public key (delegated access)

โฑ๏ธ Time Periods & Buckets

Why Time Periods?

Time periods provide several security benefits:

  • Forward Secrecy: Old period keys can't decrypt new content. If a key is compromised, only that period's content is at risk.
  • Automatic Revocation: Keys expire naturally. No need to maintain revocation lists.
  • Subscription Enforcement: Users must have an active subscription to get current period keys.

Period Duration Selection

PeriodUse CaseTrade-offs
30 secondsDemo/testingFrequent rotation visible, more server requests
1 hourNews sites (DCA default)Balance of security and UX
24 hoursMagazinesDaily access pattern, minimal overhead
30 daysMonthly subscriptionsAligns with billing cycle

Clock Drift Handling

To handle clock differences between publisher and client, Capsule always encrypts content keys with both the current and next period key. This ensures content remains accessible during the transition between time buckets.

  • Publisher wraps content key with current + next period keys
  • Client tries each wrapped key until one succeeds
  • Period key cache uses the time bucket label as key

๐Ÿ”„ DCA (Delegated Content Access)

What is DCA?

DCA is an open standard for encrypted content delivery with multi-issuer support. It separates the roles of publisher (encrypts content), issuer (manages access), and client (decrypts content).

  • Publisher: Encrypts content at build time, seals keys for each issuer, signs JWTs
  • Issuer: Verifies JWTs, checks integrity proofs, makes access decisions, unseals and returns keys
  • Client: Parses DCA data from the page, calls an issuer's unlock endpoint, decrypts content

Multiple Content Items

A single page can contain multiple named content items (e.g., "bodytext", "sidebar", "data"). Each item gets its own content key, IV, AAD, and sealed copies. Issuers can grant access to a subset of items per request.

// Multiple content items
contentItems: [
  { contentName: "bodytext", content: "<p>Article...</p>" },
  { contentName: "sidebar", content: "<aside>Premium ...</aside>" },
  { contentName: "data",    content: '{"stats": [...]}',
    contentType: "application/json" }
]

Key Delivery Modes

When a client requests access, the issuer can return keys in two modes:

  • contentKey mode: Returns the raw content key directly. Simplest path โ€” client decrypts immediately.
  • periodKey mode: Returns period keys that the client uses to unwrap the content key from sealedContentKeys. Enables client-side caching: a cached period key can unlock any article in that time window.

Wire Format (HTML)

DCA data is embedded in the page as standard HTML elements:

<!-- DCA metadata and keys -->
<script type="application/json" class="dca-data">
  { "version": "1", "resource": {...}, "resourceJWT": "...", ... }
</script>

<!-- Encrypted content -->
<template class="dca-sealed-content">
  <div data-dca-content-name="bodytext">base64url_ciphertext...</div>
  <div data-dca-content-name="sidebar">base64url_ciphertext...</div>
</template>

๐Ÿ›ก๏ธ JWT Signing & Integrity Proofs

resourceJWT

An ES256 JWT signed by the publisher containing resource metadata. Shared across all issuers. The issuer verifies this JWT to confirm the request originates from a trusted publisher.

// resourceJWT payload
{
  "renderId": "base64url...",        // binds request
  "domain": "www.news-site.com",     // publisher domain
  "issuedAt": "2025-10-23T13:00:00Z",
  "resourceId": "article-123",
  "data": { "section": "politics" }  // access metadata
}

issuerJWT

A per-issuer ES256 JWT containing SHA-256 integrity proofs of every sealed blob for that issuer. The issuer verifies these hashes before unsealing, ensuring the sealed keys haven't been tampered with in transit.

// issuerJWT payload
{
  "renderId": "base64url...",          // must match resourceJWT
  "issuerName": "sesamy",
  "proof": {
    "bodytext": {
      "contentKey": "sha256_hash...",  // hash of sealed blob
      "periodKeys": {
        "251023T13": "sha256_hash...",
        "251023T14": "sha256_hash..."
      }
    }
  }
}

SHA-256 Integrity Proofs

Each sealed blob's base64url string is hashed with SHA-256 and included in the issuerJWT. Before unsealing, the issuer recomputes the hashes and compares them โ€” any mismatch indicates tampering and the request is rejected.

// Proof hash computation
proofHash = base64url(SHA-256(utf8_bytes_of_base64url_string))

// Note: hashes the base64url STRING as UTF-8 bytes,
// not the decoded binary data

renderId (Binding Token)

A random base64url string (16 bytes) generated fresh each render. Present in both the resourceJWT and issuerJWT payloads, the issuer verifies they match โ€” binding the two JWTs together and preventing replay of mismatched tokens.