Skip to content

Robust, Ephemeral End-to-End Encryption for the Application Layer. Secure data-in-transit with disposable capsules.

License

Notifications You must be signed in to change notification settings

newben420/ephem

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ephem

Robust, Ephemeral End-to-End Encryption for the Application Layer.

Important

Not a TLS replacement. Ephem is a defense-in-depth security layer designed to protect high-value sensitive data (passwords, PII, financial info) even if TLS is terminated, inspected, or compromised.

Ephem allows your server to issue disposable "Capsules": short-lived, single-use cryptographic containers. Clients seal data into these capsules using hybrid encryption (RSA + AES). Only the server that issued the capsule can open it, and only within the defined restrictions (expiration time, maximum open count).

Once a capsule is used or expires, its private key is destroyed forever.


Why Ephem? (Intent)

Ephem is designed for Application-Layer Encryption.

In modern infrastructure, TLS (Transport Layer Security) often terminates at the perimeter, at your Load Balancer (AWS ALB, Nginx, Cloudflare). From that point onward, traffic often travels unencrypted across your internal network (VPC) to reach your application server.

The Risk: If an attacker compromises your internal network, a proxy, or your logging infrastructure, they can see sensitive data in plain text.

The Solution: Ephem ensures that sensitive fields (like Credit Card numbers, SSNs, Password changes) are encrypted at the source (the user's browser) and remain encrypted until they reach your application logic. Even if the TLS termination point is compromised, the attacker only sees Ephem capsules, which they cannot open.

Ephem is also suitable for Server-to-Server communication for high-security microservices that need to pass secrets over untrusted internal pipes.

Transport Agnostic: Since Ephem operates at the application layer, it works independently of the transport protocol. You can send capsules over HTTP, WebSockets, FTP, email, or even sneakernet.

Horizontal Scaling: Ephem supports high-availability clusters. By using inMemory: false and implementing persistence hooks (backed by Redis, Postgres, etc.), any server in your fleet can open a capsule, regardless of which server issued it. State is shared, not siloed.

Features

  • Zero-Trust Architecture: Key material never leaves the server (private keys) or client (ephemeral symmetric keys) inappropriately.
  • Transport Agnostic: Works over any protocol (HTTP, FTP, WebSockets, etc.) as the encryption happens before transmission.
  • Forward Secrecy: Each request uses a unique, disposable key pair. Past captures of traffic cannot be decrypted even if the server is later compromised.
  • Hybrid Encryption: Combines the convenience of RSA-2048 (for key exchange) with the speed of AES-256-GCM (for payload encryption).
  • Strict Lifecycle Management: Capsules have hard expiration times and maximum usage counts.
  • Persistence Ready: hooks for Redis, SQL, or other storage engines to support horizontal scaling.
  • Zero-Dependency Client: The client library is extremely lightweight and uses the native WebCrypto API.
  • Typescript First: Written in TypeScript with full type definitions.

Cryptography

Ephem relies on standard, proven algorithms:

  • Asymmetric: RSA-OAEP (2048-bit, SHA-256)
  • Symmetric: AES-256-GCM
  • Randomness: crypto.getRandomValues (Browser) / node:crypto (Server)
  • Integrity: Authenticated encryption (AEAD) via AES-GCM (CID is used as Additional Authenticated Data)

Installation

npm install ephem

Browser Support

Ephem Client relies on the Web Crypto API.

  • Works in: All modern browsers (Chrome, Firefox, Safari, Edge).
  • Requirements: Secure Context (HTTPS or localhost).
  • Legacy: Does not work in IE11.

Quick Start

1. Server Setup (Node.js)

Initialize Ephem and create an endpoint to issue capsules, and another to receive sealed data.

import Ephem from "ephem";

// Initialize with default in-memory storage
const ephem = new Ephem({
  inMemory: true,
  logging: true,
});

// --- In your framework (Express, Fastify, etc.) ---

// GET /api/capsule
app.get('/api/capsule', async (req, res) => {
  // Create a capsule that lives for 1 minute and can be opened once
  const capsule = await ephem.createCapsulePromise({
    maxOpens: 1,
    lifetimeDurationMS: 60 * 1000
  });

  if (!capsule) return res.status(500).send("Failed to create capsule");
  
  // Send the PUBLIC key and Capsule ID (CID) to the client
  res.json({
    cid: capsule.cid,
    publicKey: capsule.publicKey
  });
});

// POST /api/submit
app.post('/api/submit', async (req, res) => {
  const { sealedPayload } = req.body;

  // Attempt to open the capsule
  const decryptedData = await ephem.open(sealedPayload);

  if (!decryptedData) {
    return res.status(400).send("Invalid, expired, or exhausted capsule.");
  }

  console.log("Received sensitive data:", decryptedData);
  res.send("Data received securely.");
});

1a. Server Setup (CommonJS)

If you are using require() instead of import:

const Ephem = require("ephem");

// Initialize
const ephem = new Ephem({
  inMemory: true,
  logging: true
});

// ... (Rest of usage is identical)

2. Client Setup (Browser)

The client fetches a capsule and uses it to "seal" data before sending it.

import { seal } from "ephem/client";

async function submitSensitiveData(secretData: string) {
  // 1. Get a fresh capsule from the server
  const response = await fetch('/api/capsule');
  const { cid, publicKey } = await response.json();

  // 2. Seal the data locally (Browser WebCrypto)
  // This generates an AES key, encrypts data, then wraps the AES key with the RSA public key
  const sealedPayload = await seal(secretData, publicKey, cid);

  // 3. Send securely
  await fetch('/api/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ sealedPayload })
  });
}

3. Browser Usage (via CDN)

For projects without a bundler, you can use the pre-built unpkg/CDN version. This exposes the global EphemClient object.

<!-- Load Ephem client from CDN -->
<script src="https://unpkg.com/ephem/dist/index.global.js"></script>

<script>
    "use strict";
    // Example usage
    async function runDemo() {
        try {
            // In a real app, fetch these from your server
            const cid = "5a6d4d70-620a-472d-9edc-c9490ff77c2b"; 
            const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;

            const message = "Hello from browser đź‘‹";

            // Encrypt using the global EphemClient
            const cipher = await EphemClient.seal(
                message,
                PUBLIC_KEY_PEM,
                cid,
                // allowInsecureFallback: Set to true ONLY for local dev over HTTP
                // In production (HTTPS), this should be false or omitted.
                true, 
            );

            console.log("Cipher:", cipher);
        } catch (err) {
            console.error(err);
        }
    };
</script>

How it Works

Ephem uses a Hybrid Encryption scheme encapsulated in a "Capsule" paradigm.

Flow Diagram

  1. Handshake:
    • Server generates an RSA-2048 KeyPair.
    • Server stores PrivateKey + UsageLimits (expiresAt, maxOpens) mapped to a CID (Capsule ID).
    • Server sends PublicKey + CID to Client.
  2. Sealing (Client):
    • Client generates a random AES-256-GCM key (SymKey).
    • Client encrypts the payload with SymKey.
    • Client encrypts SymKey with the Server's PublicKey (RSA-OAEP).
    • Client bundles CID + IV + EncryptedPayload + EncryptedSymKey into a single string.
  3. Opening (Server):
    • Server looks up internal state using CID.
    • Server checks: Is it expired? Has it been used too many times?
    • Server decrypts SymKey using PrivateKey.
    • Server decrypts payload using SymKey.
    • Server increments usage count / destroys capsule if exhausted.

Use Cases

1. Secure Form Submission (Standard)

  • Scenario: User changes their password.
  • Flow:
    1. Client fetches a capsule (maxOpens: 1).
    2. Client seals the new password.
    3. Client sends sealedPassword to server.
    4. Server opens it, hashes the password, and updates DB.

2. Multi-Field Forms (Advanced)

  • Scenario: A Checkout Form with CreditCard, CVV, and SSN.
  • Problem: Requesting 3 separate capsules is slow.
  • Solution: Request one capsule with maxOpens: 3.
// Server: Issue a multi-use capsule
const capsule = await ephem.createCapsulePromise({ maxOpens: 3, lifetimeDurationMS: 60000 });
// Client: Seal each field with the SAME capsule details
const sealedCC  = await seal(ccNumber, pubKey, cid);
const sealedCVV = await seal(cvv,      pubKey, cid);
const sealedSSN = await seal(ssn,      pubKey, cid);

// Send all three
await fetch('/checkout', { body: JSON.stringify({ sealedCC, sealedCVV, sealedSSN }) });

Caution

Concurrency Warning: Serial Unsealing

When opening multiple payloads from the same capsule, you must unseal them serially (one by one), especially if using a database like Redis for persistence.

If you Promise.all([open(a), open(b), open(c)]), the parallel requests might race against the database's openCount check. One of them might read the "old" count before another has written the "new" count, causing you to accidentally exceed the limit or fail unpredictably.

Correct:

// Server Code
const cc  = await ephem.open(req.body.sealedCC); // count becomes 1
const cvv = await ephem.open(req.body.sealedCVV); // count becomes 2
const ssn = await ephem.open(req.body.sealedSSN); // count becomes 3

Strengths & Weaknesses

Feature Ephem Strength Limitation / Weakness
Security Model Perfect Forward Secrecy. Every request has a unique key. Compromising the server now does not compromise past traffic. Not a TLS Replacement. Does not verify server identity (no Certificates). Vulnerable to active Man-in-the-Middle if TLS is missing.
Architecture Zero-Trust. Keys never leave their respective environments. Stateful. Requires the server to "remember" the capsule (RAM or DB). Harder to scale than stateless JWTs.
Performance Hybrid Encryption. Fast AES-256 for payloads. RSA Overhead. Generating 2048-bit RSA keys is CPU intensive. Not suitable for high-frequency "chat" messages.
Usability Strict Types. Full TypeScript support. Simple seal() / open() API. Payload Size. Sealed strings are larger than plaintext due to base64 encoding and key wrapping.
Compliance Auditable. You define exactly where and when decryption happens. Key Management. You are responsible for the persistence layer if scaling beyond one server.
Data Suitability Optimized for Secrets. Perfect for PII, API Keys, CC numbers, and passwords. Small Payloads Only. Do not use for database dumps or file uploads. For >1MB files, use a streaming encryption library.

Threat Model

What Ephem PROTECTS Against

  • Compromised TLS Termination: If your load balancer or CDN terminates TLS and passes unencrypted traffic to your backend, that traffic is vulnerable to internal snoopers. Ephem keeps it encrypted until it reaches your application logic.
  • Man-in-the-Middle (MITM): If a corporate proxy or malicious actor intercepts the request (even with a trusted root CA), they cannot read the payload because they lack the ephemeral private key, which never leaves your app server's memory.
  • Replay Attacks: Because capsules have maxOpens (default: 1), a captured valid request cannot be replayed to trigger a second action (e.g., duplicate payment).
  • Accidental Logging: If you accidentally log the raw request body, you are only logging ciphertext.
  • Accidental Data Persistence: If sensitive data is accidentally written to disk (swap files, core dumps, or persistent logs), it remains encrypted.

What Ephem Does NOT Protect Against

  • XSS (Cross-Site Scripting): If an attacker can run JS on your page, they can hook the seal() function or read the input before it is sealed.
  • Compromised Server: If the attacker controls your server, they can access the memory where private keys are stored (temporarily).
  • Compromised Client Device: Keyloggers or malware on the user's machine.
  • Weak Application Authentication: Ephem encrypts the payload, but it does not authenticate the user. You still need strong session management (cookies, JWTs) to ensure who submitted the capsule.

API Reference

new Ephem(config)

Creates a new Ephem instance.

const ephem = new Ephem({
  inMemory: true,
  maxConcurrentCapsules: 1000,
  logging: true
});

Configuration Options:

Option Type Default Description
inMemory boolean true If true, store capsules in a JS Map. If false, you must provide the persistence hooks.
maxConcurrentCapsules number Infinity DoS Protection. Limits the maximum number of active capsules in memory. If creating a new capsule would exceed this, createCapsule returns null.
defaultCaptsuleLifetimeMS number Infinity Default duration before a capsule expires. Prevents stale keys from lingering forever.
capsuleCleanupIntervalMS number 60_000 How often the internal "reaper" runs to delete expired/exhausted capsules from memory.
logging boolean false Enable verbose internal logs. Useful for debugging but spammy in production.
onCapsuleCreation Function undefined Persistence Hook. Called when a capsule is created. Signature: (cid, privateKey, openCount, maxOpens, expiresAt). Return true to confirm storage.
onCapsuleOpen Function undefined Persistence Hook. Called after a successful decryption. Signature: (cid, openCount). Return true on success.
OnGetCapsuleByCID Function undefined Persistence Hook. Retrieve capsule. Signature: (cid). Returns { privateKey, maxOpens, openCount, expiresAt } or null.
onCapsuleDelete Function undefined Persistence Hook. Called to delete/expire. Signature: (cid, reason). Reason is 'expired' or 'max_opened'.

ephem.createCapsulePromise(config)

Creates a new capsule and returns a Promise resolving to the public details.

Parameters:

  • config.maxOpens (number, default 0): The number of times this capsule can be decrypted. 0 usually means infinite (depending on your logic), but 1 is recommended for strict one-time use.
  • config.lifetimeDurationMS (number): Milliseconds until the capsule expires.

Returns:

  • Promise<{ cid: string, publicKey: string } | null>
  • Returns null if the capsule could not be created (e.g., maxConcurrentCapsules limit reached or storage failure).

ephem.open(sealedPayload)

Attempts to decrypt a sealed payload.

Parameters:

  • sealedPayload (string): The dot-separated string generated by the client.

Returns:

  • Promise<string | null>
  • Returns the decrypted plaintext string if successful.
  • Returns null if:
    • The capsule ID (CID) is not found.
    • The capsule has expired.
    • The capsule has exceeded its maxOpens count.
    • The decryption fails (wrong key, tampering detected).

ephem.getAnalytics()

Returns a snapshot of the usage stats since the instance started.

Returns:

  • { totalCreatedCapsules: number, totalUsedCapsules: number, totalExpiredCapsules: number }

seal(text, publicKey, cid, allowInsecureFallback) (Client)

The core client-side encryption function. Available in ephem/client (Node/Bundlers) or EphemClient.seal (CDN).

Parameters:

  • text (string): The sensitive data to encrypt.
  • publicKey (string): The PEM-formatted public key string received from the server.
  • cid (string): The Capsule ID to attach to this payload.
  • allowInsecureFallback (boolean, default false):
    • Crucial for Development. WebCrypto is only available in Secure Contexts (HTTPS or localhost).
    • If you are testing on HTTP (e.g., a local network IP http://192.168.x.x), WebCrypto will throw an error and the client will crash.
    • Effect: Setting this to true causes the client to bypass encryption entirely. It sends the plaintext data prefixed with ##INSECURE##.
    • SECURITY WARNING: Data is NOT encrypted in this mode. This is strictly for debugging connectivity in non-HTTPS development environments. NEVER enable this in production.

Full Redis Implementation

To scale Ephem across multiple server instances, you must use an external store like Redis.

You must implement all 4 hooks.

import Ephem from "ephem";
import Redis from "ioredis";

const redis = new Redis();
const getKey = (cid: string) => `ephem:capsule:${cid}`;

const ephem = new Ephem({
  inMemory: false,

  /**
   * Hook 1: Creation
   * Store the new capsule, including the PRIVATE KEY.
   */
  onCapsuleCreation: async (cid, privateKey, openCount, maxOpens, expiresAt) => {
    try {
      await redis.hset(getKey(cid), {
        privateKey, // <--- CRITICAL: Store this securely!
        openCount,
        maxOpens,
        expiresAt
      });

      // Set Redis TTL so it auto-deletes roughly when the capsule expires
      if (expiresAt !== Infinity) {
        const ttlSeconds = Math.ceil((expiresAt - Date.now()) / 1000);
        if (ttlSeconds > 0) {
          await redis.expire(getKey(cid), ttlSeconds);
        }
      }
      return true;
    } catch (err) {
      console.error("Redis error on creation:", err);
      return false; // Returning false will cause createCapsule to return null
    }
  },

  /**
   * Hook 2: Retrieval
   * Fetch the capsule state so Ephem can decrypt the payload.
   */
  OnGetCapsuleByCID: async (cid) => {
    try {
      const data = await redis.hgetall(getKey(cid));
      
      // If empty or missing private key, return null
      if (!data || !data.privateKey) return null;

      return {
        privateKey: data.privateKey,
        maxOpens: Number(data.maxOpens),
        openCount: Number(data.openCount),
        expiresAt: Number(data.expiresAt)
      };
    } catch (err) {
      console.error("Redis error on get:", err);
      return null;
    }
  },

  /**
   * Hook 3: Open (Usage Update)
   * Called AFTER a successful decryption. We must increment the usage count in DB.
   */
  onCapsuleOpen: async (cid, openCount) => {
    try {
      // Ephem tracks the new 'openCount' in memory and passes it here.
      // We just need to persist it.
      await redis.hset(getKey(cid), "openCount", openCount);
      return true;
    } catch (err) {
      console.error("Redis error on update:", err);
      return false;
    }
  },

  /**
   * Hook 4: Deletion
   * Explicit cleanup (e.g., if maxOpens reached).
   */
  onCapsuleDelete: async (cid, reason) => {
    // Reason is 'expired' or 'max_opened'
    try {
      await redis.del(getKey(cid));
      console.log(`Deleted capsule ${cid} due to ${reason}`);
    } catch (err) {
      console.error("Redis error on delete:", err);
    }
  }
});

Cleanup Responsibilities (Persistent Mode)

When inMemory: false, Ephem's internal capsuleCleanUp loop is disabled. You are responsible for removing expired capsules from your storage backend.

If you are using Redis, simpler is better: use the EXPIRE command (TTL) when setting the key, as shown in the example above. Redis will automatically remove the key when it expires.

If you are using a SQL Database (Postgres, MySQL), you should run a periodic cleanup job (CRON).

Example: SQL Cleanup Cron

// run-cleanup.ts
import db from './my-db';

async function cleanup() {
  const now = Date.now();
  
  // Delete expired capsules
  await db.query('DELETE FROM capsules WHERE expires_at < $1', [now]);
  
  // Optional: Delete exhausted capsules if your logic allows
  // (Usually handled in real-time by onCapsuleOpen, but good for safety)
  await db.query('DELETE FROM capsules WHERE open_count >= max_opens AND max_opens > 0');
}

// Run every minute
setInterval(cleanup, 60 * 1000);

Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

License

ISC

Releases

No releases published

Packages

No packages published