@pryv/encryption 3.10.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/README.md +256 -0
- package/package.json +29 -0
- package/src/EventsCipher.js +259 -0
- package/src/Keyring.js +74 -0
- package/src/index.js +17 -0
- package/src/lib/base64.js +38 -0
- package/src/lib/md5.js +117 -0
- package/src/methods/aes-256-gcm.js +117 -0
- package/src/methods/aes-text-base64.js +93 -0
- package/src/methods/ecies-aes-256-gcm.js +232 -0
- package/src/methods/index.js +17 -0
- package/test/aes-256-gcm.test.js +108 -0
- package/test/aes-text-base64.test.js +106 -0
- package/test/attachments.test.js +244 -0
- package/test/ecies-aes-256-gcm.test.js +203 -0
- package/test/events-cipher.test.js +350 -0
- package/test/fixtures/aes-256-gcm.json +11 -0
- package/test/fixtures/aes-text-base64.json +32 -0
- package/test/fixtures/ecies-aes-256-gcm.json +40 -0
- package/test/fixtures/generate-aes-text-base64.js +100 -0
- package/test/fixtures/generate-ecies.js +129 -0
- package/test/integration.test.js +166 -0
- package/test/keyring.test.js +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# @pryv/encryption
|
|
2
|
+
|
|
3
|
+
Client-side encryption and decryption of [Pryv.io](https://pryv.com) events.
|
|
4
|
+
|
|
5
|
+
Events are encrypted **on the client** before they are sent to a Pryv.io core,
|
|
6
|
+
and decrypted **on the client** after they are read back. The core stores and
|
|
7
|
+
serves the encrypted form and never sees the key or the plaintext.
|
|
8
|
+
|
|
9
|
+
An encrypted event keeps its envelope in plaintext and moves the sensitive
|
|
10
|
+
`type` / `content` into an opaque payload:
|
|
11
|
+
|
|
12
|
+
```jsonc
|
|
13
|
+
{
|
|
14
|
+
"id": "ck…",
|
|
15
|
+
"streamIds": ["journal"],
|
|
16
|
+
"time": 1700000000,
|
|
17
|
+
"type": "encrypted/aes-256-gcm",
|
|
18
|
+
"content": { "payload": "…base64…", "keyRef": "journal-2026" }
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @pryv/encryption
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Node.js >= 20 or a modern browser — it uses the standard WebCrypto API
|
|
29
|
+
(`globalThis.crypto.subtle`) and has no runtime dependencies.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const { EventsCipher, Keyring } = require('@pryv/encryption');
|
|
35
|
+
|
|
36
|
+
// A Keyring holds key material and can defer to async resolvers.
|
|
37
|
+
// Key material may be raw bytes (Uint8Array), a base64 string, or a CryptoKey.
|
|
38
|
+
const keyring = new Keyring({ 'journal-2026': keyMaterial });
|
|
39
|
+
|
|
40
|
+
// Optional: resolvers are tried, in registration order, when the store misses.
|
|
41
|
+
keyring.use(async (method, keyRef, hint) => fetchKeySomehow(keyRef) /* or null */);
|
|
42
|
+
|
|
43
|
+
const cipher = new EventsCipher(keyring, {
|
|
44
|
+
// optional; default is silent
|
|
45
|
+
onDecryptError: (event, error) => console.warn('could not decrypt', event.id, error)
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Encrypt before writing to a Pryv.io connection:
|
|
49
|
+
const encrypted = await cipher.encryptEvent(
|
|
50
|
+
{ streamIds: ['journal'], type: 'note/txt', content: 'a private note' },
|
|
51
|
+
{ method: 'aes-256-gcm', keyRef: 'journal-2026' }
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Decrypt after reading:
|
|
55
|
+
const decrypted = await cipher.decryptEvent(encrypted);
|
|
56
|
+
// decrypted.type === 'note/txt', decrypted.content === 'a private note'
|
|
57
|
+
// decrypted.decryptedFrom === encrypted (the original, untouched)
|
|
58
|
+
|
|
59
|
+
// Batch, and streaming (forEachEvent) helpers:
|
|
60
|
+
const all = await cipher.decryptEvents(events);
|
|
61
|
+
const onEvent = cipher.wrapForEachEvent((event) => { /* receives decrypted events */ });
|
|
62
|
+
|
|
63
|
+
// Recover the stored (encrypted) form of a decrypted event, e.g. before an update:
|
|
64
|
+
const stored = cipher.stripDecrypted(decrypted); // === encrypted
|
|
65
|
+
|
|
66
|
+
// Update an encrypted event: re-encrypt the new material, send it as the update
|
|
67
|
+
// (updates are always full re-encryptions — see Limitations):
|
|
68
|
+
const update = await cipher.encryptEventContent(
|
|
69
|
+
{ type: 'note/txt', content: 'the edited note' },
|
|
70
|
+
{ method: 'aes-256-gcm', keyRef: 'journal-2026' }
|
|
71
|
+
);
|
|
72
|
+
await conn.api([{ method: 'events.update', params: { id: stored.id, update } }]);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Keyring
|
|
76
|
+
|
|
77
|
+
- `new Keyring({ keyRef: material, … })` — seed the key/value store.
|
|
78
|
+
- `keyring.set(keyRef, material)` — add/replace a key.
|
|
79
|
+
- `keyring.use(resolver)` — append an async resolver
|
|
80
|
+
`(method, keyRef, hint) => material | null`.
|
|
81
|
+
- `keyring.getKeyFor(method, keyRef, hint)` — resolve material: the store is
|
|
82
|
+
consulted first (by `keyRef`), then resolvers in registration order; the first
|
|
83
|
+
non-null result wins, otherwise `null`.
|
|
84
|
+
|
|
85
|
+
### Decryption is non-destructive
|
|
86
|
+
|
|
87
|
+
`decryptEvent` never throws and never drops an event:
|
|
88
|
+
|
|
89
|
+
- If the event `type` is not `encrypted/<registered-method>`, the **same event
|
|
90
|
+
reference** is returned untouched.
|
|
91
|
+
- On **any** failure — no key, a resolver throwing, a wrong key / tampered tag,
|
|
92
|
+
a malformed payload, or plaintext that is not JSON with `type` and `content` —
|
|
93
|
+
the **same event reference** is returned, and `onDecryptError(event, error)`
|
|
94
|
+
is called when provided.
|
|
95
|
+
- On success a **new** object is returned: the envelope fields are preserved,
|
|
96
|
+
`type` / `content` are the decrypted values, and `decryptedFrom` points back
|
|
97
|
+
at the original encrypted event.
|
|
98
|
+
|
|
99
|
+
`encryptEvent`, in contrast, **throws** on an unknown method or a missing key.
|
|
100
|
+
|
|
101
|
+
### Methods
|
|
102
|
+
|
|
103
|
+
Each method interprets **key material** in its own way — a keyring simply stores
|
|
104
|
+
whatever material a method expects and hands it back untouched. The three
|
|
105
|
+
built-in methods are:
|
|
106
|
+
|
|
107
|
+
#### `aes-256-gcm` (symmetric, encrypt + decrypt)
|
|
108
|
+
|
|
109
|
+
AES-256 in GCM mode. `content.payload` is `base64(iv ‖ ciphertext ‖ gcm-tag)`
|
|
110
|
+
where the IV is 12 random bytes and the tag is the 16-byte GCM authentication
|
|
111
|
+
tag. **Key material** is the 32 raw key bytes, accepted as a `Uint8Array`, a
|
|
112
|
+
**base64 string of those 32 bytes**, or an AES-GCM `CryptoKey`.
|
|
113
|
+
|
|
114
|
+
#### `aes-text-base64` (legacy, **decrypt-only**)
|
|
115
|
+
|
|
116
|
+
Reads the historical ciphertext produced by
|
|
117
|
+
`CryptoJS.AES.encrypt(text, passphrase).toString()` — the OpenSSL "salted"
|
|
118
|
+
envelope `base64("Salted__" ‖ salt[8] ‖ AES-256-CBC(text, key, iv))`. The
|
|
119
|
+
32-byte key and 16-byte IV are derived from the passphrase and salt via
|
|
120
|
+
OpenSSL's `EVP_BytesToKey` (MD5, one iteration); the cipher is AES-256-CBC with
|
|
121
|
+
PKCS#7 padding.
|
|
122
|
+
|
|
123
|
+
**Key material for this method is the passphrase STRING itself** — *not* base64
|
|
124
|
+
of key bytes (unlike `aes-256-gcm`). Put the passphrase directly into the
|
|
125
|
+
keyring:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
const keyring = new Keyring({ 'legacy-2019': 'the original passphrase' });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
This method has **no `encrypt`**: it exists only to read pre-existing legacy
|
|
132
|
+
events, so `encryptEvent` / `encryptEventContent` throw for it. New events should
|
|
133
|
+
use a modern method. (MD5 — required only to reproduce the legacy key
|
|
134
|
+
derivation — is vendored internally; it is broken and is used for nothing else.)
|
|
135
|
+
|
|
136
|
+
#### `ecies-aes-256-gcm` (asymmetric, encrypt + decrypt)
|
|
137
|
+
|
|
138
|
+
ECIES over NIST P-256 with AES-256-GCM. A sender encrypts to a recipient's
|
|
139
|
+
**public** key; only the holder of the matching **private** key can decrypt — so
|
|
140
|
+
an encrypted event can be shared without ever moving a shared secret.
|
|
141
|
+
|
|
142
|
+
`content.payload` is
|
|
143
|
+
`base64(ephemeralPublicKey[65] ‖ iv[12] ‖ ciphertext ‖ gcm-tag[16])`, where the
|
|
144
|
+
ephemeral public key is a fresh P-256 point in SEC1 uncompressed form
|
|
145
|
+
(`0x04 ‖ X ‖ Y`). The AES-256-GCM key is
|
|
146
|
+
`HKDF-SHA-256(ECDH(ephemeral, recipient), salt = <empty>, info = "encrypted/ecies-aes-256-gcm")`.
|
|
147
|
+
|
|
148
|
+
**Key material** (each operation picks the side it needs):
|
|
149
|
+
|
|
150
|
+
- to **encrypt** — the recipient PUBLIC key as a `CryptoKey`, a JWK (no `d`), a
|
|
151
|
+
raw 65-byte `Uint8Array` (SEC1 uncompressed) or its base64;
|
|
152
|
+
- to **decrypt** — the recipient PRIVATE key as a `CryptoKey` (ECDH), a JWK
|
|
153
|
+
(has `d`), or base64 / `Uint8Array` PKCS#8;
|
|
154
|
+
- either operation also accepts a `{ publicKey, privateKey }` pair object holding
|
|
155
|
+
any of the shapes above.
|
|
156
|
+
|
|
157
|
+
Mint a key pair (exportable JWK objects) with the method's `generateKeyPair()`:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
const { methods } = require('@pryv/encryption');
|
|
161
|
+
const { publicKey, privateKey } = await methods['ecies-aes-256-gcm'].generateKeyPair();
|
|
162
|
+
|
|
163
|
+
// sender only needs the public key:
|
|
164
|
+
const senderRing = new Keyring({ 'alice': publicKey });
|
|
165
|
+
const enc = await senderCipher.encryptEvent(plain, { method: 'ecies-aes-256-gcm', keyRef: 'alice' });
|
|
166
|
+
|
|
167
|
+
// alice decrypts with her private key (in her own keyring):
|
|
168
|
+
const aliceRing = new Keyring({ 'alice': privateKey });
|
|
169
|
+
const dec = await aliceCipher.decryptEvent(enc);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Encrypted attachments
|
|
173
|
+
|
|
174
|
+
An event's attached files are encrypted with the **same method and key as the
|
|
175
|
+
event's `content`**, and stored as **raw binary in the method's payload byte
|
|
176
|
+
layout** — i.e. exactly the bytes that Base64-decoding a `content.payload` would
|
|
177
|
+
yield, but **without** the Base64 wrapping:
|
|
178
|
+
|
|
179
|
+
- `aes-256-gcm` — `iv[12] ‖ ciphertext ‖ gcm-tag[16]`
|
|
180
|
+
- `ecies-aes-256-gcm` — `ephemeralPublicKey[65] ‖ iv[12] ‖ ciphertext ‖ gcm-tag[16]`
|
|
181
|
+
|
|
182
|
+
Two `EventsCipher` helpers move raw bytes:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
// Encrypt the file bytes with the SAME method/key as the event content:
|
|
186
|
+
const cipherBytes = await cipher.encryptAttachmentData(fileBytes, {
|
|
187
|
+
method: 'aes-256-gcm', keyRef: 'journal-2026'
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Upload cipherBytes as the file body (isomorphic — a Blob works in Node >= 20
|
|
191
|
+
// and the browser):
|
|
192
|
+
const blob = new Blob([cipherBytes], { type: 'application/octet-stream' });
|
|
193
|
+
const created = (await conn.createEventWithFileFromBuffer(encryptedEvent, blob, 'secret.bin')).event;
|
|
194
|
+
|
|
195
|
+
// Download the raw bytes and decrypt them against the event they belong to:
|
|
196
|
+
const att = created.attachments[0];
|
|
197
|
+
const url = conn.endpoint + 'events/' + created.id + '/' + att.id + '?readToken=' + att.readToken;
|
|
198
|
+
const downloaded = new Uint8Array(await (await fetch(url)).arrayBuffer());
|
|
199
|
+
const fileBytesBack = await cipher.decryptAttachmentData(created, downloaded);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- `encryptAttachmentData(bytes, { method, keyRef, hint })` → `Uint8Array` —
|
|
203
|
+
resolves the key exactly like `encryptEventContent`. The input is not mutated.
|
|
204
|
+
- `decryptAttachmentData(event, bytes)` → `Uint8Array` — derives the method from
|
|
205
|
+
the event's `encrypted/<method>` type and the key from its `content.keyRef` /
|
|
206
|
+
`content.hint`. `event` may be the encrypted event **or** an already-decrypted
|
|
207
|
+
one (carrying `decryptedFrom`); the encrypted form is used either way.
|
|
208
|
+
|
|
209
|
+
**Throw asymmetry.** Unlike the passive, never-throw event decryption
|
|
210
|
+
(`decryptEvent`), attachment decryption is an **explicit request** and
|
|
211
|
+
**throws** on any failure (event not encrypted, unknown method, a method with no
|
|
212
|
+
byte support, a missing key, or bad / tampered / truncated bytes). Both helpers
|
|
213
|
+
throw on error, matching `encryptEvent`.
|
|
214
|
+
|
|
215
|
+
**The legacy `aes-text-base64` method has no byte support** — there are no known
|
|
216
|
+
legacy encrypted attachments, so it exposes neither `encryptBytes` nor
|
|
217
|
+
`decryptBytes`, and both attachment helpers throw for it.
|
|
218
|
+
|
|
219
|
+
The core stores the (already-encrypted) file verbatim and reports its **encrypted**
|
|
220
|
+
size on `attachments[0].size` — it never sees the key or the plaintext.
|
|
221
|
+
|
|
222
|
+
### Custom methods
|
|
223
|
+
|
|
224
|
+
Register or override a method with:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
cipher.registerMethod('my-method', {
|
|
228
|
+
encrypt: async (material, key) => ({ payload: /* … */ }),
|
|
229
|
+
decrypt: async (content, key) => material,
|
|
230
|
+
// optional — enable encrypted attachments for this method; each returns the
|
|
231
|
+
// raw payload-layout / plaintext bytes as a Uint8Array:
|
|
232
|
+
encryptBytes: async (bytes, key) => /* Uint8Array */,
|
|
233
|
+
decryptBytes: async (bytes, key) => /* Uint8Array */
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`encryptBytes` / `decryptBytes` are optional; when present they must be
|
|
238
|
+
functions (validated on registration). A method without them cannot encrypt or
|
|
239
|
+
decrypt attachments.
|
|
240
|
+
|
|
241
|
+
## Limitations
|
|
242
|
+
|
|
243
|
+
- **Updates are full re-encryptions.** There is no partial/streaming update of
|
|
244
|
+
an encrypted `content`; changing it means encrypting the new value and
|
|
245
|
+
replacing the event's `content`.
|
|
246
|
+
- **The server cannot query encrypted content.** Because `type` and `content`
|
|
247
|
+
are opaque to the core, server-side filtering, type queries, aggregation and
|
|
248
|
+
full-text search do not apply to encrypted fields. Envelope fields
|
|
249
|
+
(`streamIds`, `time`, …) stay in plaintext and remain queryable.
|
|
250
|
+
- **Key management is the application's responsibility.** This library never
|
|
251
|
+
transmits, persists or derives keys; losing a key means losing access to the
|
|
252
|
+
data it protected.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
[BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pryv/encryption",
|
|
3
|
+
"version": "3.10.0",
|
|
4
|
+
"description": "Client-side encryption and decryption of Pryv.io events",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"Pryv",
|
|
7
|
+
"Pryv.io",
|
|
8
|
+
"Encryption",
|
|
9
|
+
"Decryption",
|
|
10
|
+
"Privacy"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/pryv/lib-js/tree/master/components/pryv-encryption#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/pryv/lib-js/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git://github.com/pryv/lib-js.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "BSD-3-Clause",
|
|
21
|
+
"author": "Pryv <info@pryv.com> (https://pryv.com)",
|
|
22
|
+
"main": "src/index.js",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Encrypts and decrypts Pryv.io events.
|
|
7
|
+
*
|
|
8
|
+
* An encrypted event carries `type: "encrypted/<method>"` and a
|
|
9
|
+
* `content: { payload, keyRef?, hint? }`. All other envelope fields
|
|
10
|
+
* (id, streamIds, time, created, …) stay in plaintext.
|
|
11
|
+
*
|
|
12
|
+
* Decryption is defensive: any failure leaves the event untouched (the exact
|
|
13
|
+
* same reference is returned) and, when configured, is reported through
|
|
14
|
+
* `onDecryptError` — a failed decryption never throws and never drops an event.
|
|
15
|
+
* Encryption, by contrast, throws on error.
|
|
16
|
+
*
|
|
17
|
+
* Attachments are handled by the byte helpers `encryptAttachmentData` /
|
|
18
|
+
* `decryptAttachmentData`. An attachment is encrypted with the SAME method and
|
|
19
|
+
* key as its event's content, stored as the method's raw payload byte layout
|
|
20
|
+
* (WITHOUT Base64). Unlike passive event decryption, attachment decryption is an
|
|
21
|
+
* explicit request and THROWS on failure.
|
|
22
|
+
*/
|
|
23
|
+
const Keyring = require('./Keyring');
|
|
24
|
+
const builtinMethods = require('./methods');
|
|
25
|
+
|
|
26
|
+
const ENCRYPTED_TYPE_PREFIX = 'encrypted/';
|
|
27
|
+
|
|
28
|
+
class EventsCipher {
|
|
29
|
+
/**
|
|
30
|
+
* @param {Keyring} keyring
|
|
31
|
+
* @param {Object} [options]
|
|
32
|
+
* @param {(event: Object, error: Error) => void} [options.onDecryptError] called on any decryption failure. Default: silent.
|
|
33
|
+
*/
|
|
34
|
+
constructor (keyring, options = {}) {
|
|
35
|
+
if (!(keyring instanceof Keyring)) {
|
|
36
|
+
throw new Error('EventsCipher requires a Keyring');
|
|
37
|
+
}
|
|
38
|
+
this.keyring = keyring;
|
|
39
|
+
this.onDecryptError = (options && options.onDecryptError) || null;
|
|
40
|
+
// Clone the built-ins so registerMethod() never mutates the shared map.
|
|
41
|
+
this._methods = Object.assign({}, builtinMethods);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register (or override) an encryption method. `decrypt` is required;
|
|
46
|
+
* `encrypt` is optional (a decrypt-only method supports reading existing
|
|
47
|
+
* encrypted events but cannot produce new ones).
|
|
48
|
+
* @param {string} name
|
|
49
|
+
* @param {{ decrypt: Function, encrypt?: Function }} method
|
|
50
|
+
* @returns {EventsCipher} this, for chaining.
|
|
51
|
+
*/
|
|
52
|
+
registerMethod (name, method) {
|
|
53
|
+
if (!method || typeof method.decrypt !== 'function' ||
|
|
54
|
+
(method.encrypt != null && typeof method.encrypt !== 'function') ||
|
|
55
|
+
(method.encryptBytes != null && typeof method.encryptBytes !== 'function') ||
|
|
56
|
+
(method.decryptBytes != null && typeof method.decryptBytes !== 'function')) {
|
|
57
|
+
throw new Error('registerMethod expects a { decrypt } function and optional { encrypt, encryptBytes, decryptBytes } functions');
|
|
58
|
+
}
|
|
59
|
+
this._methods[name] = method;
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Encrypt event material into the `{ type, content }` an implementer passes to
|
|
65
|
+
* an `events.create` / `events.update` call. Only the material's `type` and
|
|
66
|
+
* `content` are encrypted; any other fields are ignored. Throws on unknown or
|
|
67
|
+
* decrypt-only method, or on a missing key. The input is not mutated.
|
|
68
|
+
* @param {Object} material - `{ type, content, ... }` (same shape decryptEvent restores).
|
|
69
|
+
* @param {Object} params
|
|
70
|
+
* @param {string} params.method
|
|
71
|
+
* @param {string} [params.keyRef]
|
|
72
|
+
* @param {*} [params.hint]
|
|
73
|
+
* @returns {Promise<{ type: string, content: { payload: string, keyRef?: string, hint?: * } }>}
|
|
74
|
+
*/
|
|
75
|
+
async encryptEventContent (material, params = {}) {
|
|
76
|
+
const { method: methodName, keyRef, hint } = params;
|
|
77
|
+
const method = this._methods[methodName];
|
|
78
|
+
if (!method) {
|
|
79
|
+
throw new Error(`Unknown encryption method: ${methodName}`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof method.encrypt !== 'function') {
|
|
82
|
+
throw new Error(`Method "${methodName}" is decrypt-only and cannot encrypt`);
|
|
83
|
+
}
|
|
84
|
+
const key = await this.keyring.getKeyFor(methodName, keyRef, hint);
|
|
85
|
+
if (key == null) {
|
|
86
|
+
throw new Error(`No key available for method "${methodName}"${keyRef != null ? ` (keyRef "${keyRef}")` : ''}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const source = { type: material.type, content: material.content };
|
|
90
|
+
const content = await method.encrypt(source, key);
|
|
91
|
+
if (keyRef != null) content.keyRef = keyRef;
|
|
92
|
+
if (hint != null) content.hint = hint;
|
|
93
|
+
|
|
94
|
+
return { type: ENCRYPTED_TYPE_PREFIX + methodName, content };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Encrypt a plain event. Throws on unknown method or missing key.
|
|
99
|
+
* @param {Object} plainEvent
|
|
100
|
+
* @param {Object} params
|
|
101
|
+
* @param {string} params.method
|
|
102
|
+
* @param {string} [params.keyRef]
|
|
103
|
+
* @param {*} [params.hint]
|
|
104
|
+
* @returns {Promise<Object>} a new encrypted event (input is not mutated).
|
|
105
|
+
*/
|
|
106
|
+
async encryptEvent (plainEvent, params = {}) {
|
|
107
|
+
const { type, content } = await this.encryptEventContent(plainEvent, params);
|
|
108
|
+
const encrypted = Object.assign({}, plainEvent);
|
|
109
|
+
encrypted.type = type;
|
|
110
|
+
encrypted.content = content;
|
|
111
|
+
return encrypted;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decrypt a single event. Never throws.
|
|
116
|
+
* - Non-encrypted or unregistered-method events are returned untouched (same ref).
|
|
117
|
+
* - On any failure the original event is returned (same ref) and
|
|
118
|
+
* `onDecryptError(event, error)` is called when configured.
|
|
119
|
+
* - On success a NEW object is returned with restored `type`/`content`,
|
|
120
|
+
* preserved envelope fields, and a `decryptedFrom` back-reference.
|
|
121
|
+
* @param {Object} event
|
|
122
|
+
* @returns {Promise<Object>}
|
|
123
|
+
*/
|
|
124
|
+
async decryptEvent (event) {
|
|
125
|
+
const methodName = methodNameFromType(event && event.type);
|
|
126
|
+
if (methodName == null) return event;
|
|
127
|
+
const method = this._methods[methodName];
|
|
128
|
+
if (!method) return event;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const content = event.content || {};
|
|
132
|
+
const key = await this.keyring.getKeyFor(methodName, content.keyRef, content.hint);
|
|
133
|
+
if (key == null) {
|
|
134
|
+
throw new Error(`No key available for method "${methodName}"`);
|
|
135
|
+
}
|
|
136
|
+
const material = await method.decrypt(content, key);
|
|
137
|
+
if (material == null || material.type == null || material.content == null) {
|
|
138
|
+
throw new Error('Decrypted payload is missing "type" or "content"');
|
|
139
|
+
}
|
|
140
|
+
return Object.assign({}, event, material, { decryptedFrom: event });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (this.onDecryptError) this.onDecryptError(event, error);
|
|
143
|
+
return event;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Decrypt an array of events. Never throws; failures pass through untouched.
|
|
149
|
+
* @param {Object[]} events
|
|
150
|
+
* @returns {Promise<Object[]>}
|
|
151
|
+
*/
|
|
152
|
+
async decryptEvents (events) {
|
|
153
|
+
return Promise.all(events.map((event) => this.decryptEvent(event)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wrap a `forEachEvent`-style callback so each event is decrypted before it
|
|
158
|
+
* is forwarded to the user callback.
|
|
159
|
+
* @param {(event: Object) => *} callback
|
|
160
|
+
* @returns {(event: Object) => Promise<*>}
|
|
161
|
+
*/
|
|
162
|
+
wrapForEachEvent (callback) {
|
|
163
|
+
return async (event) => {
|
|
164
|
+
const decrypted = await this.decryptEvent(event);
|
|
165
|
+
return callback(decrypted);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Encrypt raw attachment bytes with the SAME method and key an event's
|
|
171
|
+
* `content` uses. The result is the method's raw payload byte layout (exactly
|
|
172
|
+
* the bytes Base64-decoding a `content.payload` would yield, WITHOUT Base64) —
|
|
173
|
+
* upload it verbatim as the file's binary body. Like `encryptEventContent`,
|
|
174
|
+
* this THROWS on an unknown method, a method without byte support, or a missing
|
|
175
|
+
* key. The input bytes are not mutated.
|
|
176
|
+
* @param {Uint8Array} bytes - raw attachment bytes to encrypt.
|
|
177
|
+
* @param {Object} params
|
|
178
|
+
* @param {string} params.method
|
|
179
|
+
* @param {string} [params.keyRef]
|
|
180
|
+
* @param {*} [params.hint]
|
|
181
|
+
* @returns {Promise<Uint8Array>} raw payload-layout bytes.
|
|
182
|
+
*/
|
|
183
|
+
async encryptAttachmentData (bytes, params = {}) {
|
|
184
|
+
const { method: methodName, keyRef, hint } = params;
|
|
185
|
+
const method = this._methods[methodName];
|
|
186
|
+
if (!method) {
|
|
187
|
+
throw new Error(`Unknown encryption method: ${methodName}`);
|
|
188
|
+
}
|
|
189
|
+
if (typeof method.encryptBytes !== 'function') {
|
|
190
|
+
throw new Error(`Method "${methodName}" does not support attachment (byte) encryption`);
|
|
191
|
+
}
|
|
192
|
+
const key = await this.keyring.getKeyFor(methodName, keyRef, hint);
|
|
193
|
+
if (key == null) {
|
|
194
|
+
throw new Error(`No key available for method "${methodName}"${keyRef != null ? ` (keyRef "${keyRef}")` : ''}`);
|
|
195
|
+
}
|
|
196
|
+
return method.encryptBytes(bytes, key);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Decrypt raw attachment bytes that belong to `event`. The method is derived
|
|
201
|
+
* from the event's `encrypted/<method>` type and the key from its
|
|
202
|
+
* `content.keyRef` / `content.hint` — the SAME material used for its content.
|
|
203
|
+
*
|
|
204
|
+
* `event` may be the encrypted event OR an already-decrypted one (carrying a
|
|
205
|
+
* `decryptedFrom` back-reference); the encrypted form is used either way.
|
|
206
|
+
*
|
|
207
|
+
* Unlike the passive, never-throw event decryption (`decryptEvent`),
|
|
208
|
+
* attachment decryption is an EXPLICIT request and THROWS on failure (event
|
|
209
|
+
* not encrypted, unknown method, no byte support, missing key, bad key /
|
|
210
|
+
* tampered / truncated bytes).
|
|
211
|
+
* @param {Object} event - the (encrypted or decrypted) event the attachment belongs to.
|
|
212
|
+
* @param {Uint8Array} bytes - raw payload-layout bytes downloaded from the core.
|
|
213
|
+
* @returns {Promise<Uint8Array>} raw plaintext bytes.
|
|
214
|
+
*/
|
|
215
|
+
async decryptAttachmentData (event, bytes) {
|
|
216
|
+
const source = this.stripDecrypted(event);
|
|
217
|
+
const methodName = methodNameFromType(source && source.type);
|
|
218
|
+
if (methodName == null) {
|
|
219
|
+
throw new Error('Event is not encrypted (type is not "encrypted/<method>")');
|
|
220
|
+
}
|
|
221
|
+
const method = this._methods[methodName];
|
|
222
|
+
if (!method) {
|
|
223
|
+
throw new Error(`Unknown encryption method: ${methodName}`);
|
|
224
|
+
}
|
|
225
|
+
if (typeof method.decryptBytes !== 'function') {
|
|
226
|
+
throw new Error(`Method "${methodName}" does not support attachment (byte) decryption`);
|
|
227
|
+
}
|
|
228
|
+
const content = (source && source.content) || {};
|
|
229
|
+
const key = await this.keyring.getKeyFor(methodName, content.keyRef, content.hint);
|
|
230
|
+
if (key == null) {
|
|
231
|
+
throw new Error(`No key available for method "${methodName}"${content.keyRef != null ? ` (keyRef "${content.keyRef}")` : ''}`);
|
|
232
|
+
}
|
|
233
|
+
return method.decryptBytes(bytes, key);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Return the original encrypted event a decrypted event came from, or the
|
|
238
|
+
* event itself when it was never decrypted.
|
|
239
|
+
* @param {Object} event
|
|
240
|
+
* @returns {Object}
|
|
241
|
+
*/
|
|
242
|
+
stripDecrypted (event) {
|
|
243
|
+
if (event && event.decryptedFrom != null) return event.decryptedFrom;
|
|
244
|
+
return event;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extract the method name from an `encrypted/<method>` type, or null.
|
|
250
|
+
* @param {*} type
|
|
251
|
+
* @returns {?string}
|
|
252
|
+
*/
|
|
253
|
+
function methodNameFromType (type) {
|
|
254
|
+
if (typeof type !== 'string' || !type.startsWith(ENCRYPTED_TYPE_PREFIX)) return null;
|
|
255
|
+
const name = type.slice(ENCRYPTED_TYPE_PREFIX.length);
|
|
256
|
+
return name.length > 0 ? name : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = EventsCipher;
|
package/src/Keyring.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A key store with a pluggable chain of asynchronous resolvers.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order for `getKeyFor(method, keyRef, hint)`:
|
|
9
|
+
* 1. the key/value store, looked up by `keyRef`;
|
|
10
|
+
* 2. each registered resolver, in registration order — the first one that
|
|
11
|
+
* returns a non-null value wins.
|
|
12
|
+
*
|
|
13
|
+
* Key material is stored and returned as-is (raw `Uint8Array`, base64 string
|
|
14
|
+
* or `CryptoKey`); interpreting it is the encryption method's responsibility.
|
|
15
|
+
*/
|
|
16
|
+
class Keyring {
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object<string, (Uint8Array|string|CryptoKey)>} [keys] initial keyRef → material map.
|
|
19
|
+
*/
|
|
20
|
+
constructor (keys = {}) {
|
|
21
|
+
this._store = new Map();
|
|
22
|
+
if (keys != null) {
|
|
23
|
+
for (const keyRef of Object.keys(keys)) {
|
|
24
|
+
this._store.set(keyRef, keys[keyRef]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
this._resolvers = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add or replace a key in the store.
|
|
32
|
+
* @param {string} keyRef
|
|
33
|
+
* @param {Uint8Array|string|CryptoKey} material
|
|
34
|
+
* @returns {Keyring} this, for chaining.
|
|
35
|
+
*/
|
|
36
|
+
set (keyRef, material) {
|
|
37
|
+
this._store.set(keyRef, material);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register an asynchronous resolver, tried after the store misses.
|
|
43
|
+
* @param {(method: string, keyRef: ?string, hint: *) => Promise<?(Uint8Array|string|CryptoKey)>} resolver
|
|
44
|
+
* @returns {Keyring} this, for chaining.
|
|
45
|
+
*/
|
|
46
|
+
use (resolver) {
|
|
47
|
+
if (typeof resolver !== 'function') {
|
|
48
|
+
throw new Error('Keyring.use() expects a function');
|
|
49
|
+
}
|
|
50
|
+
this._resolvers.push(resolver);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve key material for a given method / keyRef / hint.
|
|
56
|
+
* The store is consulted first, then each resolver in registration order.
|
|
57
|
+
* @param {string} method
|
|
58
|
+
* @param {?string} keyRef
|
|
59
|
+
* @param {*} [hint]
|
|
60
|
+
* @returns {Promise<?(Uint8Array|string|CryptoKey)>} resolved material, or null.
|
|
61
|
+
*/
|
|
62
|
+
async getKeyFor (method, keyRef, hint) {
|
|
63
|
+
if (keyRef != null && this._store.has(keyRef)) {
|
|
64
|
+
return this._store.get(keyRef);
|
|
65
|
+
}
|
|
66
|
+
for (const resolver of this._resolvers) {
|
|
67
|
+
const material = await resolver(method, keyRef, hint);
|
|
68
|
+
if (material != null) return material;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = Keyring;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* @pryv/encryption — client-side encryption and decryption of Pryv.io events.
|
|
7
|
+
*
|
|
8
|
+
* - `Keyring`: a key store with a pluggable chain of async key resolvers.
|
|
9
|
+
* - `EventsCipher`: encrypts / decrypts events using registered methods.
|
|
10
|
+
* - `methods`: the built-in encryption methods (`aes-256-gcm`,
|
|
11
|
+
* `aes-text-base64`, `ecies-aes-256-gcm`).
|
|
12
|
+
*/
|
|
13
|
+
const EventsCipher = require('./EventsCipher');
|
|
14
|
+
const Keyring = require('./Keyring');
|
|
15
|
+
const methods = require('./methods');
|
|
16
|
+
|
|
17
|
+
module.exports = { EventsCipher, Keyring, methods };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Isomorphic base64 helpers built on the standard `btoa`/`atob` globals,
|
|
7
|
+
* which are available in browsers and in Node.js (>= 16). No `Buffer`, so the
|
|
8
|
+
* same code path runs unchanged in both environments.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Encode raw bytes to a standard (non-URL-safe) base64 string.
|
|
13
|
+
* @param {Uint8Array} bytes
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function bytesToBase64 (bytes) {
|
|
17
|
+
let binary = '';
|
|
18
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
19
|
+
binary += String.fromCharCode(bytes[i]);
|
|
20
|
+
}
|
|
21
|
+
return btoa(binary);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decode a standard base64 string to raw bytes.
|
|
26
|
+
* @param {string} b64
|
|
27
|
+
* @returns {Uint8Array}
|
|
28
|
+
*/
|
|
29
|
+
function base64ToBytes (b64) {
|
|
30
|
+
const binary = atob(b64);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i++) {
|
|
33
|
+
bytes[i] = binary.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { bytesToBase64, base64ToBytes };
|