@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/src/lib/md5.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Minimal, dependency-free MD5 over bytes.
|
|
7
|
+
*
|
|
8
|
+
* ⚠ This exists SOLELY to reproduce the OpenSSL EVP_BytesToKey key derivation
|
|
9
|
+
* used by the legacy `aes-text-base64` decrypt-only method (MD5 is not part of
|
|
10
|
+
* WebCrypto). MD5 is cryptographically broken — it MUST NOT be used for
|
|
11
|
+
* anything other than reading pre-existing legacy ciphertext. Do not reach for
|
|
12
|
+
* it for hashing, integrity, or any new derivation.
|
|
13
|
+
*
|
|
14
|
+
* Implements RFC 1321. Operates on and returns `Uint8Array`; no `Buffer`, so it
|
|
15
|
+
* runs unchanged in the browser and in Node.js.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function toUint32 (n) { return n >>> 0; }
|
|
19
|
+
function rotl (x, c) { return toUint32((x << c) | (x >>> (32 - c))); }
|
|
20
|
+
|
|
21
|
+
const S = [
|
|
22
|
+
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
23
|
+
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
24
|
+
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
25
|
+
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// K[i] = floor(2^32 * abs(sin(i + 1))), precomputed at load.
|
|
29
|
+
const K = new Uint32Array(64);
|
|
30
|
+
for (let i = 0; i < 64; i++) {
|
|
31
|
+
K[i] = toUint32(Math.floor(Math.abs(Math.sin(i + 1)) * 0x100000000));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute the MD5 digest of the given bytes.
|
|
36
|
+
* @param {Uint8Array} message
|
|
37
|
+
* @returns {Uint8Array} 16-byte digest.
|
|
38
|
+
*/
|
|
39
|
+
function md5 (message) {
|
|
40
|
+
const originalLenBits = message.length * 8;
|
|
41
|
+
|
|
42
|
+
// Pad: append 0x80, then zeros, until length ≡ 56 (mod 64), then 64-bit length.
|
|
43
|
+
const paddedLen = ((message.length + 8) >> 6 << 6) + 64;
|
|
44
|
+
const bytes = new Uint8Array(paddedLen);
|
|
45
|
+
bytes.set(message);
|
|
46
|
+
bytes[message.length] = 0x80;
|
|
47
|
+
|
|
48
|
+
// 64-bit little-endian bit length (low 32 bits, then high 32 bits).
|
|
49
|
+
const lenLow = toUint32(originalLenBits);
|
|
50
|
+
const lenHigh = Math.floor(originalLenBits / 0x100000000) >>> 0;
|
|
51
|
+
bytes[paddedLen - 8] = lenLow & 0xff;
|
|
52
|
+
bytes[paddedLen - 7] = (lenLow >>> 8) & 0xff;
|
|
53
|
+
bytes[paddedLen - 6] = (lenLow >>> 16) & 0xff;
|
|
54
|
+
bytes[paddedLen - 5] = (lenLow >>> 24) & 0xff;
|
|
55
|
+
bytes[paddedLen - 4] = lenHigh & 0xff;
|
|
56
|
+
bytes[paddedLen - 3] = (lenHigh >>> 8) & 0xff;
|
|
57
|
+
bytes[paddedLen - 2] = (lenHigh >>> 16) & 0xff;
|
|
58
|
+
bytes[paddedLen - 1] = (lenHigh >>> 24) & 0xff;
|
|
59
|
+
|
|
60
|
+
let a0 = 0x67452301;
|
|
61
|
+
let b0 = 0xefcdab89;
|
|
62
|
+
let c0 = 0x98badcfe;
|
|
63
|
+
let d0 = 0x10325476;
|
|
64
|
+
|
|
65
|
+
const M = new Uint32Array(16);
|
|
66
|
+
for (let offset = 0; offset < paddedLen; offset += 64) {
|
|
67
|
+
for (let j = 0; j < 16; j++) {
|
|
68
|
+
const k = offset + j * 4;
|
|
69
|
+
M[j] = toUint32(bytes[k] | (bytes[k + 1] << 8) | (bytes[k + 2] << 16) | (bytes[k + 3] << 24));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let A = a0;
|
|
73
|
+
let B = b0;
|
|
74
|
+
let C = c0;
|
|
75
|
+
let D = d0;
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < 64; i++) {
|
|
78
|
+
let F;
|
|
79
|
+
let g;
|
|
80
|
+
if (i < 16) {
|
|
81
|
+
F = (B & C) | (~B & D);
|
|
82
|
+
g = i;
|
|
83
|
+
} else if (i < 32) {
|
|
84
|
+
F = (D & B) | (~D & C);
|
|
85
|
+
g = (5 * i + 1) % 16;
|
|
86
|
+
} else if (i < 48) {
|
|
87
|
+
F = B ^ C ^ D;
|
|
88
|
+
g = (3 * i + 5) % 16;
|
|
89
|
+
} else {
|
|
90
|
+
F = C ^ (B | (~D >>> 0));
|
|
91
|
+
g = (7 * i) % 16;
|
|
92
|
+
}
|
|
93
|
+
F = toUint32(F + A + K[i] + M[g]);
|
|
94
|
+
A = D;
|
|
95
|
+
D = C;
|
|
96
|
+
C = B;
|
|
97
|
+
B = toUint32(B + rotl(F, S[i]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
a0 = toUint32(a0 + A);
|
|
101
|
+
b0 = toUint32(b0 + B);
|
|
102
|
+
c0 = toUint32(c0 + C);
|
|
103
|
+
d0 = toUint32(d0 + D);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const out = new Uint8Array(16);
|
|
107
|
+
const words = [a0, b0, c0, d0];
|
|
108
|
+
for (let i = 0; i < 4; i++) {
|
|
109
|
+
out[i * 4] = words[i] & 0xff;
|
|
110
|
+
out[i * 4 + 1] = (words[i] >>> 8) & 0xff;
|
|
111
|
+
out[i * 4 + 2] = (words[i] >>> 16) & 0xff;
|
|
112
|
+
out[i * 4 + 3] = (words[i] >>> 24) & 0xff;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { md5 };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Built-in `aes-256-gcm` encryption method.
|
|
7
|
+
*
|
|
8
|
+
* Wire format of `content.payload`:
|
|
9
|
+
* base64( iv (12 bytes) || ciphertext || gcm-auth-tag (16 bytes) )
|
|
10
|
+
*
|
|
11
|
+
* WebCrypto's `subtle.encrypt` appends the 16-byte GCM authentication tag to
|
|
12
|
+
* the ciphertext it returns, so the payload is simply the random IV followed
|
|
13
|
+
* by that output, base64-encoded.
|
|
14
|
+
*
|
|
15
|
+
* Key material is accepted as a 32-byte `Uint8Array`, a base64 string of 32
|
|
16
|
+
* bytes, or an already-imported AES-GCM `CryptoKey`.
|
|
17
|
+
*/
|
|
18
|
+
const { bytesToBase64, base64ToBytes } = require('../lib/base64');
|
|
19
|
+
|
|
20
|
+
const ALGORITHM = 'AES-GCM';
|
|
21
|
+
const IV_LENGTH = 12; // bytes — the recommended GCM nonce length
|
|
22
|
+
const KEY_LENGTH = 32; // bytes — AES-256
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalise supported key-material shapes into an AES-GCM CryptoKey.
|
|
26
|
+
* @param {Uint8Array|string|CryptoKey} key
|
|
27
|
+
* @returns {Promise<CryptoKey>}
|
|
28
|
+
*/
|
|
29
|
+
async function importKey (key) {
|
|
30
|
+
if (typeof CryptoKey !== 'undefined' && key instanceof CryptoKey) return key;
|
|
31
|
+
|
|
32
|
+
let raw;
|
|
33
|
+
if (key instanceof Uint8Array) {
|
|
34
|
+
raw = key;
|
|
35
|
+
} else if (key instanceof ArrayBuffer) {
|
|
36
|
+
raw = new Uint8Array(key);
|
|
37
|
+
} else if (typeof key === 'string') {
|
|
38
|
+
raw = base64ToBytes(key);
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error('aes-256-gcm: unsupported key material (expected Uint8Array, base64 string or CryptoKey)');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (raw.length !== KEY_LENGTH) {
|
|
44
|
+
throw new Error(`aes-256-gcm: key must be ${KEY_LENGTH} bytes, got ${raw.length}`);
|
|
45
|
+
}
|
|
46
|
+
return globalThis.crypto.subtle.importKey('raw', raw, ALGORITHM, false, ['encrypt', 'decrypt']);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Encrypt raw bytes into the method's payload byte layout:
|
|
51
|
+
* iv (12 bytes) || ciphertext || gcm-auth-tag (16 bytes)
|
|
52
|
+
* These are exactly the bytes that Base64-decoding a `content.payload` yields.
|
|
53
|
+
* The input is not mutated.
|
|
54
|
+
* @param {Uint8Array} bytes - plaintext bytes to encrypt.
|
|
55
|
+
* @param {Uint8Array|string|CryptoKey} key
|
|
56
|
+
* @returns {Promise<Uint8Array>}
|
|
57
|
+
*/
|
|
58
|
+
async function encryptBytes (bytes, key) {
|
|
59
|
+
const cryptoKey = await importKey(key);
|
|
60
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
61
|
+
const cipherBuffer = await globalThis.crypto.subtle.encrypt({ name: ALGORITHM, iv }, cryptoKey, bytes);
|
|
62
|
+
const cipherBytes = new Uint8Array(cipherBuffer);
|
|
63
|
+
|
|
64
|
+
const out = new Uint8Array(iv.length + cipherBytes.length);
|
|
65
|
+
out.set(iv, 0);
|
|
66
|
+
out.set(cipherBytes, iv.length);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decrypt the method's payload byte layout back into raw plaintext bytes.
|
|
72
|
+
* Throws on any failure (bad key, tampered tag, truncated input).
|
|
73
|
+
* @param {Uint8Array} bytes - `iv || ciphertext || gcm-tag`.
|
|
74
|
+
* @param {Uint8Array|string|CryptoKey} key
|
|
75
|
+
* @returns {Promise<Uint8Array>}
|
|
76
|
+
*/
|
|
77
|
+
async function decryptBytes (bytes, key) {
|
|
78
|
+
if (bytes.length <= IV_LENGTH) {
|
|
79
|
+
throw new Error('aes-256-gcm: payload too short');
|
|
80
|
+
}
|
|
81
|
+
const cryptoKey = await importKey(key);
|
|
82
|
+
const iv = bytes.slice(0, IV_LENGTH);
|
|
83
|
+
const cipherBytes = bytes.slice(IV_LENGTH);
|
|
84
|
+
const plainBuffer = await globalThis.crypto.subtle.decrypt({ name: ALGORITHM, iv }, cryptoKey, cipherBytes);
|
|
85
|
+
return new Uint8Array(plainBuffer);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Encrypt a material object into an event `content`. Thin wrapper over
|
|
90
|
+
* {@link encryptBytes}: `payload = base64(encryptBytes(utf8(JSON.stringify(material))))`.
|
|
91
|
+
* @param {Object} material - the object to serialise + encrypt.
|
|
92
|
+
* @param {Uint8Array|string|CryptoKey} key
|
|
93
|
+
* @returns {Promise<{ payload: string }>}
|
|
94
|
+
*/
|
|
95
|
+
async function encrypt (material, key) {
|
|
96
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(material));
|
|
97
|
+
const out = await encryptBytes(plaintext, key);
|
|
98
|
+
return { payload: bytesToBase64(out) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Decrypt an event `content` back into its material object. Thin wrapper over
|
|
103
|
+
* {@link decryptBytes}. Throws on any failure (bad key, tampered tag, malformed /
|
|
104
|
+
* truncated payload, or non-JSON plaintext).
|
|
105
|
+
* @param {{ payload: string }} content
|
|
106
|
+
* @param {Uint8Array|string|CryptoKey} key
|
|
107
|
+
* @returns {Promise<Object>}
|
|
108
|
+
*/
|
|
109
|
+
async function decrypt (content, key) {
|
|
110
|
+
if (content == null || typeof content.payload !== 'string') {
|
|
111
|
+
throw new Error('aes-256-gcm: content.payload must be a base64 string');
|
|
112
|
+
}
|
|
113
|
+
const plainBytes = await decryptBytes(base64ToBytes(content.payload), key);
|
|
114
|
+
return JSON.parse(new TextDecoder().decode(plainBytes));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { encrypt, decrypt, encryptBytes, decryptBytes };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Legacy, decrypt-only `aes-text-base64` method.
|
|
7
|
+
*
|
|
8
|
+
* Reads the historical ciphertext format produced by
|
|
9
|
+
* `CryptoJS.AES.encrypt(text, passphrase).toString()` — the OpenSSL
|
|
10
|
+
* "salted" envelope:
|
|
11
|
+
*
|
|
12
|
+
* base64( "Salted__" || salt[8] || AES-256-CBC(text, key, iv) )
|
|
13
|
+
*
|
|
14
|
+
* The 32-byte AES key and 16-byte IV are derived from the passphrase STRING and
|
|
15
|
+
* the salt via OpenSSL's EVP_BytesToKey (MD5, one iteration). The cipher is
|
|
16
|
+
* AES-256-CBC with PKCS#7 padding, which WebCrypto's `AES-CBC` handles natively.
|
|
17
|
+
*
|
|
18
|
+
* Key material for THIS method is the passphrase STRING itself — not base64 of
|
|
19
|
+
* key bytes (unlike `aes-256-gcm`). See the component README.
|
|
20
|
+
*
|
|
21
|
+
* This method is decrypt-only: there is no `encrypt`. It exists to read
|
|
22
|
+
* pre-existing legacy events; new events must use a modern method.
|
|
23
|
+
*/
|
|
24
|
+
const { base64ToBytes } = require('../lib/base64');
|
|
25
|
+
const { md5 } = require('../lib/md5');
|
|
26
|
+
|
|
27
|
+
const SALTED_MAGIC = [0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]; // "Salted__"
|
|
28
|
+
const SALT_LENGTH = 8;
|
|
29
|
+
const KEY_LENGTH = 32; // AES-256
|
|
30
|
+
const IV_LENGTH = 16; // CBC block size
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* OpenSSL EVP_BytesToKey with MD5 and a single hashing chain (count = 1).
|
|
34
|
+
* @param {Uint8Array} passphrase - UTF-8 bytes of the passphrase string.
|
|
35
|
+
* @param {Uint8Array} salt - 8 salt bytes.
|
|
36
|
+
* @returns {{ key: Uint8Array, iv: Uint8Array }}
|
|
37
|
+
*/
|
|
38
|
+
function evpBytesToKey (passphrase, salt) {
|
|
39
|
+
const needed = KEY_LENGTH + IV_LENGTH;
|
|
40
|
+
const derived = new Uint8Array(needed);
|
|
41
|
+
let filled = 0;
|
|
42
|
+
let block = new Uint8Array(0);
|
|
43
|
+
while (filled < needed) {
|
|
44
|
+
const input = new Uint8Array(block.length + passphrase.length + salt.length);
|
|
45
|
+
input.set(block, 0);
|
|
46
|
+
input.set(passphrase, block.length);
|
|
47
|
+
input.set(salt, block.length + passphrase.length);
|
|
48
|
+
block = md5(input);
|
|
49
|
+
const take = Math.min(block.length, needed - filled);
|
|
50
|
+
derived.set(block.subarray(0, take), filled);
|
|
51
|
+
filled += take;
|
|
52
|
+
}
|
|
53
|
+
return { key: derived.slice(0, KEY_LENGTH), iv: derived.slice(KEY_LENGTH, KEY_LENGTH + IV_LENGTH) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decrypt a legacy `aes-text-base64` `content` back into its material object.
|
|
58
|
+
* Throws on any failure (missing/short payload, absent "Salted__" prefix, wrong
|
|
59
|
+
* passphrase / bad padding, or non-JSON plaintext).
|
|
60
|
+
* @param {{ payload: string }} content
|
|
61
|
+
* @param {string} key - the passphrase STRING.
|
|
62
|
+
* @returns {Promise<Object>}
|
|
63
|
+
*/
|
|
64
|
+
async function decrypt (content, key) {
|
|
65
|
+
if (content == null || typeof content.payload !== 'string') {
|
|
66
|
+
throw new Error('aes-text-base64: content.payload must be a base64 string');
|
|
67
|
+
}
|
|
68
|
+
if (typeof key !== 'string') {
|
|
69
|
+
throw new Error('aes-text-base64: key material must be the passphrase string');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const blob = base64ToBytes(content.payload);
|
|
73
|
+
if (blob.length < SALTED_MAGIC.length + SALT_LENGTH + IV_LENGTH) {
|
|
74
|
+
throw new Error('aes-text-base64: payload too short');
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < SALTED_MAGIC.length; i++) {
|
|
77
|
+
if (blob[i] !== SALTED_MAGIC[i]) {
|
|
78
|
+
throw new Error('aes-text-base64: missing "Salted__" prefix');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const salt = blob.slice(SALTED_MAGIC.length, SALTED_MAGIC.length + SALT_LENGTH);
|
|
83
|
+
const ciphertext = blob.slice(SALTED_MAGIC.length + SALT_LENGTH);
|
|
84
|
+
|
|
85
|
+
const passphrase = new TextEncoder().encode(key);
|
|
86
|
+
const { key: keyBytes, iv } = evpBytesToKey(passphrase, salt);
|
|
87
|
+
|
|
88
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['decrypt']);
|
|
89
|
+
const plainBuffer = await globalThis.crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext);
|
|
90
|
+
return JSON.parse(new TextDecoder().decode(plainBuffer));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { decrypt };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Asymmetric `ecies-aes-256-gcm` method (ECIES over NIST P-256 + AES-256-GCM).
|
|
7
|
+
*
|
|
8
|
+
* A sender encrypts to a recipient's PUBLIC key; only the holder of the matching
|
|
9
|
+
* PRIVATE key can decrypt. This enables sharing an encrypted event with someone
|
|
10
|
+
* without ever moving a shared secret.
|
|
11
|
+
*
|
|
12
|
+
* Wire format of `content.payload` (FROZEN — published in the event-type
|
|
13
|
+
* registry):
|
|
14
|
+
*
|
|
15
|
+
* base64( ephemeralPublicKey[65, SEC1 uncompressed 0x04||X||Y]
|
|
16
|
+
* || iv[12] || ciphertext || gcm-tag[16] )
|
|
17
|
+
*
|
|
18
|
+
* Scheme:
|
|
19
|
+
* 1. Generate an ephemeral P-256 key pair.
|
|
20
|
+
* 2. ECDH(ephemeral private, recipient public) -> 32-byte shared secret.
|
|
21
|
+
* 3. HKDF-SHA-256(secret, salt = <empty>, info = ASCII "encrypted/ecies-aes-256-gcm"),
|
|
22
|
+
* 32 bytes -> AES-256-GCM key.
|
|
23
|
+
* 4. AES-256-GCM(UTF-8 JSON of the material, key, random 12-byte IV).
|
|
24
|
+
* WebCrypto appends the 16-byte tag to the ciphertext it returns.
|
|
25
|
+
*
|
|
26
|
+
* Key material (see the component README):
|
|
27
|
+
* - decrypt: recipient PRIVATE key as a CryptoKey (ECDH), a JWK (has `d`), or
|
|
28
|
+
* base64 / Uint8Array PKCS#8.
|
|
29
|
+
* - encrypt: recipient PUBLIC key as a CryptoKey, a JWK (no `d`), a raw
|
|
30
|
+
* 65-byte Uint8Array (SEC1 uncompressed) or its base64.
|
|
31
|
+
* - either operation also accepts a `{ publicKey, privateKey }` pair object
|
|
32
|
+
* holding any of the above; the side the operation needs is picked.
|
|
33
|
+
*/
|
|
34
|
+
const { bytesToBase64, base64ToBytes } = require('../lib/base64');
|
|
35
|
+
|
|
36
|
+
const CURVE = 'P-256';
|
|
37
|
+
const ALGORITHM = { name: 'ECDH', namedCurve: CURVE };
|
|
38
|
+
const INFO = 'encrypted/ecies-aes-256-gcm';
|
|
39
|
+
const EPH_PUB_LENGTH = 65; // SEC1 uncompressed: 0x04 || X(32) || Y(32)
|
|
40
|
+
const IV_LENGTH = 12;
|
|
41
|
+
const GCM_TAG_LENGTH = 16;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mint a fresh recipient key pair as exportable JWK objects.
|
|
45
|
+
* @returns {Promise<{ publicKey: Object, privateKey: Object }>}
|
|
46
|
+
*/
|
|
47
|
+
async function generateKeyPair () {
|
|
48
|
+
const subtle = globalThis.crypto.subtle;
|
|
49
|
+
const pair = await subtle.generateKey(ALGORITHM, true, ['deriveBits']);
|
|
50
|
+
return {
|
|
51
|
+
publicKey: await subtle.exportKey('jwk', pair.publicKey),
|
|
52
|
+
privateKey: await subtle.exportKey('jwk', pair.privateKey)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalise any accepted key-material shape into an ECDH CryptoKey.
|
|
58
|
+
* @param {*} material - CryptoKey, JWK, raw/PKCS#8 Uint8Array, base64 string, or a `{ publicKey, privateKey }` pair.
|
|
59
|
+
* @param {'encrypt'|'decrypt'} usage - `encrypt` needs the PUBLIC key, `decrypt` the PRIVATE key.
|
|
60
|
+
* @returns {Promise<CryptoKey>}
|
|
61
|
+
*/
|
|
62
|
+
async function normalizeKey (material, usage) {
|
|
63
|
+
if (usage !== 'encrypt' && usage !== 'decrypt') {
|
|
64
|
+
throw new Error(`ecies-aes-256-gcm: unknown usage "${usage}"`);
|
|
65
|
+
}
|
|
66
|
+
if (material == null) {
|
|
67
|
+
throw new Error('ecies-aes-256-gcm: key material is required');
|
|
68
|
+
}
|
|
69
|
+
const wantPrivate = usage === 'decrypt';
|
|
70
|
+
const subtle = globalThis.crypto.subtle;
|
|
71
|
+
|
|
72
|
+
// Pair object: pick the side the operation needs.
|
|
73
|
+
if (isPair(material)) {
|
|
74
|
+
const side = wantPrivate ? material.privateKey : material.publicKey;
|
|
75
|
+
if (side == null) {
|
|
76
|
+
throw new Error(`ecies-aes-256-gcm: pair object has no ${wantPrivate ? 'privateKey' : 'publicKey'}`);
|
|
77
|
+
}
|
|
78
|
+
return normalizeKey(side, usage);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Already a CryptoKey — trust it.
|
|
82
|
+
if (typeof CryptoKey !== 'undefined' && material instanceof CryptoKey) {
|
|
83
|
+
return material;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// JWK object.
|
|
87
|
+
if (typeof material === 'object' && !(material instanceof Uint8Array) &&
|
|
88
|
+
!(material instanceof ArrayBuffer) && material.kty != null) {
|
|
89
|
+
const isPrivateJwk = material.d != null;
|
|
90
|
+
if (wantPrivate && !isPrivateJwk) {
|
|
91
|
+
throw new Error('ecies-aes-256-gcm: decrypt requires a private key (JWK with "d")');
|
|
92
|
+
}
|
|
93
|
+
return subtle.importKey('jwk', material, ALGORITHM, false, wantPrivate ? ['deriveBits'] : []);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Raw bytes: PKCS#8 for private, SEC1 raw (65 bytes) for public.
|
|
97
|
+
let bytes;
|
|
98
|
+
if (material instanceof Uint8Array) {
|
|
99
|
+
bytes = material;
|
|
100
|
+
} else if (material instanceof ArrayBuffer) {
|
|
101
|
+
bytes = new Uint8Array(material);
|
|
102
|
+
} else if (typeof material === 'string') {
|
|
103
|
+
bytes = base64ToBytes(material);
|
|
104
|
+
} else {
|
|
105
|
+
throw new Error('ecies-aes-256-gcm: unsupported key material');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (wantPrivate) {
|
|
109
|
+
return subtle.importKey('pkcs8', bytes, ALGORITHM, false, ['deriveBits']);
|
|
110
|
+
}
|
|
111
|
+
if (bytes.length !== EPH_PUB_LENGTH || bytes[0] !== 0x04) {
|
|
112
|
+
throw new Error('ecies-aes-256-gcm: public key must be a 65-byte SEC1 uncompressed point (0x04 || X || Y)');
|
|
113
|
+
}
|
|
114
|
+
return subtle.importKey('raw', bytes, ALGORITHM, false, []);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derive the AES-256-GCM key from an ECDH shared secret via HKDF-SHA-256.
|
|
119
|
+
* @param {Uint8Array} secret - the 32-byte ECDH shared secret.
|
|
120
|
+
* @param {string[]} usages
|
|
121
|
+
* @returns {Promise<CryptoKey>}
|
|
122
|
+
*/
|
|
123
|
+
async function deriveAesKey (secret, usages) {
|
|
124
|
+
const subtle = globalThis.crypto.subtle;
|
|
125
|
+
const hkdfKey = await subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']);
|
|
126
|
+
const aesBits = await subtle.deriveBits(
|
|
127
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(INFO) },
|
|
128
|
+
hkdfKey,
|
|
129
|
+
256
|
|
130
|
+
);
|
|
131
|
+
return subtle.importKey('raw', new Uint8Array(aesBits), 'AES-GCM', false, usages);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Encrypt raw bytes to the recipient's public key, producing the method's
|
|
136
|
+
* payload byte layout:
|
|
137
|
+
* ephemeralPublicKey[65] || iv[12] || ciphertext || gcm-tag[16]
|
|
138
|
+
* These are exactly the bytes that Base64-decoding a `content.payload` yields.
|
|
139
|
+
* The input is not mutated.
|
|
140
|
+
* @param {Uint8Array} bytes - plaintext bytes to encrypt.
|
|
141
|
+
* @param {*} key - recipient public key in any accepted shape (or a pair object).
|
|
142
|
+
* @returns {Promise<Uint8Array>}
|
|
143
|
+
*/
|
|
144
|
+
async function encryptBytes (bytes, key) {
|
|
145
|
+
const subtle = globalThis.crypto.subtle;
|
|
146
|
+
const recipientPublic = await normalizeKey(key, 'encrypt');
|
|
147
|
+
|
|
148
|
+
const ephemeral = await subtle.generateKey(ALGORITHM, true, ['deriveBits']);
|
|
149
|
+
const secretBits = await subtle.deriveBits({ name: 'ECDH', public: recipientPublic }, ephemeral.privateKey, 256);
|
|
150
|
+
const aesKey = await deriveAesKey(new Uint8Array(secretBits), ['encrypt']);
|
|
151
|
+
|
|
152
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
153
|
+
const cipherBuffer = await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, bytes);
|
|
154
|
+
const cipherBytes = new Uint8Array(cipherBuffer);
|
|
155
|
+
|
|
156
|
+
const ephRaw = new Uint8Array(await subtle.exportKey('raw', ephemeral.publicKey));
|
|
157
|
+
|
|
158
|
+
const out = new Uint8Array(ephRaw.length + iv.length + cipherBytes.length);
|
|
159
|
+
out.set(ephRaw, 0);
|
|
160
|
+
out.set(iv, ephRaw.length);
|
|
161
|
+
out.set(cipherBytes, ephRaw.length + iv.length);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Decrypt the method's payload byte layout back into raw plaintext bytes with
|
|
167
|
+
* the recipient's private key. Throws on any failure (short input, bad key,
|
|
168
|
+
* tampered tag).
|
|
169
|
+
* @param {Uint8Array} bytes - `ephemeralPublicKey[65] || iv[12] || ciphertext || gcm-tag[16]`.
|
|
170
|
+
* @param {*} key - recipient private key in any accepted shape (or a pair object).
|
|
171
|
+
* @returns {Promise<Uint8Array>}
|
|
172
|
+
*/
|
|
173
|
+
async function decryptBytes (bytes, key) {
|
|
174
|
+
const subtle = globalThis.crypto.subtle;
|
|
175
|
+
if (bytes.length < EPH_PUB_LENGTH + IV_LENGTH + GCM_TAG_LENGTH) {
|
|
176
|
+
throw new Error('ecies-aes-256-gcm: payload too short');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ephRaw = bytes.slice(0, EPH_PUB_LENGTH);
|
|
180
|
+
const iv = bytes.slice(EPH_PUB_LENGTH, EPH_PUB_LENGTH + IV_LENGTH);
|
|
181
|
+
const cipherBytes = bytes.slice(EPH_PUB_LENGTH + IV_LENGTH);
|
|
182
|
+
|
|
183
|
+
const recipientPrivate = await normalizeKey(key, 'decrypt');
|
|
184
|
+
const ephemeralPublic = await subtle.importKey('raw', ephRaw, ALGORITHM, false, []);
|
|
185
|
+
const secretBits = await subtle.deriveBits({ name: 'ECDH', public: ephemeralPublic }, recipientPrivate, 256);
|
|
186
|
+
const aesKey = await deriveAesKey(new Uint8Array(secretBits), ['decrypt']);
|
|
187
|
+
|
|
188
|
+
const plainBuffer = await subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, cipherBytes);
|
|
189
|
+
return new Uint8Array(plainBuffer);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Encrypt a material object to the recipient's public key. Thin wrapper over
|
|
194
|
+
* {@link encryptBytes}: `payload = base64(encryptBytes(utf8(JSON.stringify(material))))`.
|
|
195
|
+
* @param {Object} material - the object to serialise + encrypt.
|
|
196
|
+
* @param {*} key - recipient public key in any accepted shape (or a pair object).
|
|
197
|
+
* @returns {Promise<{ payload: string }>}
|
|
198
|
+
*/
|
|
199
|
+
async function encrypt (material, key) {
|
|
200
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(material));
|
|
201
|
+
const out = await encryptBytes(plaintext, key);
|
|
202
|
+
return { payload: bytesToBase64(out) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Decrypt an event `content` with the recipient's private key. Thin wrapper over
|
|
207
|
+
* {@link decryptBytes}. Throws on any failure (missing/short payload, bad key,
|
|
208
|
+
* tampered tag, non-JSON).
|
|
209
|
+
* @param {{ payload: string }} content
|
|
210
|
+
* @param {*} key - recipient private key in any accepted shape (or a pair object).
|
|
211
|
+
* @returns {Promise<Object>}
|
|
212
|
+
*/
|
|
213
|
+
async function decrypt (content, key) {
|
|
214
|
+
if (content == null || typeof content.payload !== 'string') {
|
|
215
|
+
throw new Error('ecies-aes-256-gcm: content.payload must be a base64 string');
|
|
216
|
+
}
|
|
217
|
+
const plainBytes = await decryptBytes(base64ToBytes(content.payload), key);
|
|
218
|
+
return JSON.parse(new TextDecoder().decode(plainBytes));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {*} m
|
|
223
|
+
* @returns {boolean} true when `m` is a `{ publicKey, privateKey }` pair object (not a JWK).
|
|
224
|
+
*/
|
|
225
|
+
function isPair (m) {
|
|
226
|
+
return m != null && typeof m === 'object' && m.kty == null &&
|
|
227
|
+
!(m instanceof Uint8Array) && !(m instanceof ArrayBuffer) &&
|
|
228
|
+
(typeof CryptoKey === 'undefined' || !(m instanceof CryptoKey)) &&
|
|
229
|
+
(m.publicKey != null || m.privateKey != null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { encrypt, decrypt, encryptBytes, decryptBytes, generateKeyPair, normalizeKey };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Registry of built-in encryption methods, keyed by their method name (the
|
|
7
|
+
* suffix of an `encrypted/<method>` event type).
|
|
8
|
+
*/
|
|
9
|
+
const aes256gcm = require('./aes-256-gcm');
|
|
10
|
+
const aesTextBase64 = require('./aes-text-base64');
|
|
11
|
+
const eciesAes256gcm = require('./ecies-aes-256-gcm');
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
'aes-256-gcm': aes256gcm,
|
|
15
|
+
'aes-text-base64': aesTextBase64,
|
|
16
|
+
'ecies-aes-256-gcm': eciesAes256gcm
|
|
17
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, expect */
|
|
6
|
+
|
|
7
|
+
const aes = require('../src/methods/aes-256-gcm');
|
|
8
|
+
const { base64ToBytes, bytesToBase64 } = require('../src/lib/base64');
|
|
9
|
+
const vector = require('./fixtures/aes-256-gcm.json');
|
|
10
|
+
|
|
11
|
+
describe('[ENCA] aes-256-gcm method', function () {
|
|
12
|
+
describe('[ENCAV] fixed test vector', function () {
|
|
13
|
+
it('[ENCAVA] payload byte-layout matches iv || subtle-ciphertext (deterministic vector)', async function () {
|
|
14
|
+
// Rebuild the payload independently, with the SAME fixed key + IV, using raw
|
|
15
|
+
// WebCrypto — proves the committed vector is byte-exact and format-stable.
|
|
16
|
+
const keyBytes = base64ToBytes(vector.keyBase64);
|
|
17
|
+
const iv = base64ToBytes(vector.ivBase64);
|
|
18
|
+
const key = await globalThis.crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
19
|
+
const pt = new TextEncoder().encode(vector.plaintextJson);
|
|
20
|
+
const cipherBuf = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, pt);
|
|
21
|
+
const cipherBytes = new Uint8Array(cipherBuf);
|
|
22
|
+
const out = new Uint8Array(iv.length + cipherBytes.length);
|
|
23
|
+
out.set(iv, 0);
|
|
24
|
+
out.set(cipherBytes, iv.length);
|
|
25
|
+
expect(bytesToBase64(out)).to.equal(vector.payload);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('[ENCAVB] decrypt() of the committed vector yields the exact plaintext object', async function () {
|
|
29
|
+
const material = await aes.decrypt({ payload: vector.payload }, vector.keyBase64);
|
|
30
|
+
expect(material).to.deep.equal(vector.plaintextObject);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('[ENCAR] round-trips (random IV)', function () {
|
|
35
|
+
const rawKey = base64ToBytes(vector.keyBase64);
|
|
36
|
+
|
|
37
|
+
it('[ENCARA] encrypt then decrypt returns the original material (Uint8Array key)', async function () {
|
|
38
|
+
const material = { type: 'note/txt', content: 'round trip', extra: [1, 2, 3] };
|
|
39
|
+
const content = await aes.encrypt(material, rawKey);
|
|
40
|
+
expect(content).to.have.property('payload').that.is.a('string');
|
|
41
|
+
const back = await aes.decrypt(content, rawKey);
|
|
42
|
+
expect(back).to.deep.equal(material);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('[ENCARB] two encryptions of the same material produce different payloads (random IV)', async function () {
|
|
46
|
+
const material = { type: 'note/txt', content: 'nonce check' };
|
|
47
|
+
const a = await aes.encrypt(material, rawKey);
|
|
48
|
+
const b = await aes.encrypt(material, rawKey);
|
|
49
|
+
expect(a.payload).to.not.equal(b.payload);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('[ENCARC] accepts a base64 string key', async function () {
|
|
53
|
+
const material = { type: 'note/txt', content: 'string key' };
|
|
54
|
+
const content = await aes.encrypt(material, vector.keyBase64);
|
|
55
|
+
const back = await aes.decrypt(content, vector.keyBase64);
|
|
56
|
+
expect(back).to.deep.equal(material);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('[ENCARD] accepts a CryptoKey', async function () {
|
|
60
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
61
|
+
const material = { type: 'note/txt', content: 'crypto key' };
|
|
62
|
+
const content = await aes.encrypt(material, cryptoKey);
|
|
63
|
+
const back = await aes.decrypt(content, cryptoKey);
|
|
64
|
+
expect(back).to.deep.equal(material);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('[ENCAN] negative cases (must reject)', function () {
|
|
69
|
+
const rawKey = base64ToBytes(vector.keyBase64);
|
|
70
|
+
|
|
71
|
+
it('[ENCANA] wrong key fails authentication', async function () {
|
|
72
|
+
const wrong = new Uint8Array(32).fill(9);
|
|
73
|
+
let threw = false;
|
|
74
|
+
try {
|
|
75
|
+
await aes.decrypt({ payload: vector.payload }, wrong);
|
|
76
|
+
} catch (e) { threw = true; }
|
|
77
|
+
expect(threw).to.equal(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('[ENCANB] truncated payload rejects', async function () {
|
|
81
|
+
const truncated = vector.payload.slice(0, 20);
|
|
82
|
+
let threw = false;
|
|
83
|
+
try {
|
|
84
|
+
await aes.decrypt({ payload: truncated }, rawKey);
|
|
85
|
+
} catch (e) { threw = true; }
|
|
86
|
+
expect(threw).to.equal(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('[ENCANC] corrupted auth tag rejects', async function () {
|
|
90
|
+
const bytes = base64ToBytes(vector.payload);
|
|
91
|
+
bytes[bytes.length - 1] ^= 0xff; // flip the last tag byte
|
|
92
|
+
let threw = false;
|
|
93
|
+
try {
|
|
94
|
+
await aes.decrypt({ payload: bytesToBase64(bytes) }, rawKey);
|
|
95
|
+
} catch (e) { threw = true; }
|
|
96
|
+
expect(threw).to.equal(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('[ENCAND] key of wrong length rejects', async function () {
|
|
100
|
+
const short = new Uint8Array(16).fill(1);
|
|
101
|
+
let threw = false;
|
|
102
|
+
try {
|
|
103
|
+
await aes.encrypt({ type: 'a', content: 'b' }, short);
|
|
104
|
+
} catch (e) { threw = true; }
|
|
105
|
+
expect(threw).to.equal(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|