PHPackages                             sesamy/capsule-publisher - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Security](/categories/security)
4. /
5. sesamy/capsule-publisher

ActiveLibrary[Security](/categories/security)

sesamy/capsule-publisher
========================

PHP port of the Capsule (DCA) CMS publisher. Encrypts content, derives wrap keys, wraps for issuers (ECDH-P256 / RSA-OAEP), and signs ES256 resource JWTs.

v1.0.0(2mo ago)0151MITPHP &gt;=8.1

Since Apr 29Compare

[ Source](https://github.com/sesamyab/capsule)[ Packagist](https://packagist.org/packages/sesamy/capsule-publisher)[ Docs](https://github.com/sesamyab/capsule/tree/main/packages/capsule-publisher-php)[ RSS](/packages/sesamy-capsule-publisher/feed)WikiDiscussions Synced 3w ago

READMEChangelog (10)Dependencies (2)Versions (7)Used By (1)

Capsule
=======

[](#capsule)

An open standard for secure article encryption using envelope encryption (RSA-OAEP + AES-256-GCM) via the Web Crypto API.

Overview
--------

[](#overview)

Capsule provides a complete solution for encrypting and decrypting premium content using envelope encryption:

1. **Server-side**: Encrypts articles using AES-256-GCM with unique Data Encryption Keys (DEKs), then wraps DEKs with tier-level time-bucket keys
2. **Client-side**: Fetches the tier key once, then locally unwraps every article's DEK — no per-article server round-trips

### Two Key Modes

[](#two-key-modes)

ModeWhat the client receivesPer-article network?Use case**Tier Key (KEK)**Tier's key-wrapping key (`keyType: "kek"`)**No** — one call, then all articles in that tier decrypt locallySubscribers with tier access**Per-article DEK**Individual article's DEK (`keyType: "dek"`)Yes, every articleSingle-article purchase, share links### Three Unlock Flows

[](#three-unlock-flows)

Capsule supports three ways to unlock content:

FlowUse CaseUser Auth RequiredKey Mode**Subscription Flow**Logged-in subscribers unlock content✅ YesTier Key (KEK)**Share Link Flow**Anyone with a link can unlock❌ NoPer-article DEK**Single Purchase**One-time article purchase✅ YesPer-article DEKShare Links (Pre-signed Tokens)
-------------------------------

[](#share-links-pre-signed-tokens)

Publishers can generate shareable links that unlock content without requiring user authentication. Perfect for:

- 📱 **Social Media** - Share articles on Facebook, Twitter, LinkedIn
- 📧 **Email Campaigns** - Direct article access in newsletters
- 🎁 **Gift Articles** - "Send this article to a friend"
- ⏰ **Promotions** - Time-limited free access

### How Share Links Work

[](#how-share-links-work)

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                         SHARE LINK FLOW                                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. PUBLISHER GENERATES TOKEN                                               │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  POST /api/share { tier: "premium", expiresIn: "7d" }           │     │
│     │                          ↓                                       │     │
│     │  Token: eyJhbGc... (signed with server secret)                  │     │
│     │  URL: https://example.com/article/xyz?token=eyJhbGc...          │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
│  2. READER CLICKS LINK (no login required)                                  │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  Browser loads page with encrypted content                       │     │
│     │  Client generates ephemeral RSA key pair                         │     │
│     │  Client extracts token from URL                                  │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
│  3. CLIENT REQUESTS UNLOCK                                                  │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  POST /api/unlock {                                              │     │
│     │    token: "eyJhbGc...",      // Proves access                   │     │
│     │    wrappedDek: "...",        // From encrypted article          │     │
│     │    publicKey: "..."          // Client's ephemeral key          │     │
│     │  }                                                               │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
│  4. SERVER VALIDATES & UNLOCKS                                              │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  ✓ Validate token signature                                      │     │
│     │  ✓ Check expiration                                              │     │
│     │  ✓ Log unlock for analytics (article, token, timestamp, IP)     │     │
│     │  → Derive KEK from tier + bucket                                 │     │
│     │  → Unwrap DEK, re-wrap for client's public key                  │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
│  5. CLIENT DECRYPTS                                                         │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  Unwrap DEK with private key → Decrypt content with AES-GCM     │     │
│     │  ✨ Article displayed!                                           │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

```

### Quick Example

[](#quick-example)

```
// Server: Generate share link
import { createTokenManager } from "@sesamy/capsule-server";

const tokens = createTokenManager({ secret: process.env.TOKEN_SECRET });

const token = tokens.generate({
  tier: "premium",
  expiresIn: "7d",
  maxUses: 1000, // Optional: limit uses
  resourceId: "my-article", // Optional: restrict to article
});

const shareUrl = `https://example.com/article/my-article?token=${token}`;
// → Share this URL on social media!
```

```
// Client: Auto-unlock when token in URL
const capsule = new CapsuleClient({
  unlock: async (params) => {
    // Token is automatically included if present in URL
    return fetch("/api/unlock", {
      method: "POST",
      body: JSON.stringify(params),
    }).then((r) => r.json());
  },
});

// Check for token and unlock
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("token");

if (token) {
  await capsule.unlockWithToken(article, token);
}
```

### Analytics &amp; Tracking

[](#analytics--tracking)

Every share link unlock is logged with:

- **Token ID** - Unique identifier for the token
- **Tier** - Which tier was accessed
- **Article ID** - Which article was unlocked
- **Timestamp** - When the unlock occurred
- **IP Address** - Reader's location (for geo analytics)

This gives publishers full visibility: *"Share link X was used 847 times, peaked at 3pm when the Twitter post went viral."*

See detailed documentation:

- [@sesamy/capsule-server](./packages/capsule-server/README.md#share-links--pre-signed-tokens) - Token generation and validation
- [@sesamy/capsule](./packages/capsule-client/README.md#share-link-unlock) - Client-side token handling

Monorepo Structure
------------------

[](#monorepo-structure)

This is a pnpm workspace monorepo containing:

```
capsule/
├── apps/
│   └── demo/                   # Next.js demo application
├── packages/
│   ├── capsule-client/         # Browser decryption library
│   ├── capsule-server/         # Server-side encryption & token management
│   └── capsule-publisher-php/  # PHP port of the CMS publisher (for WordPress)
├── package.json                # Workspace root
└── pnpm-workspace.yaml

```

### Packages

[](#packages)

PackageLanguageDescriptionLocation`@sesamy/capsule`TypeScriptBrowser client-side decryption library[packages/capsule-client](./packages/capsule-client)`@sesamy/capsule-server`TypeScriptServer encryption, tokens &amp; unlock[packages/capsule-server](./packages/capsule-server)`sesamy/capsule-publisher`PHPPHP port of the CMS publisher — for WordPress plugins (Composer)[packages/capsule-publisher-php](./packages/capsule-publisher-php)### Apps

[](#apps)

AppDescriptionLocation`capsule-demo`Next.js demo with encrypted articles[apps/demo](./apps/demo)Development
-----------

[](#development)

### Prerequisites

[](#prerequisites)

- Node.js 18+
- pnpm 8+

### Setup

[](#setup)

```
# Install dependencies
pnpm install

# Build all packages
pnpm build

# Build specific package
pnpm client build

# Run demo
pnpm demo dev

# Run specific app/package commands
pnpm demo    # Run command in demo app
pnpm client  # Run command in client package
```

### Available Scripts

[](#available-scripts)

```
pnpm dev              # Start demo dev server
pnpm build            # Build all packages
pnpm build:client     # Build capsule-client
pnpm build:demo       # Build demo app
pnpm test             # Run tests in all packages
pnpm lint             # Lint all packages
pnpm clean            # Clean all node_modules and build outputs
```

Security Model
--------------

[](#security-model)

### Tier Key Flow (Recommended for Subscribers)

[](#tier-key-flow-recommended-for-subscribers)

Subscribers fetch the tier key **once**, then decrypt every article in that tier locally:

```
┌───────────────────────────────────────────────────────────────────────────────┐
│                     TIER KEY FLOW ("Unlock Once, Access All")                 │
├───────────────────────────────────────────────────────────────────────────────┤
│                                                                               │
│  CMS (build/publish time)              SUBSCRIPTION SERVER                    │
│  ┌─────────────────────────┐           ┌────────────────────────────────────┐ │
│  │ 1. Generate unique DEK  │           │ Derives time-bucket keys from      │ │
│  │ 2. Encrypt article with │           │ period secret + tier + bucket ID   │ │
│  │    DEK (AES-256-GCM)    │           │ using HKDF                         │ │
│  │ 3. Wrap DEK with tier's │           └────────────────────────────────────┘ │
│  │    bucket key (AES-GCM) │                          │                       │
│  │ 4. Embed in HTML        │                          │                       │
│  └─────────────────────────┘                          │                       │
│                                                       │                       │
│  CLIENT (Browser)                                     │                       │
│  ┌─────────────────────────────────────────────────────┼─────────────────────┐ │
│  │                                                     │                     │ │
│  │  First article in tier:                             │                     │ │
│  │  ┌─────────────────────────────┐    ┌───────────────┴──────────────────┐  │ │
│  │  │ Send publicKey + keyId      │───►│ Derive bucket key for tier       │  │ │
│  │  │ (e.g., "premium:123456")   │    │ RSA-encrypt it for user          │  │ │
│  │  │ mode: "tier"               │◄───│ Return { encryptedDek,           │  │ │
│  │  └─────────────────────────────┘    │   keyType: "kek" }              │  │ │
│  │         │                           └─────────────────────────────────┘  │ │
│  │         ▼                                                                │ │
│  │  ┌──────────────────────────────────────┐                                │ │
│  │  │ RSA-unwrap → AES tier key            │                                │ │
│  │  │ Cache tier key in memory             │                                │ │
│  │  │ AES-GCM unwrap article's wrappedDek  │  ← LOCAL, no network           │ │
│  │  │ AES-GCM decrypt article content      │                                │ │
│  │  └──────────────────────────────────────┘                                │ │
│  │                                                                          │ │
│  │  Every subsequent article in same tier + bucket:                         │ │
│  │  ┌──────────────────────────────────────┐                                │ │
│  │  │ Use cached tier key                  │  ← NO SERVER CALL              │ │
│  │  │ AES-GCM unwrap wrappedDek locally    │                                │ │
│  │  │ AES-GCM decrypt content              │                                │ │
│  │  └──────────────────────────────────────┘                                │ │
│  └──────────────────────────────────────────────────────────────────────────┘ │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘

```

### Per-Article DEK Flow (Share Links &amp; Purchases)

[](#per-article-dek-flow-share-links--purchases)

For single-article access, the server unwraps and re-wraps one DEK at a time:

```
┌─────────────────────────────────────────────────────────────────────────┐
│                     PER-ARTICLE DEK FLOW                                 │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  CLIENT (Browser)                    SERVER                             │
│  ┌─────────────────┐                ┌─────────────────────────────────┐ │
│  │ Send:           │                │                                 │ │
│  │  publicKey      │────────────►   │  1. Derive bucket key           │ │
│  │  wrappedDek     │                │  2. Unwrap DEK with bucket key  │ │
│  │  keyId          │   ◄────────    │  3. RSA-wrap DEK for user       │ │
│  └─────────────────┘                │  4. Return { encryptedDek,      │ │
│           │                         │     keyType: "dek" }            │ │
│           ▼                         └─────────────────────────────────┘ │
│  ┌─────────────────┐                                                    │
│  │ RSA-unwrap DEK  │                                                    │
│  │ Decrypt content │                                                    │
│  └─────────────────┘                                                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

```

Security Constraints
--------------------

[](#security-constraints)

- **Private RSA key never leaves the client** - stored as non-extractable in IndexedDB
- **AES DEK only exists in memory** - during decryption process only
- **Each article has a unique DEK** - compromise of one doesn't affect others
- **Dual key model** - Tier keys (unlock all tier articles) + Article-specific keys (single article)
- **Tier key caching** - subscriber fetches tier key once, decrypts all articles locally
- **RSA-OAEP (2048-bit, SHA-256)** for key wrapping
- **AES-256-GCM** for content encryption with authentication

DEK Storage Security Modes
--------------------------

[](#dek-storage-security-modes)

Capsule supports two security modes for storing Data Encryption Keys (DEKs), configurable via the `securityMode` prop on ``:

### Persist Mode (Default)

[](#persist-mode-default)

```

```

AspectDescription**Storage**Non-extractable `CryptoKey` stored directly in IndexedDB**Page Refresh**✅ No network request needed - instant decryption**Exfiltration**✅ Key material cannot be exported via `exportKey()`**Local Attack**⚠️ Attacker with IndexedDB access can *use* the key locally**Best For**Typical premium content, performance-critical apps**How it works**: The `CryptoKey` object is stored directly in IndexedDB (it's structured-cloneable). On page refresh, the key is loaded and used immediately without any network requests. The key is marked as `extractable: false`, meaning `crypto.subtle.exportKey()` will throw an error - an attacker cannot export the raw key bytes to send to their server.

### Session Mode

[](#session-mode)

```

```

AspectDescription**Storage**DEK kept in memory only (not persisted)**Page Refresh**⚠️ Requires network request to fetch new DEK**Exfiltration**✅ Key vanishes when tab closes**Local Attack**✅ Key only exists while tab is open**Best For**Highly sensitive content, security-critical apps**How it works**: The DEK is only stored in JavaScript memory and expires when the page is closed or refreshed. Each page load requires a new network request to obtain a fresh DEK.

### Security Comparison

[](#security-comparison)

```
┌────────────────────┬─────────────────┬─────────────────┐
│ Threat             │ Persist Mode    │ Session Mode    │
├────────────────────┼─────────────────┼─────────────────┤
│ Network sniffing   │ ✅ Protected    │ ✅ Protected    │
│ Server compromise  │ ✅ Protected    │ ✅ Protected    │
│ Key exfiltration   │ ✅ Protected    │ ✅ Protected    │
│ Local key usage    │ ⚠️ Vulnerable  │ ✅ Protected*   │
│ Performance        │ ✅ Fast         │ ⚠️ Network req │
└────────────────────┴─────────────────┴─────────────────┘
* While tab is closed

```

### Important: Client-Side Decryption Limitations

[](#important-client-side-decryption-limitations)

**No browser mechanism can fully protect against a compromised browser.** If an attacker can execute JavaScript in your origin (via XSS, malicious extension, or physical access), they can:

1. Intercept decrypted content after decryption
2. Use stored keys (even non-extractable ones) for local decryption
3. Modify the page to exfiltrate content

**Defense in depth is essential:**

- Content Security Policy (CSP) to prevent script injection
- Subresource Integrity (SRI) for all scripts
- Time-limited DEKs with bucket rotation
- Server-side entitlement checks as the primary gate

Client Library Usage
--------------------

[](#client-library-usage)

### Installation

[](#installation)

```
npm install @sesamy/capsule
# or
pnpm add @sesamy/capsule
```

### Basic Usage

[](#basic-usage)

```
import { CapsuleClient } from "@sesamy/capsule";

const client = new CapsuleClient();

// First time: generate and store keys
const publicKey = await client.generateKeyPair();
// Send publicKey to server for registration

// Later: decrypt an article
const content = await client.decryptArticle(encryptedPayload);
```

### Server-side Encryption (Example)

[](#server-side-encryption-example)

Server-side implementations can use the Web Crypto API in Node.js or any language with RSA-OAEP and AES-GCM support:

```
// Node.js example (see demo for full implementation)
import { subtle } from "crypto";

// 1. Generate random AES-256 key (DEK)
const dek = await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
  "encrypt",
]);

// 2. Encrypt content with DEK
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedContent = await subtle.encrypt(
  { name: "AES-GCM", iv },
  dek,
  new TextEncoder().encode(content),
);

// 3. Wrap DEK with recipient's public RSA key
const publicKey = await subtle.importKey(/* ... */);
const wrappedDek = await subtle.wrapKey("raw", dek, publicKey, {
  name: "RSA-OAEP",
});

// 4. Return payload
return {
  encryptedContent: base64(encryptedContent),
  iv: base64(iv),
  encryptedDek: base64(wrappedDek),
};
```

Demo Features
-------------

[](#demo-features)

The demo application showcases:

- **Encrypted articles** with tier-based and article-specific keys
- **Key management UI** - view and remove stored keys
- **Developer console** - inspect encryption operations
- **Interactive unlock** - choose between tier or article-specific keys
- **Syntax-highlighted code blocks** - documentation with examples

Publishing
----------

[](#publishing)

To publish the client package to npm:

```
cd packages/capsule-client
pnpm build
pnpm publish --access public
```

License
-------

[](#license)

MIT

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance84

Actively maintained with recent releases

Popularity7

Limited adoption so far

Community5

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

81d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/24687879?v=4)[sesamy](/maintainers/sesamy)[@sesamy](https://github.com/sesamy)

---

Tags

wordpressencryptioncmsdcapublishercapsule

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/sesamy-capsule-publisher/health.svg)

```
[![Health](https://phpackages.com/badges/sesamy-capsule-publisher/health.svg)](https://phpackages.com/packages/sesamy-capsule-publisher)
```

###  Alternatives

[leantime/leantime

Open source project management system for non-project managers. Simple like Trello, powerful like Jira. Built with neurodiversity in mind.

10.2k4.0k](/packages/leantime-leantime)[camcima/dukpt-php

DUKPT implementation in PHP

26154.0k](/packages/camcima-dukpt-php)[concrete5/core

Concrete core subtree split

20166.1k53](/packages/concrete5-core)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
