ephem 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +481 -0
- package/dist/client/index.d.mts +3 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +88 -0
- package/dist/client/index.mjs +63 -0
- package/dist/index.d.mts +87 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +308 -0
- package/dist/index.mjs +291 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# Ephem
|
|
2
|
+
|
|
3
|
+
**Robust, Ephemeral End-to-End Encryption for the Application Layer.**
|
|
4
|
+
|
|
5
|
+
> [!IMPORTANT]
|
|
6
|
+
> **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.
|
|
7
|
+
|
|
8
|
+
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).
|
|
9
|
+
|
|
10
|
+
Once a capsule is used or expires, its private key is destroyed forever.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Why Ephem? (Intent)
|
|
15
|
+
|
|
16
|
+
Ephem is designed for **Application-Layer Encryption**.
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
**The Risk:** If an attacker compromises your internal network, a proxy, or your logging infrastructure, they can see sensitive data in plain text.
|
|
21
|
+
|
|
22
|
+
**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.
|
|
23
|
+
|
|
24
|
+
Ephem is also suitable for **Server-to-Server** communication for high-security microservices that need to pass secrets over untrusted internal pipes.
|
|
25
|
+
|
|
26
|
+
Ephem is also suitable for **Server-to-Server** communication for high-security microservices that need to pass secrets over untrusted internal pipes.
|
|
27
|
+
|
|
28
|
+
**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.
|
|
29
|
+
|
|
30
|
+
**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.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Zero-Trust Architecture**: Key material never leaves the server (private keys) or client (ephemeral symmetric keys) inappropriately.
|
|
35
|
+
- **Transport Agnostic**: Works over any protocol (HTTP, FTP, WebSockets, etc.) as the encryption happens before transmission.
|
|
36
|
+
- **Forward Secrecy**: Each request uses a unique, disposable key pair. Past captures of traffic cannot be decrypted even if the server is later compromised.
|
|
37
|
+
- **Hybrid Encryption**: Combines the convenience of RSA-2048 (for key exchange) with the speed of AES-256-GCM (for payload encryption).
|
|
38
|
+
- **Strict Lifecycle Management**: Capsules have hard expiration times and maximum usage counts.
|
|
39
|
+
- **Persistence Ready**: hooks for Redis, SQL, or other storage engines to support horizontal scaling.
|
|
40
|
+
- **Zero-Dependency Client**: The client library is extremely lightweight and uses the native WebCrypto API.
|
|
41
|
+
- **Typescript First**: Written in TypeScript with full type definitions.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install ephem
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. Server Setup (Node.js)
|
|
56
|
+
|
|
57
|
+
Initialize Ephem and create an endpoint to issue capsules, and another to receive sealed data.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import Ephem from "ephem";
|
|
61
|
+
|
|
62
|
+
// Initialize with default in-memory storage
|
|
63
|
+
const ephem = new Ephem({
|
|
64
|
+
inMemory: true,
|
|
65
|
+
logging: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// --- In your framework (Express, Fastify, etc.) ---
|
|
69
|
+
|
|
70
|
+
// GET /api/capsule
|
|
71
|
+
app.get('/api/capsule', async (req, res) => {
|
|
72
|
+
// Create a capsule that lives for 1 minute and can be opened once
|
|
73
|
+
const capsule = await ephem.createCapsulePromise({
|
|
74
|
+
maxOpens: 1,
|
|
75
|
+
lifetimeDurationMS: 60 * 1000
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!capsule) return res.status(500).send("Failed to create capsule");
|
|
79
|
+
|
|
80
|
+
// Send the PUBLIC key and Capsule ID (CID) to the client
|
|
81
|
+
res.json({
|
|
82
|
+
cid: capsule.cid,
|
|
83
|
+
publicKey: capsule.publicKey
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// POST /api/submit
|
|
88
|
+
app.post('/api/submit', async (req, res) => {
|
|
89
|
+
const { sealedPayload } = req.body;
|
|
90
|
+
|
|
91
|
+
// Attempt to open the capsule
|
|
92
|
+
const decryptedData = await ephem.open(sealedPayload);
|
|
93
|
+
|
|
94
|
+
if (!decryptedData) {
|
|
95
|
+
return res.status(400).send("Invalid, expired, or exhausted capsule.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log("Received sensitive data:", decryptedData);
|
|
99
|
+
res.send("Data received securely.");
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 1a. Server Setup (CommonJS)
|
|
104
|
+
|
|
105
|
+
If you are using `require()` instead of `import`:
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
const Ephem = require("ephem");
|
|
109
|
+
|
|
110
|
+
// Initialize
|
|
111
|
+
const ephem = new Ephem({
|
|
112
|
+
inMemory: true,
|
|
113
|
+
logging: true
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ... (Rest of usage is identical)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 2. Client Setup (Browser)
|
|
120
|
+
|
|
121
|
+
The client fetches a capsule and uses it to "seal" data before sending it.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { seal } from "ephem/client";
|
|
125
|
+
|
|
126
|
+
async function submitSensitiveData(secretData: string) {
|
|
127
|
+
// 1. Get a fresh capsule from the server
|
|
128
|
+
const response = await fetch('/api/capsule');
|
|
129
|
+
const { cid, publicKey } = await response.json();
|
|
130
|
+
|
|
131
|
+
// 2. Seal the data locally (Browser WebCrypto)
|
|
132
|
+
// This generates an AES key, encrypts data, then wraps the AES key with the RSA public key
|
|
133
|
+
const sealedPayload = await seal(secretData, publicKey, cid);
|
|
134
|
+
|
|
135
|
+
// 3. Send securely
|
|
136
|
+
await fetch('/api/submit', {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({ sealedPayload })
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 3. Browser Usage (via CDN)
|
|
145
|
+
|
|
146
|
+
For projects without a bundler, you can use the pre-built unpkg/CDN version. This exposes the global `EphemClient` object.
|
|
147
|
+
|
|
148
|
+
```html
|
|
149
|
+
<!-- Load Ephem client from CDN -->
|
|
150
|
+
<script src="https://unpkg.com/ephem/dist/client/index.global.js"></script>
|
|
151
|
+
|
|
152
|
+
<script>
|
|
153
|
+
"use strict";
|
|
154
|
+
// Example usage
|
|
155
|
+
async function runDemo() {
|
|
156
|
+
try {
|
|
157
|
+
// In a real app, fetch these from your server
|
|
158
|
+
const cid = "5a6d4d70-620a-472d-9edc-c9490ff77c2b";
|
|
159
|
+
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
160
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
|
161
|
+
-----END PUBLIC KEY-----`;
|
|
162
|
+
|
|
163
|
+
const message = "Hello from browser 👋";
|
|
164
|
+
|
|
165
|
+
// Encrypt using the global EphemClient
|
|
166
|
+
const cipher = await EphemClient.seal(
|
|
167
|
+
message,
|
|
168
|
+
PUBLIC_KEY_PEM,
|
|
169
|
+
cid,
|
|
170
|
+
// allowInsecureFallback: Set to true ONLY for local dev over HTTP
|
|
171
|
+
// In production (HTTPS), this should be false or omitted.
|
|
172
|
+
true,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
console.log("Cipher:", cipher);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(err);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
</script>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## How it Works
|
|
186
|
+
|
|
187
|
+
Ephem uses a **Hybrid Encryption** scheme encapsulated in a "Capsule" paradigm.
|
|
188
|
+
|
|
189
|
+
### Flow Diagram
|
|
190
|
+
|
|
191
|
+
1. **Handshake**:
|
|
192
|
+
* **Server** generates an RSA-2048 KeyPair.
|
|
193
|
+
* **Server** stores `PrivateKey` + `UsageLimits` (expiresAt, maxOpens) mapped to a `CID` (Capsule ID).
|
|
194
|
+
* **Server** sends `PublicKey` + `CID` to Client.
|
|
195
|
+
2. **Sealing (Client)**:
|
|
196
|
+
* **Client** generates a random AES-256-GCM key (`SymKey`).
|
|
197
|
+
* **Client** encrypts the payload with `SymKey`.
|
|
198
|
+
* **Client** encrypts `SymKey` with the Server's `PublicKey` (RSA-OAEP).
|
|
199
|
+
* **Client** bundles `CID` + `IV` + `EncryptedPayload` + `EncryptedSymKey` into a single string.
|
|
200
|
+
3. **Opening (Server)**:
|
|
201
|
+
* **Server** looks up internal state using `CID`.
|
|
202
|
+
* **Server** checks: Is it expired? Has it been used too many times?
|
|
203
|
+
* **Server** decrypts `SymKey` using `PrivateKey`.
|
|
204
|
+
* **Server** decrypts payload using `SymKey`.
|
|
205
|
+
* **Server** increments usage count / destroys capsule if exhausted.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
## Use Cases
|
|
212
|
+
|
|
213
|
+
### 1. Secure Form Submission (Standard)
|
|
214
|
+
|
|
215
|
+
* **Scenario:** User changes their password.
|
|
216
|
+
* **Flow:**
|
|
217
|
+
1. Client fetches a capsule (`maxOpens: 1`).
|
|
218
|
+
2. Client seals the new password.
|
|
219
|
+
3. Client sends `sealedPassword` to server.
|
|
220
|
+
4. Server opens it, hashes the password, and updates DB.
|
|
221
|
+
|
|
222
|
+
### 2. Multi-Field Forms (Advanced)
|
|
223
|
+
|
|
224
|
+
* **Scenario:** A Checkout Form with `CreditCard`, `CVV`, and `SSN`.
|
|
225
|
+
* **Problem:** Requesting 3 separate capsules is slow.
|
|
226
|
+
* **Solution:** Request **one** capsule with `maxOpens: 3`.
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
// Server: Issue a multi-use capsule
|
|
230
|
+
const capsule = await ephem.createCapsulePromise({ maxOpens: 3, lifetimeDurationMS: 60000 });
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// Client: Seal each field with the SAME capsule details
|
|
235
|
+
const sealedCC = await seal(ccNumber, pubKey, cid);
|
|
236
|
+
const sealedCVV = await seal(cvv, pubKey, cid);
|
|
237
|
+
const sealedSSN = await seal(ssn, pubKey, cid);
|
|
238
|
+
|
|
239
|
+
// Send all three
|
|
240
|
+
await fetch('/checkout', { body: JSON.stringify({ sealedCC, sealedCVV, sealedSSN }) });
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
> [!CAUTION]
|
|
244
|
+
> **Concurrency Warning: Serial Unsealing**
|
|
245
|
+
>
|
|
246
|
+
> 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.
|
|
247
|
+
>
|
|
248
|
+
> 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.
|
|
249
|
+
>
|
|
250
|
+
> **Correct:**
|
|
251
|
+
> ```typescript
|
|
252
|
+
> // Server Code
|
|
253
|
+
> const cc = await ephem.open(req.body.sealedCC); // count becomes 1
|
|
254
|
+
> const cvv = await ephem.open(req.body.sealedCVV); // count becomes 2
|
|
255
|
+
> const ssn = await ephem.open(req.body.sealedSSN); // count becomes 3
|
|
256
|
+
> ```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Strengths & Weaknesses
|
|
261
|
+
|
|
262
|
+
| Feature | Ephem Strength | Limitation / Weakness |
|
|
263
|
+
| :--- | :--- | :--- |
|
|
264
|
+
| **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. |
|
|
265
|
+
| **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. |
|
|
266
|
+
| **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. |
|
|
267
|
+
| **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. |
|
|
268
|
+
| **Compliance** | **Auditable.** You define exactly where and when decryption happens. | **Key Management.** You are responsible for the persistence layer if scaling beyond one server. |
|
|
269
|
+
| **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. |
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Threat Model
|
|
274
|
+
|
|
275
|
+
### What Ephem PROTECTS Against
|
|
276
|
+
|
|
277
|
+
* **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.
|
|
278
|
+
* **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.
|
|
279
|
+
* **Replay Attacks**: Because capsules have `maxOpens` (default: 1), a captured valid request cannot be replayed to trigger a second action (e.g., duplicate payment).
|
|
280
|
+
* **Accidental Logging**: If you accidentally log the raw request body, you are only logging ciphertext.
|
|
281
|
+
|
|
282
|
+
### What Ephem Does NOT Protect Against
|
|
283
|
+
|
|
284
|
+
* **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.
|
|
285
|
+
* **Compromised Server**: If the attacker controls your server, they can access the memory where private keys are stored (temporarily).
|
|
286
|
+
* **Compromised Client Device**: Keyloggers or malware on the user's machine.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## API Reference
|
|
291
|
+
|
|
292
|
+
### `new Ephem(config)`
|
|
293
|
+
|
|
294
|
+
Creates a new Ephem instance.
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
const ephem = new Ephem({
|
|
298
|
+
inMemory: true,
|
|
299
|
+
maxConcurrentCapsules: 1000,
|
|
300
|
+
logging: true
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Configuration Options:**
|
|
305
|
+
|
|
306
|
+
| Option | Type | Default | Description |
|
|
307
|
+
| :--- | :--- | :--- | :--- |
|
|
308
|
+
| **`inMemory`** | `boolean` | `true` | If `true`, store capsules in a JS Map. If `false`, you **must** provide the persistence hooks. |
|
|
309
|
+
| **`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`. |
|
|
310
|
+
| **`defaultCaptsuleLifetimeMS`** | `number` | `Infinity` | Default duration before a capsule expires. Prevents stale keys from lingering forever. |
|
|
311
|
+
| **`capsuleCleanupIntervalMS`** | `number` | `60_000` | How often the internal "reaper" runs to delete expired/exhausted capsules from memory. |
|
|
312
|
+
| **`logging`** | `boolean` | `false` | Enable verbose internal logs. Useful for debugging but spammy in production. |
|
|
313
|
+
| **`onCapsuleCreation`** | `Function` | `undefined` | **Persistence Hook.** Called when a capsule is created. Signature: `(cid, privateKey, openCount, maxOpens, expiresAt)`. Return `true` to confirm storage. |
|
|
314
|
+
| **`onCapsuleOpen`** | `Function` | `undefined` | **Persistence Hook.** Called after a successful decryption. Signature: `(cid, openCount)`. Return `true` on success. |
|
|
315
|
+
| **`OnGetCapsuleByCID`** | `Function` | `undefined` | **Persistence Hook.** Retrieve capsule. Signature: `(cid)`. Returns `{ privateKey, maxOpens, openCount, expiresAt }` or `null`. |
|
|
316
|
+
| **`onCapsuleDelete`** | `Function` | `undefined` | **Persistence Hook.** Called to delete/expire. Signature: `(cid, reason)`. Reason is `'expired'` or `'max_opened'`. |
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### `ephem.createCapsulePromise(config)`
|
|
321
|
+
|
|
322
|
+
Creates a new capsule and returns a Promise resolving to the public details.
|
|
323
|
+
|
|
324
|
+
**Parameters:**
|
|
325
|
+
* `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.
|
|
326
|
+
* `config.lifetimeDurationMS` (number): Milliseconds until the capsule expires.
|
|
327
|
+
|
|
328
|
+
**Returns:**
|
|
329
|
+
* `Promise<{ cid: string, publicKey: string } | null>`
|
|
330
|
+
* Returns `null` if the capsule could not be created (e.g., `maxConcurrentCapsules` limit reached or storage failure).
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
### `ephem.open(sealedPayload)`
|
|
335
|
+
|
|
336
|
+
Attempts to decrypt a sealed payload.
|
|
337
|
+
|
|
338
|
+
**Parameters:**
|
|
339
|
+
* `sealedPayload` (string): The dot-separated string generated by the client.
|
|
340
|
+
|
|
341
|
+
**Returns:**
|
|
342
|
+
* `Promise<string | null>`
|
|
343
|
+
* Returns the **decrypted plaintext** string if successful.
|
|
344
|
+
* Returns `null` if:
|
|
345
|
+
* The capsule ID (CID) is not found.
|
|
346
|
+
* The capsule has expired.
|
|
347
|
+
* The capsule has exceeded its `maxOpens` count.
|
|
348
|
+
* The decryption fails (wrong key, tampering detected).
|
|
349
|
+
|
|
350
|
+
* The decryption fails (wrong key, tampering detected).
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
### `ephem.getAnalytics()`
|
|
355
|
+
|
|
356
|
+
Returns a snapshot of the usage stats since the instance started.
|
|
357
|
+
|
|
358
|
+
**Returns:**
|
|
359
|
+
* `{ totalCreatedCapsules: number, totalUsedCapsules: number, totalExpiredCapsules: number }`
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
### `seal(text, publicKey, cid, allowInsecureFallback)` (Client)
|
|
364
|
+
|
|
365
|
+
The core client-side encryption function. Available in `ephem/client` (Node/Bundlers) or `EphemClient.seal` (CDN).
|
|
366
|
+
|
|
367
|
+
**Parameters:**
|
|
368
|
+
* **`text`** (string): The sensitive data to encrypt.
|
|
369
|
+
* **`publicKey`** (string): The PEM-formatted public key string received from the server.
|
|
370
|
+
* **`cid`** (string): The Capsule ID to attach to this payload.
|
|
371
|
+
* **`allowInsecureFallback`** (boolean, default `false`):
|
|
372
|
+
* **Crucial for Development.** WebCrypto is **only** available in Secure Contexts (HTTPS or `localhost`).
|
|
373
|
+
* 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.
|
|
374
|
+
* **Effect:** Setting this to `true` causes the client to bypass encryption entirely. It sends the plaintext data prefixed with `##INSECURE##`.
|
|
375
|
+
* **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.
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
### Full Redis Implementation
|
|
380
|
+
|
|
381
|
+
To scale Ephem across multiple server instances, you must use an external store like Redis.
|
|
382
|
+
|
|
383
|
+
You must implement **all 4 hooks**.
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import Ephem from "ephem";
|
|
387
|
+
import Redis from "ioredis";
|
|
388
|
+
|
|
389
|
+
const redis = new Redis();
|
|
390
|
+
const getKey = (cid: string) => `ephem:capsule:${cid}`;
|
|
391
|
+
|
|
392
|
+
const ephem = new Ephem({
|
|
393
|
+
inMemory: false,
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Hook 1: Creation
|
|
397
|
+
* Store the new capsule, including the PRIVATE KEY.
|
|
398
|
+
*/
|
|
399
|
+
onCapsuleCreation: async (cid, privateKey, openCount, maxOpens, expiresAt) => {
|
|
400
|
+
try {
|
|
401
|
+
await redis.hset(getKey(cid), {
|
|
402
|
+
privateKey, // <--- CRITICAL: Store this securely!
|
|
403
|
+
openCount,
|
|
404
|
+
maxOpens,
|
|
405
|
+
expiresAt
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Set Redis TTL so it auto-deletes roughly when the capsule expires
|
|
409
|
+
if (expiresAt !== Infinity) {
|
|
410
|
+
const ttlSeconds = Math.ceil((expiresAt - Date.now()) / 1000);
|
|
411
|
+
if (ttlSeconds > 0) {
|
|
412
|
+
await redis.expire(getKey(cid), ttlSeconds);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.error("Redis error on creation:", err);
|
|
418
|
+
return false; // Returning false will cause createCapsule to return null
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Hook 2: Retrieval
|
|
424
|
+
* Fetch the capsule state so Ephem can decrypt the payload.
|
|
425
|
+
*/
|
|
426
|
+
OnGetCapsuleByCID: async (cid) => {
|
|
427
|
+
try {
|
|
428
|
+
const data = await redis.hgetall(getKey(cid));
|
|
429
|
+
|
|
430
|
+
// If empty or missing private key, return null
|
|
431
|
+
if (!data || !data.privateKey) return null;
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
privateKey: data.privateKey,
|
|
435
|
+
maxOpens: Number(data.maxOpens),
|
|
436
|
+
openCount: Number(data.openCount),
|
|
437
|
+
expiresAt: Number(data.expiresAt)
|
|
438
|
+
};
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error("Redis error on get:", err);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Hook 3: Open (Usage Update)
|
|
447
|
+
* Called AFTER a successful decryption. We must increment the usage count in DB.
|
|
448
|
+
*/
|
|
449
|
+
onCapsuleOpen: async (cid, openCount) => {
|
|
450
|
+
try {
|
|
451
|
+
// Ephem tracks the new 'openCount' in memory and passes it here.
|
|
452
|
+
// We just need to persist it.
|
|
453
|
+
await redis.hset(getKey(cid), "openCount", openCount);
|
|
454
|
+
return true;
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.error("Redis error on update:", err);
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Hook 4: Deletion
|
|
463
|
+
* Explicit cleanup (e.g., if maxOpens reached).
|
|
464
|
+
*/
|
|
465
|
+
onCapsuleDelete: async (cid, reason) => {
|
|
466
|
+
// Reason is 'expired' or 'max_opened'
|
|
467
|
+
try {
|
|
468
|
+
await redis.del(getKey(cid));
|
|
469
|
+
console.log(`Deleted capsule ${cid} due to ${reason}`);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.error("Redis error on delete:", err);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## Contributing
|
|
478
|
+
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
|
479
|
+
|
|
480
|
+
## License
|
|
481
|
+
[ISC](/LICENSE)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/client/index.ts
|
|
21
|
+
var client_exports = {};
|
|
22
|
+
__export(client_exports, {
|
|
23
|
+
seal: () => seal
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(client_exports);
|
|
26
|
+
function pemToArrayBuffer(pem) {
|
|
27
|
+
const b64 = pem.replace(/-----BEGIN PUBLIC KEY-----/, "").replace(/-----END PUBLIC KEY-----/, "").replace(/\s+/g, "");
|
|
28
|
+
const binary = atob(b64);
|
|
29
|
+
const bytes = new Uint8Array(binary.length);
|
|
30
|
+
for (let i = 0; i < binary.length; i++) {
|
|
31
|
+
bytes[i] = binary.charCodeAt(i);
|
|
32
|
+
}
|
|
33
|
+
return bytes.buffer;
|
|
34
|
+
}
|
|
35
|
+
function base64Encode(data) {
|
|
36
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
37
|
+
let binary = "";
|
|
38
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
39
|
+
binary += String.fromCharCode(bytes[i] || 0);
|
|
40
|
+
}
|
|
41
|
+
return btoa(binary);
|
|
42
|
+
}
|
|
43
|
+
async function seal(text, publicKeyPem, cid, allowInsecureFallback = false) {
|
|
44
|
+
if (!globalThis.crypto?.subtle) {
|
|
45
|
+
if (allowInsecureFallback) {
|
|
46
|
+
return `##INSECURE##${text}`;
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error("EphemClient requires a secure context (HTTPS or localhost).");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const subtle = globalThis.crypto.subtle;
|
|
52
|
+
const enc = new TextEncoder();
|
|
53
|
+
const symKey = await subtle.generateKey(
|
|
54
|
+
{ name: "AES-GCM", length: 256 },
|
|
55
|
+
true,
|
|
56
|
+
["encrypt"]
|
|
57
|
+
);
|
|
58
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
59
|
+
const ciphertext = await subtle.encrypt(
|
|
60
|
+
{ name: "AES-GCM", iv, additionalData: enc.encode(cid) },
|
|
61
|
+
symKey,
|
|
62
|
+
enc.encode(text)
|
|
63
|
+
);
|
|
64
|
+
const publicKey = await subtle.importKey(
|
|
65
|
+
"spki",
|
|
66
|
+
pemToArrayBuffer(publicKeyPem),
|
|
67
|
+
{ name: "RSA-OAEP", hash: "SHA-256" },
|
|
68
|
+
false,
|
|
69
|
+
["encrypt"]
|
|
70
|
+
);
|
|
71
|
+
const rawSymKey = await subtle.exportKey("raw", symKey);
|
|
72
|
+
const encryptedSymKey = await subtle.encrypt(
|
|
73
|
+
{ name: "RSA-OAEP" },
|
|
74
|
+
publicKey,
|
|
75
|
+
rawSymKey
|
|
76
|
+
);
|
|
77
|
+
return [
|
|
78
|
+
cid,
|
|
79
|
+
base64Encode(iv),
|
|
80
|
+
base64Encode(ciphertext),
|
|
81
|
+
base64Encode(encryptedSymKey)
|
|
82
|
+
].join(".");
|
|
83
|
+
}
|
|
84
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
85
|
+
0 && (module.exports = {
|
|
86
|
+
seal
|
|
87
|
+
});
|
|
88
|
+
if (module.exports.default) { module.exports = module.exports.default; }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/client/index.ts
|
|
2
|
+
function pemToArrayBuffer(pem) {
|
|
3
|
+
const b64 = pem.replace(/-----BEGIN PUBLIC KEY-----/, "").replace(/-----END PUBLIC KEY-----/, "").replace(/\s+/g, "");
|
|
4
|
+
const binary = atob(b64);
|
|
5
|
+
const bytes = new Uint8Array(binary.length);
|
|
6
|
+
for (let i = 0; i < binary.length; i++) {
|
|
7
|
+
bytes[i] = binary.charCodeAt(i);
|
|
8
|
+
}
|
|
9
|
+
return bytes.buffer;
|
|
10
|
+
}
|
|
11
|
+
function base64Encode(data) {
|
|
12
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
13
|
+
let binary = "";
|
|
14
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
15
|
+
binary += String.fromCharCode(bytes[i] || 0);
|
|
16
|
+
}
|
|
17
|
+
return btoa(binary);
|
|
18
|
+
}
|
|
19
|
+
async function seal(text, publicKeyPem, cid, allowInsecureFallback = false) {
|
|
20
|
+
if (!globalThis.crypto?.subtle) {
|
|
21
|
+
if (allowInsecureFallback) {
|
|
22
|
+
return `##INSECURE##${text}`;
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error("EphemClient requires a secure context (HTTPS or localhost).");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const subtle = globalThis.crypto.subtle;
|
|
28
|
+
const enc = new TextEncoder();
|
|
29
|
+
const symKey = await subtle.generateKey(
|
|
30
|
+
{ name: "AES-GCM", length: 256 },
|
|
31
|
+
true,
|
|
32
|
+
["encrypt"]
|
|
33
|
+
);
|
|
34
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
35
|
+
const ciphertext = await subtle.encrypt(
|
|
36
|
+
{ name: "AES-GCM", iv, additionalData: enc.encode(cid) },
|
|
37
|
+
symKey,
|
|
38
|
+
enc.encode(text)
|
|
39
|
+
);
|
|
40
|
+
const publicKey = await subtle.importKey(
|
|
41
|
+
"spki",
|
|
42
|
+
pemToArrayBuffer(publicKeyPem),
|
|
43
|
+
{ name: "RSA-OAEP", hash: "SHA-256" },
|
|
44
|
+
false,
|
|
45
|
+
["encrypt"]
|
|
46
|
+
);
|
|
47
|
+
const rawSymKey = await subtle.exportKey("raw", symKey);
|
|
48
|
+
const encryptedSymKey = await subtle.encrypt(
|
|
49
|
+
{ name: "RSA-OAEP" },
|
|
50
|
+
publicKey,
|
|
51
|
+
rawSymKey
|
|
52
|
+
);
|
|
53
|
+
return [
|
|
54
|
+
cid,
|
|
55
|
+
base64Encode(iv),
|
|
56
|
+
base64Encode(ciphertext),
|
|
57
|
+
base64Encode(encryptedSymKey)
|
|
58
|
+
].join(".");
|
|
59
|
+
}
|
|
60
|
+
export {
|
|
61
|
+
seal
|
|
62
|
+
};
|
|
63
|
+
if (module.exports.default) { module.exports = module.exports.default; }
|