@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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, expect, beforeEach */
|
|
6
|
+
|
|
7
|
+
const { EventsCipher, Keyring } = require('../src');
|
|
8
|
+
const vector = require('./fixtures/aes-256-gcm.json');
|
|
9
|
+
|
|
10
|
+
const KEY_REF = 'journal-2026';
|
|
11
|
+
|
|
12
|
+
function newCipher (opts) {
|
|
13
|
+
const keyring = new Keyring({ [KEY_REF]: vector.keyBase64 });
|
|
14
|
+
return new EventsCipher(keyring, opts);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function plainEvent (over) {
|
|
18
|
+
return Object.assign({
|
|
19
|
+
id: 'evt-1',
|
|
20
|
+
streamIds: ['journal'],
|
|
21
|
+
time: 1500000000,
|
|
22
|
+
created: 1500000000,
|
|
23
|
+
createdBy: 'test',
|
|
24
|
+
type: 'note/txt',
|
|
25
|
+
content: 'a plaintext note'
|
|
26
|
+
}, over);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('[ENCC] EventsCipher', function () {
|
|
30
|
+
describe('[ENCCE] encryptEvent', function () {
|
|
31
|
+
it('[ENCCEA] wraps type as encrypted/<method> and builds a payload content', async function () {
|
|
32
|
+
const cipher = newCipher();
|
|
33
|
+
const enc = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
34
|
+
expect(enc.type).to.equal('encrypted/aes-256-gcm');
|
|
35
|
+
expect(enc.content).to.have.property('payload').that.is.a('string');
|
|
36
|
+
expect(enc.content).to.have.property('keyRef', KEY_REF);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('[ENCCEB] passes non-content/type fields through as plaintext', async function () {
|
|
40
|
+
const cipher = newCipher();
|
|
41
|
+
const enc = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
42
|
+
expect(enc.id).to.equal('evt-1');
|
|
43
|
+
expect(enc.streamIds).to.deep.equal(['journal']);
|
|
44
|
+
expect(enc.time).to.equal(1500000000);
|
|
45
|
+
expect(enc.createdBy).to.equal('test');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('[ENCCEC] includes hint only when provided; omits keyRef/hint when absent', async function () {
|
|
49
|
+
const cipher = newCipher();
|
|
50
|
+
const withHint = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF, hint: { kid: 1 } });
|
|
51
|
+
expect(withHint.content.hint).to.deep.equal({ kid: 1 });
|
|
52
|
+
|
|
53
|
+
// key resolvable via a resolver (no keyRef), so content should carry neither keyRef nor hint
|
|
54
|
+
const keyring = new Keyring();
|
|
55
|
+
keyring.use(async () => vector.keyBase64);
|
|
56
|
+
const cipher2 = new EventsCipher(keyring);
|
|
57
|
+
const noRefs = await cipher2.encryptEvent(plainEvent(), { method: 'aes-256-gcm' });
|
|
58
|
+
expect(noRefs.content).to.not.have.property('keyRef');
|
|
59
|
+
expect(noRefs.content).to.not.have.property('hint');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('[ENCCED] does not mutate the input event', async function () {
|
|
63
|
+
const cipher = newCipher();
|
|
64
|
+
const input = plainEvent();
|
|
65
|
+
const snapshot = JSON.parse(JSON.stringify(input));
|
|
66
|
+
await cipher.encryptEvent(input, { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
67
|
+
expect(input).to.deep.equal(snapshot);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('[ENCCEE] throws on unknown method', async function () {
|
|
71
|
+
const cipher = newCipher();
|
|
72
|
+
await expect(cipher.encryptEvent(plainEvent(), { method: 'no-such', keyRef: KEY_REF }))
|
|
73
|
+
.to.be.rejected;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('[ENCCEF] throws when no key can be resolved', async function () {
|
|
77
|
+
const cipher = newCipher();
|
|
78
|
+
await expect(cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: 'unknown-ref' }))
|
|
79
|
+
.to.be.rejected;
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('[ENCCU] encryptEventContent', function () {
|
|
84
|
+
it('[ENCCUA] returns exactly { type: encrypted/<method>, content: { payload } }', async function () {
|
|
85
|
+
const cipher = newCipher();
|
|
86
|
+
const res = await cipher.encryptEventContent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
87
|
+
expect(res).to.have.all.keys(['type', 'content']);
|
|
88
|
+
expect(res.type).to.equal('encrypted/aes-256-gcm');
|
|
89
|
+
expect(res.content).to.have.property('payload').that.is.a('string');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('[ENCCUB] includes keyRef in content when provided', async function () {
|
|
93
|
+
const cipher = newCipher();
|
|
94
|
+
const res = await cipher.encryptEventContent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
95
|
+
expect(res.content).to.have.property('keyRef', KEY_REF);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('[ENCCUC] includes hint when provided; omits keyRef/hint when resolvable without refs', async function () {
|
|
99
|
+
const cipher = newCipher();
|
|
100
|
+
const withHint = await cipher.encryptEventContent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF, hint: { kid: 1 } });
|
|
101
|
+
expect(withHint.content.hint).to.deep.equal({ kid: 1 });
|
|
102
|
+
|
|
103
|
+
const keyring = new Keyring();
|
|
104
|
+
keyring.use(async () => vector.keyBase64);
|
|
105
|
+
const cipher2 = new EventsCipher(keyring);
|
|
106
|
+
const noRefs = await cipher2.encryptEventContent(plainEvent(), { method: 'aes-256-gcm' });
|
|
107
|
+
expect(noRefs.content).to.not.have.property('keyRef');
|
|
108
|
+
expect(noRefs.content).to.not.have.property('hint');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('[ENCCUD] does not mutate the input material', async function () {
|
|
112
|
+
const cipher = newCipher();
|
|
113
|
+
const input = plainEvent();
|
|
114
|
+
const snapshot = JSON.parse(JSON.stringify(input));
|
|
115
|
+
await cipher.encryptEventContent(input, { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
116
|
+
expect(input).to.deep.equal(snapshot);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('[ENCCUE] only carries type + content of the material into the ciphertext (envelope fields excluded)', async function () {
|
|
120
|
+
const cipher = newCipher();
|
|
121
|
+
const res = await cipher.encryptEventContent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
122
|
+
// assemble a synthetic event and decrypt: restored material must be just type/content
|
|
123
|
+
const synthetic = { id: 'x', streamIds: ['s'], type: res.type, content: res.content };
|
|
124
|
+
const dec = await cipher.decryptEvent(synthetic);
|
|
125
|
+
expect(dec.type).to.equal('note/txt');
|
|
126
|
+
expect(dec.content).to.equal('a plaintext note');
|
|
127
|
+
// envelope-only fields of the ORIGINAL material must not have leaked into the payload
|
|
128
|
+
expect(dec).to.not.have.property('createdBy', 'test-should-not-be-in-payload');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('[ENCCUF] round-trips: a synthetic event assembled from the result decrypts back', async function () {
|
|
132
|
+
const cipher = newCipher();
|
|
133
|
+
const material = { type: 'note/txt', content: 'updated body' };
|
|
134
|
+
const res = await cipher.encryptEventContent(material, { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
135
|
+
const synthetic = { id: 'evt-9', streamIds: ['journal'], type: res.type, content: res.content };
|
|
136
|
+
const dec = await cipher.decryptEvent(synthetic);
|
|
137
|
+
expect(dec.type).to.equal('note/txt');
|
|
138
|
+
expect(dec.content).to.equal('updated body');
|
|
139
|
+
expect(dec.decryptedFrom).to.equal(synthetic);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('[ENCCUG] throws on unknown method', async function () {
|
|
143
|
+
const cipher = newCipher();
|
|
144
|
+
await expect(cipher.encryptEventContent(plainEvent(), { method: 'no-such', keyRef: KEY_REF }))
|
|
145
|
+
.to.be.rejected;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('[ENCCUH] throws when the method is decrypt-only', async function () {
|
|
149
|
+
const cipher = newCipher();
|
|
150
|
+
cipher.registerMethod('read-only', {
|
|
151
|
+
decrypt: async (content, key) => JSON.parse(content.payload)
|
|
152
|
+
});
|
|
153
|
+
await expect(cipher.encryptEventContent(plainEvent(), { method: 'read-only', keyRef: KEY_REF }))
|
|
154
|
+
.to.be.rejected;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('[ENCCUI] throws when no key can be resolved', async function () {
|
|
158
|
+
const cipher = newCipher();
|
|
159
|
+
await expect(cipher.encryptEventContent(plainEvent(), { method: 'aes-256-gcm', keyRef: 'unknown-ref' }))
|
|
160
|
+
.to.be.rejected;
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('[ENCCD] decryptEvent', function () {
|
|
165
|
+
let cipher;
|
|
166
|
+
let encrypted;
|
|
167
|
+
beforeEach(async function () {
|
|
168
|
+
cipher = newCipher();
|
|
169
|
+
encrypted = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('[ENCCDA] decrypts to a NEW object with restored type/content + decryptedFrom', async function () {
|
|
173
|
+
const out = await cipher.decryptEvent(encrypted);
|
|
174
|
+
expect(out).to.not.equal(encrypted);
|
|
175
|
+
expect(out.type).to.equal('note/txt');
|
|
176
|
+
expect(out.content).to.equal('a plaintext note');
|
|
177
|
+
expect(out.decryptedFrom).to.equal(encrypted);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('[ENCCDB] preserves envelope fields from the encrypted event', async function () {
|
|
181
|
+
const out = await cipher.decryptEvent(encrypted);
|
|
182
|
+
expect(out.id).to.equal('evt-1');
|
|
183
|
+
expect(out.streamIds).to.deep.equal(['journal']);
|
|
184
|
+
expect(out.time).to.equal(1500000000);
|
|
185
|
+
expect(out.createdBy).to.equal('test');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('[ENCCDC] does not mutate the original encrypted event', async function () {
|
|
189
|
+
const snapshot = JSON.parse(JSON.stringify(encrypted));
|
|
190
|
+
await cipher.decryptEvent(encrypted);
|
|
191
|
+
expect(encrypted).to.deep.equal(snapshot);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('[ENCCDD] returns the SAME reference when type is not encrypted/<method>', async function () {
|
|
195
|
+
const ev = plainEvent();
|
|
196
|
+
const out = await cipher.decryptEvent(ev);
|
|
197
|
+
expect(out).to.equal(ev);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('[ENCCDE] returns the SAME reference when method is not registered', async function () {
|
|
201
|
+
const ev = plainEvent({ type: 'encrypted/unregistered-method', content: { payload: 'x' } });
|
|
202
|
+
const out = await cipher.decryptEvent(ev);
|
|
203
|
+
expect(out).to.equal(ev);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('[ENCCDF] no key found: returns SAME ref, calls onDecryptError, never throws', async function () {
|
|
207
|
+
const errors = [];
|
|
208
|
+
const keyring = new Keyring(); // empty store, no resolvers
|
|
209
|
+
const c = new EventsCipher(keyring, { onDecryptError: (event, error) => errors.push({ event, error }) });
|
|
210
|
+
const out = await c.decryptEvent(encrypted);
|
|
211
|
+
expect(out).to.equal(encrypted);
|
|
212
|
+
expect(errors).to.have.length(1);
|
|
213
|
+
expect(errors[0].event).to.equal(encrypted);
|
|
214
|
+
expect(errors[0].error).to.be.instanceOf(Error);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('[ENCCDG] resolver that throws: returns SAME ref, calls onDecryptError, never throws', async function () {
|
|
218
|
+
const errors = [];
|
|
219
|
+
const keyring = new Keyring();
|
|
220
|
+
keyring.use(async () => { throw new Error('resolver boom'); });
|
|
221
|
+
const c = new EventsCipher(keyring, { onDecryptError: (event, error) => errors.push(error) });
|
|
222
|
+
const out = await c.decryptEvent(encrypted);
|
|
223
|
+
expect(out).to.equal(encrypted);
|
|
224
|
+
expect(errors).to.have.length(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('[ENCCDH] wrong key: returns SAME ref, calls onDecryptError', async function () {
|
|
228
|
+
const errors = [];
|
|
229
|
+
const keyring = new Keyring({ [KEY_REF]: 'BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU=' });
|
|
230
|
+
const c = new EventsCipher(keyring, { onDecryptError: () => errors.push(1) });
|
|
231
|
+
const out = await c.decryptEvent(encrypted);
|
|
232
|
+
expect(out).to.equal(encrypted);
|
|
233
|
+
expect(errors).to.have.length(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('[ENCCDI] malformed payload: returns SAME ref', async function () {
|
|
237
|
+
const bad = plainEvent({ type: 'encrypted/aes-256-gcm', content: { payload: 'not-a-valid-payload', keyRef: KEY_REF } });
|
|
238
|
+
const out = await cipher.decryptEvent(bad);
|
|
239
|
+
expect(out).to.equal(bad);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('[ENCCDJ] decrypted material lacking type/content: returns SAME ref', async function () {
|
|
243
|
+
// encrypt an event whose type is missing -> decrypted material won't carry `type`
|
|
244
|
+
const halfPlain = { id: 'h1', streamIds: ['s'], content: 'no type here' };
|
|
245
|
+
const enc = await cipher.encryptEvent(halfPlain, { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
246
|
+
const errors = [];
|
|
247
|
+
const c = newCipher({ onDecryptError: () => errors.push(1) });
|
|
248
|
+
const out = await c.decryptEvent(enc);
|
|
249
|
+
expect(out).to.equal(enc);
|
|
250
|
+
expect(errors).to.have.length(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('[ENCCDK] default onDecryptError is silent (no throw)', async function () {
|
|
254
|
+
const keyring = new Keyring();
|
|
255
|
+
const c = new EventsCipher(keyring);
|
|
256
|
+
const out = await c.decryptEvent(encrypted);
|
|
257
|
+
expect(out).to.equal(encrypted);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('[ENCCB] batch + streaming helpers', function () {
|
|
262
|
+
it('[ENCCBA] decryptEvents maps an array, decrypting matching events', async function () {
|
|
263
|
+
const cipher = newCipher();
|
|
264
|
+
const enc = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
265
|
+
const plain = plainEvent({ id: 'evt-2' });
|
|
266
|
+
const out = await cipher.decryptEvents([enc, plain]);
|
|
267
|
+
expect(out).to.have.length(2);
|
|
268
|
+
expect(out[0].type).to.equal('note/txt');
|
|
269
|
+
expect(out[0].decryptedFrom).to.equal(enc);
|
|
270
|
+
expect(out[1]).to.equal(plain); // untouched
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('[ENCCBB] wrapForEachEvent decrypts then forwards to the user callback', async function () {
|
|
274
|
+
const cipher = newCipher();
|
|
275
|
+
const enc = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
276
|
+
const seen = [];
|
|
277
|
+
const wrapped = cipher.wrapForEachEvent((event) => seen.push(event));
|
|
278
|
+
await wrapped(enc);
|
|
279
|
+
expect(seen).to.have.length(1);
|
|
280
|
+
expect(seen[0].type).to.equal('note/txt');
|
|
281
|
+
expect(seen[0].decryptedFrom).to.equal(enc);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('[ENCCS] stripDecrypted', function () {
|
|
286
|
+
it('[ENCCSA] returns the original encrypted event for a decrypted one', async function () {
|
|
287
|
+
const cipher = newCipher();
|
|
288
|
+
const enc = await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
289
|
+
const dec = await cipher.decryptEvent(enc);
|
|
290
|
+
expect(cipher.stripDecrypted(dec)).to.equal(enc);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('[ENCCSB] returns the event itself when it was not decrypted', function () {
|
|
294
|
+
const cipher = newCipher();
|
|
295
|
+
const ev = plainEvent();
|
|
296
|
+
expect(cipher.stripDecrypted(ev)).to.equal(ev);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('[ENCCM] registerMethod', function () {
|
|
301
|
+
it('[ENCCMA] registers a custom method usable for encrypt + decrypt', async function () {
|
|
302
|
+
const keyring = new Keyring({ [KEY_REF]: 'passthrough' });
|
|
303
|
+
const cipher = new EventsCipher(keyring);
|
|
304
|
+
// a trivial (insecure, test-only) method that base64-JSONs the material
|
|
305
|
+
cipher.registerMethod('plain-json', {
|
|
306
|
+
encrypt: async (material, key) => ({ payload: JSON.stringify(material) }),
|
|
307
|
+
decrypt: async (content, key) => JSON.parse(content.payload)
|
|
308
|
+
});
|
|
309
|
+
const enc = await cipher.encryptEvent(plainEvent(), { method: 'plain-json', keyRef: KEY_REF });
|
|
310
|
+
expect(enc.type).to.equal('encrypted/plain-json');
|
|
311
|
+
const dec = await cipher.decryptEvent(enc);
|
|
312
|
+
expect(dec.type).to.equal('note/txt');
|
|
313
|
+
expect(dec.content).to.equal('a plaintext note');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('[ENCCMC] accepts a decrypt-only method (no encrypt)', async function () {
|
|
317
|
+
const cipher = newCipher();
|
|
318
|
+
cipher.registerMethod('read-only', {
|
|
319
|
+
decrypt: async (content, key) => JSON.parse(content.payload)
|
|
320
|
+
});
|
|
321
|
+
const ev = plainEvent({
|
|
322
|
+
type: 'encrypted/read-only',
|
|
323
|
+
content: { payload: JSON.stringify({ type: 'note/txt', content: 'decoded' }), keyRef: KEY_REF }
|
|
324
|
+
});
|
|
325
|
+
const dec = await cipher.decryptEvent(ev);
|
|
326
|
+
expect(dec.type).to.equal('note/txt');
|
|
327
|
+
expect(dec.content).to.equal('decoded');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('[ENCCMD] encryptEvent with a decrypt-only method throws', async function () {
|
|
331
|
+
const cipher = newCipher();
|
|
332
|
+
cipher.registerMethod('read-only', {
|
|
333
|
+
decrypt: async (content, key) => JSON.parse(content.payload)
|
|
334
|
+
});
|
|
335
|
+
await expect(cipher.encryptEvent(plainEvent(), { method: 'read-only', keyRef: KEY_REF }))
|
|
336
|
+
.to.be.rejected;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('[ENCCMB] can override a built-in method', async function () {
|
|
340
|
+
const cipher = newCipher();
|
|
341
|
+
let used = false;
|
|
342
|
+
cipher.registerMethod('aes-256-gcm', {
|
|
343
|
+
encrypt: async (material) => { used = true; return { payload: JSON.stringify(material) }; },
|
|
344
|
+
decrypt: async (content) => JSON.parse(content.payload)
|
|
345
|
+
});
|
|
346
|
+
await cipher.encryptEvent(plainEvent(), { method: 'aes-256-gcm', keyRef: KEY_REF });
|
|
347
|
+
expect(used).to.equal(true);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Deterministic AES-256-GCM test vector. payload = base64(iv || ciphertext || gcmTag). Generated once with a fixed 32-byte key (bytes 0..31) and a fixed 12-byte IV (bytes 0..11).",
|
|
3
|
+
"keyBase64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
|
4
|
+
"ivBase64": "AAECAwQFBgcICQoL",
|
|
5
|
+
"plaintextObject": {
|
|
6
|
+
"type": "note/txt",
|
|
7
|
+
"content": "secret message"
|
|
8
|
+
},
|
|
9
|
+
"plaintextJson": "{\"type\":\"note/txt\",\"content\":\"secret message\"}",
|
|
10
|
+
"payload": "AAECAwQFBgcICQoLPCCiYrWA4CGvL/j/1MYMFff0qxaTFDEIXQmRpydLc9diYsuIj6x36wfFGIiq+v9cBfRF8sIXTsUY8kkKszA="
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Legacy aes-text-base64 known-answer vectors. payload = base64(\"Salted__\" || salt[8] || AES-256-CBC(JSON, key, iv)); key+iv derived from the passphrase STRING via OpenSSL EVP_BytesToKey (MD5, 1 iteration). Generated by generate-aes-text-base64.js using node:crypto as an implementation-independent oracle.",
|
|
3
|
+
"vectors": [
|
|
4
|
+
{
|
|
5
|
+
"passphrase": "correct horse battery staple",
|
|
6
|
+
"plaintextObject": {
|
|
7
|
+
"type": "note/txt",
|
|
8
|
+
"content": "a legacy secret note"
|
|
9
|
+
},
|
|
10
|
+
"plaintextJson": "{\"type\":\"note/txt\",\"content\":\"a legacy secret note\"}",
|
|
11
|
+
"saltHex": "0102030405060708",
|
|
12
|
+
"payload": "U2FsdGVkX18BAgMEBQYHCCFXymfQ/729Q7VKEGY0z96dPk5U5mmdyxlVYODjTVlk4HeEYoe5lpeKH6WKiWG942x67xmSLxCd7cc0KDG+5Tw="
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"passphrase": "p@ssw0rd — ünîcode ✓",
|
|
16
|
+
"plaintextObject": {
|
|
17
|
+
"type": "mass/kg",
|
|
18
|
+
"content": {
|
|
19
|
+
"value": 42,
|
|
20
|
+
"extra": [
|
|
21
|
+
1,
|
|
22
|
+
2,
|
|
23
|
+
3
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"plaintextJson": "{\"type\":\"mass/kg\",\"content\":{\"value\":42,\"extra\":[1,2,3]}}",
|
|
28
|
+
"saltHex": "a1b2c3d4e5f60718",
|
|
29
|
+
"payload": "U2FsdGVkX1+hssPU5fYHGFIbDqx7aqo8mD7JDhgtW/15wTNO8fWYALHGXd+RsnnIuajQ41t1JAVlCh/Ay2Q6QD7dDALC0AZRoOytp7oMIoU="
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Known-answer decrypt vector for ecies-aes-256-gcm. payload = base64(ephemeralPublicKey[65] || iv[12] || ciphertext || tag[16]); AES-256-GCM key = HKDF-SHA-256(ECDH(ephemeral, recipient), salt=<empty>, info=\"encrypted/ecies-aes-256-gcm\"). Recipient + ephemeral key pairs, IV and plaintext are all fixed. Generated by generate-ecies.js (Node WebCrypto).",
|
|
3
|
+
"recipient": {
|
|
4
|
+
"publicKey": {
|
|
5
|
+
"kty": "EC",
|
|
6
|
+
"crv": "P-256",
|
|
7
|
+
"x": "vN2pTQ7eQd3gDo9E9-T6XlK4J7nkkHDNPo5CEi54nIg",
|
|
8
|
+
"y": "v-noMfXE5bc8WQr7DLUVMw186XLeYnqNEyZEBacQnes"
|
|
9
|
+
},
|
|
10
|
+
"privateKey": {
|
|
11
|
+
"kty": "EC",
|
|
12
|
+
"crv": "P-256",
|
|
13
|
+
"x": "vN2pTQ7eQd3gDo9E9-T6XlK4J7nkkHDNPo5CEi54nIg",
|
|
14
|
+
"y": "v-noMfXE5bc8WQr7DLUVMw186XLeYnqNEyZEBacQnes",
|
|
15
|
+
"d": "kCm5Pa7sj_7firW_nild7COqV0eR3vI5ofaBJTRY3h4"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"ephemeral": {
|
|
19
|
+
"publicKey": {
|
|
20
|
+
"kty": "EC",
|
|
21
|
+
"crv": "P-256",
|
|
22
|
+
"x": "itduLILJK0LFNFSX4DJGsJveY0eMALL72we3KH38VDs",
|
|
23
|
+
"y": "XkK7ZETz45gBXbDDJS7QXpFKxLBQngvw8J29dDj04C4"
|
|
24
|
+
},
|
|
25
|
+
"privateKey": {
|
|
26
|
+
"kty": "EC",
|
|
27
|
+
"crv": "P-256",
|
|
28
|
+
"x": "itduLILJK0LFNFSX4DJGsJveY0eMALL72we3KH38VDs",
|
|
29
|
+
"y": "XkK7ZETz45gBXbDDJS7QXpFKxLBQngvw8J29dDj04C4",
|
|
30
|
+
"d": "Dm1NsoliiiaCF6PvfQMupI4a1FJ8UQhfy2faUXtYxU4"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"ivBase64": "AAECAwQFBgcICQoL",
|
|
34
|
+
"plaintextObject": {
|
|
35
|
+
"type": "note/txt",
|
|
36
|
+
"content": "an asymmetric secret note"
|
|
37
|
+
},
|
|
38
|
+
"plaintextJson": "{\"type\":\"note/txt\",\"content\":\"an asymmetric secret note\"}",
|
|
39
|
+
"payload": "BIrXbiyCyStCxTRUl+AyRrCb3mNHjACy+9sHtyh9/FQ7XkK7ZETz45gBXbDDJS7QXpFKxLBQngvw8J29dDj04C4AAQIDBAUGBwgJCguCp20WLofBEKOMIn3UAuXg8+iUZ9VUVw6wn7MmNIOX8Rg9tK6/Cr0A0o8Zecv+MsnHsZplOb2aOEZy7lwe7QmNPDLxBNo5N2iK"
|
|
40
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* One-off generator for `aes-text-base64.json` — an implementation-independent
|
|
7
|
+
* oracle for the legacy `aes-text-base64` decrypt-only method.
|
|
8
|
+
*
|
|
9
|
+
* The legacy on-disk format is the one produced historically by
|
|
10
|
+
* `CryptoJS.AES.encrypt(text, passphrase).toString()`:
|
|
11
|
+
*
|
|
12
|
+
* base64( "Salted__" || salt[8] || AES-256-CBC(text, key, iv) )
|
|
13
|
+
*
|
|
14
|
+
* where `key` (32 bytes) and `iv` (16 bytes) are derived from the passphrase
|
|
15
|
+
* STRING and the salt via OpenSSL's EVP_BytesToKey (MD5, one iteration), and
|
|
16
|
+
* the cipher is AES-256-CBC with PKCS#7 padding.
|
|
17
|
+
*
|
|
18
|
+
* This script depends ONLY on `node:crypto` (createHash('md5') +
|
|
19
|
+
* createCipheriv('aes-256-cbc', …)) so it is an oracle independent of the
|
|
20
|
+
* WebCrypto implementation under test. Salts are FIXED so the emitted vectors
|
|
21
|
+
* are deterministic and can be committed as known-answer fixtures.
|
|
22
|
+
*
|
|
23
|
+
* Run: `node components/pryv-encryption/test/fixtures/generate-aes-text-base64.js`
|
|
24
|
+
*/
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const crypto = require('node:crypto');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* OpenSSL EVP_BytesToKey with MD5 and a single hashing chain (count = 1).
|
|
33
|
+
* @param {Buffer} passphrase - the passphrase bytes (UTF-8 of the string).
|
|
34
|
+
* @param {Buffer} salt - 8 salt bytes.
|
|
35
|
+
* @param {number} keyLen - key length in bytes (32 for AES-256).
|
|
36
|
+
* @param {number} ivLen - IV length in bytes (16 for CBC).
|
|
37
|
+
* @returns {{ key: Buffer, iv: Buffer }}
|
|
38
|
+
*/
|
|
39
|
+
function evpBytesToKey (passphrase, salt, keyLen, ivLen) {
|
|
40
|
+
let derived = Buffer.alloc(0);
|
|
41
|
+
let block = Buffer.alloc(0);
|
|
42
|
+
while (derived.length < keyLen + ivLen) {
|
|
43
|
+
const hash = crypto.createHash('md5');
|
|
44
|
+
hash.update(block);
|
|
45
|
+
hash.update(passphrase);
|
|
46
|
+
hash.update(salt);
|
|
47
|
+
block = hash.digest();
|
|
48
|
+
derived = Buffer.concat([derived, block]);
|
|
49
|
+
}
|
|
50
|
+
return { key: derived.subarray(0, keyLen), iv: derived.subarray(keyLen, keyLen + ivLen) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Encrypt a material object into a legacy `aes-text-base64` payload.
|
|
55
|
+
* @param {string} passphrase
|
|
56
|
+
* @param {Object} material - the `{ type, content }` object to protect.
|
|
57
|
+
* @param {Buffer} salt - 8 fixed salt bytes.
|
|
58
|
+
* @returns {{ plaintextJson: string, payload: string, saltHex: string }}
|
|
59
|
+
*/
|
|
60
|
+
function encryptLegacy (passphrase, material, salt) {
|
|
61
|
+
const plaintextJson = JSON.stringify(material);
|
|
62
|
+
const { key, iv } = evpBytesToKey(Buffer.from(passphrase, 'utf8'), salt, 32, 16);
|
|
63
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); // PKCS#7 padding by default
|
|
64
|
+
const ciphertext = Buffer.concat([cipher.update(plaintextJson, 'utf8'), cipher.final()]);
|
|
65
|
+
const blob = Buffer.concat([Buffer.from('Salted__', 'ascii'), salt, ciphertext]);
|
|
66
|
+
return { plaintextJson, payload: blob.toString('base64'), saltHex: salt.toString('hex') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const cases = [
|
|
70
|
+
{
|
|
71
|
+
passphrase: 'correct horse battery staple',
|
|
72
|
+
material: { type: 'note/txt', content: 'a legacy secret note' },
|
|
73
|
+
salt: Buffer.from('0102030405060708', 'hex')
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
passphrase: 'p@ssw0rd — ünîcode ✓',
|
|
77
|
+
material: { type: 'mass/kg', content: { value: 42, extra: [1, 2, 3] } },
|
|
78
|
+
salt: Buffer.from('a1b2c3d4e5f60718', 'hex')
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const vectors = cases.map((c) => {
|
|
83
|
+
const { plaintextJson, payload, saltHex } = encryptLegacy(c.passphrase, c.material, c.salt);
|
|
84
|
+
return {
|
|
85
|
+
passphrase: c.passphrase,
|
|
86
|
+
plaintextObject: c.material,
|
|
87
|
+
plaintextJson,
|
|
88
|
+
saltHex,
|
|
89
|
+
payload
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const out = {
|
|
94
|
+
description: 'Legacy aes-text-base64 known-answer vectors. payload = base64("Salted__" || salt[8] || AES-256-CBC(JSON, key, iv)); key+iv derived from the passphrase STRING via OpenSSL EVP_BytesToKey (MD5, 1 iteration). Generated by generate-aes-text-base64.js using node:crypto as an implementation-independent oracle.',
|
|
95
|
+
vectors
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const target = path.join(__dirname, 'aes-text-base64.json');
|
|
99
|
+
fs.writeFileSync(target, JSON.stringify(out, null, 2) + '\n');
|
|
100
|
+
console.log('wrote', target, 'with', vectors.length, 'vectors');
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* One-off generator for `ecies-aes-256-gcm.json` — a known-answer DECRYPT
|
|
7
|
+
* vector for the asymmetric `ecies-aes-256-gcm` method.
|
|
8
|
+
*
|
|
9
|
+
* The wire format is FROZEN (published in the event-type registry):
|
|
10
|
+
*
|
|
11
|
+
* payload = base64( ephemeralPublicKey[65, SEC1 uncompressed 0x04||X||Y]
|
|
12
|
+
* || iv[12] || ciphertext || tag[16] )
|
|
13
|
+
*
|
|
14
|
+
* Encryption steps:
|
|
15
|
+
* 1. ECDH(ephemeral private, recipient public) -> 32-byte shared secret.
|
|
16
|
+
* 2. HKDF-SHA-256(secret, salt = <empty>, info = ASCII "encrypted/ecies-aes-256-gcm"),
|
|
17
|
+
* length 32 -> AES-256-GCM key.
|
|
18
|
+
* 3. AES-256-GCM(plaintext, key, iv) where plaintext = UTF-8 JSON of material.
|
|
19
|
+
*
|
|
20
|
+
* To make the vector reproducible and byte-exact, BOTH the recipient and the
|
|
21
|
+
* ephemeral key pairs, the 12-byte IV and the plaintext are FIXED here (the
|
|
22
|
+
* runtime `encrypt` uses a random ephemeral + random IV by design, which the
|
|
23
|
+
* round-trip tests cover separately). Generation runs on Node's WebCrypto; the
|
|
24
|
+
* emitted vector is plain JSON.
|
|
25
|
+
*
|
|
26
|
+
* Run: `node components/pryv-encryption/test/fixtures/generate-ecies.js`
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const { webcrypto } = require('node:crypto');
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const subtle = webcrypto.subtle;
|
|
35
|
+
const INFO = 'encrypted/ecies-aes-256-gcm';
|
|
36
|
+
|
|
37
|
+
// Fixed recipient key pair (P-256, JWK).
|
|
38
|
+
const RECIPIENT = {
|
|
39
|
+
publicKey: {
|
|
40
|
+
kty: 'EC',
|
|
41
|
+
crv: 'P-256',
|
|
42
|
+
x: 'vN2pTQ7eQd3gDo9E9-T6XlK4J7nkkHDNPo5CEi54nIg',
|
|
43
|
+
y: 'v-noMfXE5bc8WQr7DLUVMw186XLeYnqNEyZEBacQnes'
|
|
44
|
+
},
|
|
45
|
+
privateKey: {
|
|
46
|
+
kty: 'EC',
|
|
47
|
+
crv: 'P-256',
|
|
48
|
+
x: 'vN2pTQ7eQd3gDo9E9-T6XlK4J7nkkHDNPo5CEi54nIg',
|
|
49
|
+
y: 'v-noMfXE5bc8WQr7DLUVMw186XLeYnqNEyZEBacQnes',
|
|
50
|
+
d: 'kCm5Pa7sj_7firW_nild7COqV0eR3vI5ofaBJTRY3h4'
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Fixed ephemeral key pair (P-256, JWK).
|
|
55
|
+
const EPHEMERAL = {
|
|
56
|
+
publicKey: {
|
|
57
|
+
kty: 'EC',
|
|
58
|
+
crv: 'P-256',
|
|
59
|
+
x: 'itduLILJK0LFNFSX4DJGsJveY0eMALL72we3KH38VDs',
|
|
60
|
+
y: 'XkK7ZETz45gBXbDDJS7QXpFKxLBQngvw8J29dDj04C4'
|
|
61
|
+
},
|
|
62
|
+
privateKey: {
|
|
63
|
+
kty: 'EC',
|
|
64
|
+
crv: 'P-256',
|
|
65
|
+
x: 'itduLILJK0LFNFSX4DJGsJveY0eMALL72we3KH38VDs',
|
|
66
|
+
y: 'XkK7ZETz45gBXbDDJS7QXpFKxLBQngvw8J29dDj04C4',
|
|
67
|
+
d: 'Dm1NsoliiiaCF6PvfQMupI4a1FJ8UQhfy2faUXtYxU4'
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Fixed 12-byte IV and plaintext material.
|
|
72
|
+
const IV = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
|
73
|
+
const MATERIAL = { type: 'note/txt', content: 'an asymmetric secret note' };
|
|
74
|
+
|
|
75
|
+
function bytesToBase64 (bytes) {
|
|
76
|
+
let bin = '';
|
|
77
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
78
|
+
return Buffer.from(bin, 'binary').toString('base64');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main () {
|
|
82
|
+
// ECDH(ephemeral private, recipient public) -> 32-byte shared secret.
|
|
83
|
+
const ephPriv = await subtle.importKey('jwk', EPHEMERAL.privateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
|
|
84
|
+
const recipPub = await subtle.importKey('jwk', RECIPIENT.publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
85
|
+
const secretBits = await subtle.deriveBits({ name: 'ECDH', public: recipPub }, ephPriv, 256);
|
|
86
|
+
const secret = new Uint8Array(secretBits);
|
|
87
|
+
|
|
88
|
+
// HKDF-SHA-256 -> AES-256-GCM key.
|
|
89
|
+
const hkdfKey = await subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']);
|
|
90
|
+
const aesBits = await subtle.deriveBits(
|
|
91
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(INFO) },
|
|
92
|
+
hkdfKey,
|
|
93
|
+
256
|
|
94
|
+
);
|
|
95
|
+
const aesKey = await subtle.importKey('raw', new Uint8Array(aesBits), 'AES-GCM', false, ['encrypt']);
|
|
96
|
+
|
|
97
|
+
// AES-256-GCM encrypt.
|
|
98
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(MATERIAL));
|
|
99
|
+
const cipherBuf = await subtle.encrypt({ name: 'AES-GCM', iv: IV }, aesKey, plaintext);
|
|
100
|
+
const cipherBytes = new Uint8Array(cipherBuf);
|
|
101
|
+
|
|
102
|
+
// Ephemeral public key as raw SEC1 uncompressed (65 bytes: 0x04 || X || Y).
|
|
103
|
+
const ephPub = await subtle.importKey('jwk', EPHEMERAL.publicKey, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
|
|
104
|
+
const ephRaw = new Uint8Array(await subtle.exportKey('raw', ephPub));
|
|
105
|
+
if (ephRaw.length !== 65 || ephRaw[0] !== 0x04) {
|
|
106
|
+
throw new Error('unexpected ephemeral public key raw encoding');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const out = new Uint8Array(ephRaw.length + IV.length + cipherBytes.length);
|
|
110
|
+
out.set(ephRaw, 0);
|
|
111
|
+
out.set(IV, ephRaw.length);
|
|
112
|
+
out.set(cipherBytes, ephRaw.length + IV.length);
|
|
113
|
+
|
|
114
|
+
const fixture = {
|
|
115
|
+
description: 'Known-answer decrypt vector for ecies-aes-256-gcm. payload = base64(ephemeralPublicKey[65] || iv[12] || ciphertext || tag[16]); AES-256-GCM key = HKDF-SHA-256(ECDH(ephemeral, recipient), salt=<empty>, info="' + INFO + '"). Recipient + ephemeral key pairs, IV and plaintext are all fixed. Generated by generate-ecies.js (Node WebCrypto).',
|
|
116
|
+
recipient: RECIPIENT,
|
|
117
|
+
ephemeral: EPHEMERAL,
|
|
118
|
+
ivBase64: bytesToBase64(IV),
|
|
119
|
+
plaintextObject: MATERIAL,
|
|
120
|
+
plaintextJson: JSON.stringify(MATERIAL),
|
|
121
|
+
payload: bytesToBase64(out)
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const target = path.join(__dirname, 'ecies-aes-256-gcm.json');
|
|
125
|
+
fs.writeFileSync(target, JSON.stringify(fixture, null, 2) + '\n');
|
|
126
|
+
console.log('wrote', target);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|