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/dist/index.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { randomUUID, generateKeyPair } from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
privateDecrypt,
|
|
5
|
+
createDecipheriv,
|
|
6
|
+
constants
|
|
7
|
+
} from "crypto";
|
|
8
|
+
var Analytics = class {
|
|
9
|
+
totalCreatedCapsules;
|
|
10
|
+
totalUsedCapsules;
|
|
11
|
+
totalExpiredCapsules;
|
|
12
|
+
constructor() {
|
|
13
|
+
this.totalCreatedCapsules = 0;
|
|
14
|
+
this.totalExpiredCapsules = 0;
|
|
15
|
+
this.totalUsedCapsules = 0;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var DEFAULT_IN_MEMORY_CONFIG = {
|
|
19
|
+
inMemory: true,
|
|
20
|
+
maxConcurrentCapsules: Infinity,
|
|
21
|
+
defaultCaptsuleLifetimeMS: Infinity,
|
|
22
|
+
capsuleCleanupIntervalMS: 6e4
|
|
23
|
+
};
|
|
24
|
+
function normalizeConfig(config) {
|
|
25
|
+
if (config?.inMemory === false) {
|
|
26
|
+
return {
|
|
27
|
+
...DEFAULT_IN_MEMORY_CONFIG,
|
|
28
|
+
...config,
|
|
29
|
+
inMemory: false
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
...DEFAULT_IN_MEMORY_CONFIG,
|
|
34
|
+
...config,
|
|
35
|
+
inMemory: true
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function generateCapsuleKeyPair() {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
generateKeyPair(
|
|
41
|
+
"rsa",
|
|
42
|
+
{
|
|
43
|
+
modulusLength: 2048,
|
|
44
|
+
publicKeyEncoding: {
|
|
45
|
+
type: "spki",
|
|
46
|
+
format: "pem"
|
|
47
|
+
},
|
|
48
|
+
privateKeyEncoding: {
|
|
49
|
+
type: "pkcs8",
|
|
50
|
+
format: "pem"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
(err, publicKey, privateKey) => {
|
|
54
|
+
if (err) return reject(err);
|
|
55
|
+
resolve({ publicKey, privateKey });
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function base64ToBuffer(b64) {
|
|
61
|
+
return Buffer.from(b64, "base64");
|
|
62
|
+
}
|
|
63
|
+
var Ephem = class {
|
|
64
|
+
config;
|
|
65
|
+
genUUID = () => randomUUID();
|
|
66
|
+
slug = `Ephem`;
|
|
67
|
+
capsules;
|
|
68
|
+
analytics;
|
|
69
|
+
log(...message) {
|
|
70
|
+
if (this.config.logging) {
|
|
71
|
+
console.log(`${this.slug} =>`, ...message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
constructor(config) {
|
|
75
|
+
this.analytics = new Analytics();
|
|
76
|
+
this.config = normalizeConfig(config);
|
|
77
|
+
this.capsules = /* @__PURE__ */ new Map();
|
|
78
|
+
this.log(`initialized with ${this.config.inMemory ? `in-memory` : `persistent`} mode.`);
|
|
79
|
+
if (this.config.inMemory) {
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
this.capsuleCleanUp();
|
|
82
|
+
}, this.config.capsuleCleanupIntervalMS || 6e4);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Alias Handshake
|
|
87
|
+
*/
|
|
88
|
+
async createCapsule(callback, config = {}) {
|
|
89
|
+
try {
|
|
90
|
+
if (this.config.inMemory && this.capsules.size >= (this.config.maxConcurrentCapsules || Infinity)) {
|
|
91
|
+
callback(null);
|
|
92
|
+
} else {
|
|
93
|
+
const { privateKey, publicKey } = await generateCapsuleKeyPair();
|
|
94
|
+
const cid = this.genUUID();
|
|
95
|
+
const lifetime = config.lifetimeDurationMS === void 0 ? this.config.defaultCaptsuleLifetimeMS ? Math.max(1, this.config.defaultCaptsuleLifetimeMS) : Infinity : Math.max(1, config.lifetimeDurationMS);
|
|
96
|
+
const expiresAt = lifetime === Infinity ? Infinity : Date.now() + lifetime;
|
|
97
|
+
const maxOpens = Math.max(1, config.maxOpens || 0);
|
|
98
|
+
const cbData = {
|
|
99
|
+
publicKey,
|
|
100
|
+
cid
|
|
101
|
+
};
|
|
102
|
+
if (this.config.inMemory) {
|
|
103
|
+
const capsule = {
|
|
104
|
+
expiresAt: expiresAt === Infinity ? 0 : expiresAt,
|
|
105
|
+
maxOpens: maxOpens === Infinity ? 0 : maxOpens,
|
|
106
|
+
openCount: 0,
|
|
107
|
+
privateKey
|
|
108
|
+
};
|
|
109
|
+
this.capsules.set(cid, capsule);
|
|
110
|
+
this.analytics.totalCreatedCapsules++;
|
|
111
|
+
this.log(`new capsul with id ${cid}`);
|
|
112
|
+
callback(cbData);
|
|
113
|
+
} else if (this.config.onCapsuleCreation) {
|
|
114
|
+
const res = await this.config.onCapsuleCreation(cid, privateKey, 0, maxOpens === Infinity ? 0 : maxOpens, expiresAt === Infinity ? 0 : expiresAt);
|
|
115
|
+
if (res) {
|
|
116
|
+
this.analytics.totalCreatedCapsules++;
|
|
117
|
+
this.log(`new capsul with id ${cid}`);
|
|
118
|
+
callback(cbData);
|
|
119
|
+
} else {
|
|
120
|
+
this.log(`Error encountered during capsule creation:`, `onCapsuleCreation returned false.`);
|
|
121
|
+
callback(null);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
this.log(`Error encountered during capsule creation:`, `onCapsuleCreation not defined in persistent mode.`);
|
|
125
|
+
callback(null);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.log(`Error encountered during capsule creation:`, error);
|
|
130
|
+
callback(null);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
createCapsulePromise(config = {}) {
|
|
134
|
+
return new Promise(async (resolve, reject) => {
|
|
135
|
+
this.createCapsule((r) => {
|
|
136
|
+
if (r === null) {
|
|
137
|
+
reject(r);
|
|
138
|
+
} else {
|
|
139
|
+
resolve(r);
|
|
140
|
+
}
|
|
141
|
+
}, config);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Handles periodic cleanup of in-memory capsules.
|
|
146
|
+
* Works only if capsules are in memory.
|
|
147
|
+
* Else, user should implement their own cleanup elsewhere
|
|
148
|
+
*/
|
|
149
|
+
capsuleCleanUp() {
|
|
150
|
+
this.log(`cleanup initialized.`);
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
let n = 0;
|
|
153
|
+
for (const [cid, capsule] of this.capsules) {
|
|
154
|
+
if (capsule.expiresAt <= Date.now()) {
|
|
155
|
+
this.analytics.totalExpiredCapsules++;
|
|
156
|
+
this.capsules.delete(cid);
|
|
157
|
+
n++;
|
|
158
|
+
} else if (capsule.openCount >= capsule.maxOpens && capsule.maxOpens != 0) {
|
|
159
|
+
this.analytics.totalUsedCapsules++;
|
|
160
|
+
this.capsules.delete(cid);
|
|
161
|
+
n++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (n) {
|
|
165
|
+
this.log(`${n} capsule${n == 1 ? "" : "s"} deleted due to expiry or max usage.`);
|
|
166
|
+
} else {
|
|
167
|
+
this.log(`no capsule deleted.`);
|
|
168
|
+
}
|
|
169
|
+
const duration = this.config.capsuleCleanupIntervalMS || 6e4;
|
|
170
|
+
const elapsed = Date.now() - start;
|
|
171
|
+
if (elapsed >= duration) {
|
|
172
|
+
this.capsuleCleanUp();
|
|
173
|
+
} else {
|
|
174
|
+
setTimeout(() => this.capsuleCleanUp(), Math.max(0, duration - elapsed));
|
|
175
|
+
}
|
|
176
|
+
this.log(`cleanup ended.`);
|
|
177
|
+
}
|
|
178
|
+
async getCapsule(cid) {
|
|
179
|
+
if (this.config.inMemory) {
|
|
180
|
+
if (this.capsules.has(cid)) {
|
|
181
|
+
return this.capsules.get(cid);
|
|
182
|
+
} else {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
if (this.config.OnGetCapsuleByCID) {
|
|
187
|
+
const r = await this.config.OnGetCapsuleByCID(cid);
|
|
188
|
+
if (r) {
|
|
189
|
+
const capsule = {
|
|
190
|
+
expiresAt: Math.max(Number(r.expiresAt) || 0, 0),
|
|
191
|
+
maxOpens: Math.max(Number(r.maxOpens) || 0, 1),
|
|
192
|
+
openCount: Math.max(Number(r.openCount) || 0, 0),
|
|
193
|
+
privateKey: String(r.privateKey) || ""
|
|
194
|
+
};
|
|
195
|
+
return capsule;
|
|
196
|
+
} else {
|
|
197
|
+
this.log(`Get capsule error: OnGetCapsuleByCID did not return a capsule.`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
this.log(`Get capsule error: OnGetCapsuleByCID not supplied.`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
removeIndividualCapsule(cid, reason) {
|
|
207
|
+
if (this.config.inMemory) {
|
|
208
|
+
if (this.capsules.has(cid)) {
|
|
209
|
+
this.capsules.delete(cid);
|
|
210
|
+
this.log(`${cid} removed due to '${reason}'.`);
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
if (this.config.onCapsuleDelete) {
|
|
214
|
+
this.config.onCapsuleDelete(cid, reason);
|
|
215
|
+
} else {
|
|
216
|
+
this.log(`warn: onCapsuleDelete was just required, but not implemented.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Decrypts encrypted data.
|
|
222
|
+
*/
|
|
223
|
+
async open(cipher) {
|
|
224
|
+
if (cipher.startsWith("##INSECURE##")) {
|
|
225
|
+
const plainText = cipher.slice("##INSECURE##".length);
|
|
226
|
+
this.log(`WARN: Insecure dev-mode capsule opened.`);
|
|
227
|
+
return plainText;
|
|
228
|
+
}
|
|
229
|
+
const parts = cipher.split(".");
|
|
230
|
+
if (parts.length !== 4) return null;
|
|
231
|
+
let [cid, ivB64, cipherTextB64, encKeyB64] = parts;
|
|
232
|
+
cid = cid || "InvalidPlaceHolder";
|
|
233
|
+
const capsule = await this.getCapsule(cid);
|
|
234
|
+
if (!capsule) return null;
|
|
235
|
+
const { expiresAt, maxOpens, openCount, privateKey } = capsule;
|
|
236
|
+
if (expiresAt !== 0 && Date.now() >= expiresAt) {
|
|
237
|
+
this.removeIndividualCapsule(cid, "expired");
|
|
238
|
+
this.analytics.totalExpiredCapsules++;
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (maxOpens !== 0 && openCount >= maxOpens) {
|
|
242
|
+
this.removeIndividualCapsule(cid, "max_opened");
|
|
243
|
+
this.analytics.totalUsedCapsules++;
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const aesKey = privateDecrypt(
|
|
248
|
+
{
|
|
249
|
+
key: privateKey,
|
|
250
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
251
|
+
oaepHash: "sha256"
|
|
252
|
+
},
|
|
253
|
+
base64ToBuffer(encKeyB64 || "")
|
|
254
|
+
);
|
|
255
|
+
const iv = base64ToBuffer(ivB64 || "");
|
|
256
|
+
const cipherBuf = base64ToBuffer(cipherTextB64 || "");
|
|
257
|
+
const authTag = cipherBuf.subarray(cipherBuf.length - 16);
|
|
258
|
+
const encrypted = cipherBuf.subarray(0, cipherBuf.length - 16);
|
|
259
|
+
const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
260
|
+
decipher.setAuthTag(authTag);
|
|
261
|
+
decipher.setAAD(Buffer.from(cid));
|
|
262
|
+
const decrypted = decipher.update(encrypted) + decipher.final("utf8");
|
|
263
|
+
capsule.openCount++;
|
|
264
|
+
if (this.config.inMemory) {
|
|
265
|
+
this.capsules.set(cid, capsule);
|
|
266
|
+
} else if (this.config.onCapsuleOpen) {
|
|
267
|
+
await this.config.onCapsuleOpen(cid, capsule.openCount);
|
|
268
|
+
}
|
|
269
|
+
if (capsule.expiresAt !== 0 && Date.now() >= capsule.expiresAt) {
|
|
270
|
+
this.removeIndividualCapsule(cid, "expired");
|
|
271
|
+
this.analytics.totalExpiredCapsules++;
|
|
272
|
+
}
|
|
273
|
+
if (capsule.maxOpens !== 0 && capsule.openCount >= capsule.maxOpens) {
|
|
274
|
+
this.removeIndividualCapsule(cid, "max_opened");
|
|
275
|
+
this.analytics.totalUsedCapsules++;
|
|
276
|
+
}
|
|
277
|
+
return decrypted;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
this.log("open() failed:", err);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
getAnalytics() {
|
|
284
|
+
const snap = { ...this.analytics };
|
|
285
|
+
return snap;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
export {
|
|
289
|
+
Ephem as default
|
|
290
|
+
};
|
|
291
|
+
if (module.exports.default) { module.exports = module.exports.default; }
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ephem",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ephemeral client-side encryption capsules for secure data-in-transit",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"typesVersions": {
|
|
9
|
+
"*": {
|
|
10
|
+
"client": [
|
|
11
|
+
"dist/client/index.d.ts"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"require": "./dist/index.js",
|
|
18
|
+
"import": "./dist/index.mjs"
|
|
19
|
+
},
|
|
20
|
+
"./client": {
|
|
21
|
+
"require": "./dist/client/index.js",
|
|
22
|
+
"import": "./dist/client/index.mjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"build:cdn": "tsup src/client/index.ts --format iife --global-name EphemClient --minify",
|
|
28
|
+
"dev": "tsup --watch",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"encryption",
|
|
33
|
+
"ephemeral",
|
|
34
|
+
"client-side",
|
|
35
|
+
"security"
|
|
36
|
+
],
|
|
37
|
+
"author": "Gbiang Benedict Mashingil <gbiangb@gmail.com> (https://gbm.name.ng)",
|
|
38
|
+
"license": "ISC",
|
|
39
|
+
"type": "commonjs",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.0.3",
|
|
42
|
+
"tsup": "^8.5.1",
|
|
43
|
+
"typescript": "^5.9.3"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
]
|
|
50
|
+
}
|