Skip to content

Commit 0d8bbb2

Browse files
committed
test(encryption): add legacy-compatibility test
1 parent 1c4e12c commit 0d8bbb2

File tree

4 files changed

+308
-7
lines changed

4 files changed

+308
-7
lines changed

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
"files": [
2727
"dist",
2828
"!__snapshots__",
29+
"!**/test-legacy-*.d.ts",
30+
"!**/test-legacy-*.js",
31+
"!**/test-legacy-*.js.map",
2932
"!**/*.test.js",
3033
"!**/*.test.js.map",
3134
"!**/*.test.ts",
@@ -75,6 +78,7 @@
7578
"prettier-plugin-packagejson": "^2.2.11",
7679
"rimraf": "^3.0.2",
7780
"ts-jest": "^27.0.3",
81+
"tweetnacl-util": "^0.15.1",
7882
"typedoc": "^0.24.6",
7983
"typescript": "~4.8.4"
8084
},

src/encryption.test.ts

+32-7
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,23 @@ import {
55
encryptSafely,
66
getEncryptionPublicKey,
77
} from './encryption';
8-
8+
import {
9+
decrypt as legacyDecrypt,
10+
decryptSafely as legacyDecryptSafely,
11+
encrypt as legacyEncrypt,
12+
encryptSafely as legacyEncryptSafely,
13+
getEncryptionPublicKey as legacyGetEncryptionPublicKey,
14+
} from './test-legacy-encryption';
15+
16+
/* eslint-disable @typescript-eslint/no-shadow */
917
const run = ({
10-
decrypt,
11-
decryptSafely,
12-
encrypt,
13-
encryptSafely,
14-
getEncryptionPublicKey,
15-
}) => {
18+
decrypt,
19+
decryptSafely,
20+
encrypt,
21+
encryptSafely,
22+
getEncryptionPublicKey,
23+
}) => {
24+
/* eslint-enable @typescript-eslint/no-shadow */
1625
describe('encryption', function () {
1726
const bob = {
1827
ethereumPrivateKey:
@@ -367,3 +376,19 @@ run({
367376
encryptSafely,
368377
getEncryptionPublicKey,
369378
});
379+
380+
run({
381+
decrypt,
382+
decryptSafely,
383+
encrypt: legacyEncrypt,
384+
encryptSafely: legacyEncryptSafely,
385+
getEncryptionPublicKey: legacyGetEncryptionPublicKey,
386+
});
387+
388+
run({
389+
decrypt: legacyDecrypt,
390+
decryptSafely: legacyDecryptSafely,
391+
encrypt,
392+
encryptSafely,
393+
getEncryptionPublicKey,
394+
});

src/test-legacy-encryption.ts

+264
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// This is a copy of encryption.ts from eth-sig-util v7.0.1.
2+
// It is here for the sake of compatibility testing as the library moves from tweetnacl
3+
// Implementation bugs in this file should in general not be addressed (unless backported to a @metamask/eth-sig-util v7.x release)
4+
5+
import * as nacl from 'tweetnacl';
6+
import * as naclUtil from 'tweetnacl-util';
7+
8+
import { isNullish } from './utils';
9+
10+
export type EthEncryptedData = {
11+
version: string;
12+
nonce: string;
13+
ephemPublicKey: string;
14+
ciphertext: string;
15+
};
16+
17+
/**
18+
* Encrypt a message.
19+
*
20+
* @param options - The encryption options.
21+
* @param options.publicKey - The public key of the message recipient.
22+
* @param options.data - The message data.
23+
* @param options.version - The type of encryption to use.
24+
* @returns The encrypted data.
25+
*/
26+
export function encrypt({
27+
publicKey,
28+
data,
29+
version,
30+
}: {
31+
publicKey: string;
32+
data: unknown;
33+
version: string;
34+
}): EthEncryptedData {
35+
if (isNullish(publicKey)) {
36+
throw new Error('Missing publicKey parameter');
37+
} else if (isNullish(data)) {
38+
throw new Error('Missing data parameter');
39+
} else if (isNullish(version)) {
40+
throw new Error('Missing version parameter');
41+
}
42+
43+
switch (version) {
44+
case 'x25519-xsalsa20-poly1305': {
45+
if (typeof data !== 'string') {
46+
throw new Error('Message data must be given as a string');
47+
}
48+
// generate ephemeral keypair
49+
const ephemeralKeyPair = nacl.box.keyPair();
50+
51+
// assemble encryption parameters - from string to UInt8
52+
let pubKeyUInt8Array: Uint8Array;
53+
try {
54+
pubKeyUInt8Array = naclUtil.decodeBase64(publicKey);
55+
} catch (err) {
56+
throw new Error('Bad public key');
57+
}
58+
59+
const msgParamsUInt8Array = naclUtil.decodeUTF8(data);
60+
const nonce = nacl.randomBytes(nacl.box.nonceLength);
61+
62+
// encrypt
63+
const encryptedMessage = nacl.box(
64+
msgParamsUInt8Array,
65+
nonce,
66+
pubKeyUInt8Array,
67+
ephemeralKeyPair.secretKey,
68+
);
69+
70+
// handle encrypted data
71+
const output = {
72+
version: 'x25519-xsalsa20-poly1305',
73+
nonce: naclUtil.encodeBase64(nonce),
74+
ephemPublicKey: naclUtil.encodeBase64(ephemeralKeyPair.publicKey),
75+
ciphertext: naclUtil.encodeBase64(encryptedMessage),
76+
};
77+
// return encrypted msg data
78+
return output;
79+
}
80+
81+
default:
82+
throw new Error('Encryption type/version not supported');
83+
}
84+
}
85+
86+
/**
87+
* Encrypt a message in a way that obscures the message length.
88+
*
89+
* The message is padded to a multiple of 2048 before being encrypted so that the length of the
90+
* resulting encrypted message can't be used to guess the exact length of the original message.
91+
*
92+
* @param options - The encryption options.
93+
* @param options.publicKey - The public key of the message recipient.
94+
* @param options.data - The message data.
95+
* @param options.version - The type of encryption to use.
96+
* @returns The encrypted data.
97+
*/
98+
export function encryptSafely({
99+
publicKey,
100+
data,
101+
version,
102+
}: {
103+
publicKey: string;
104+
data: unknown;
105+
version: string;
106+
}): EthEncryptedData {
107+
if (isNullish(publicKey)) {
108+
throw new Error('Missing publicKey parameter');
109+
} else if (isNullish(data)) {
110+
throw new Error('Missing data parameter');
111+
} else if (isNullish(version)) {
112+
throw new Error('Missing version parameter');
113+
}
114+
115+
const DEFAULT_PADDING_LENGTH = 2 ** 11;
116+
const NACL_EXTRA_BYTES = 16;
117+
118+
if (typeof data === 'object' && data && 'toJSON' in data) {
119+
// remove toJSON attack vector
120+
// TODO, check all possible children
121+
throw new Error(
122+
'Cannot encrypt with toJSON property. Please remove toJSON property',
123+
);
124+
}
125+
126+
// add padding
127+
const dataWithPadding = {
128+
data,
129+
padding: '',
130+
};
131+
132+
// calculate padding
133+
const dataLength = Buffer.byteLength(
134+
JSON.stringify(dataWithPadding),
135+
'utf-8',
136+
);
137+
const modVal = dataLength % DEFAULT_PADDING_LENGTH;
138+
let padLength = 0;
139+
// Only pad if necessary
140+
if (modVal > 0) {
141+
padLength = DEFAULT_PADDING_LENGTH - modVal - NACL_EXTRA_BYTES; // nacl extra bytes
142+
}
143+
dataWithPadding.padding = '0'.repeat(padLength);
144+
145+
const paddedMessage = JSON.stringify(dataWithPadding);
146+
return encrypt({ publicKey, data: paddedMessage, version });
147+
}
148+
149+
/**
150+
* Decrypt a message.
151+
*
152+
* @param options - The decryption options.
153+
* @param options.encryptedData - The encrypted data.
154+
* @param options.privateKey - The private key to decrypt with.
155+
* @returns The decrypted message.
156+
*/
157+
export function decrypt({
158+
encryptedData,
159+
privateKey,
160+
}: {
161+
encryptedData: EthEncryptedData;
162+
privateKey: string;
163+
}): string {
164+
if (isNullish(encryptedData)) {
165+
throw new Error('Missing encryptedData parameter');
166+
} else if (isNullish(privateKey)) {
167+
throw new Error('Missing privateKey parameter');
168+
}
169+
170+
switch (encryptedData.version) {
171+
case 'x25519-xsalsa20-poly1305': {
172+
// string to buffer to UInt8Array
173+
const receiverPrivateKeyUint8Array = naclDecodeHex(privateKey);
174+
const receiverEncryptionPrivateKey = nacl.box.keyPair.fromSecretKey(
175+
receiverPrivateKeyUint8Array,
176+
).secretKey;
177+
178+
// assemble decryption parameters
179+
const nonce = naclUtil.decodeBase64(encryptedData.nonce);
180+
const ciphertext = naclUtil.decodeBase64(encryptedData.ciphertext);
181+
const ephemPublicKey = naclUtil.decodeBase64(
182+
encryptedData.ephemPublicKey,
183+
);
184+
185+
// decrypt
186+
const decryptedMessage = nacl.box.open(
187+
ciphertext,
188+
nonce,
189+
ephemPublicKey,
190+
receiverEncryptionPrivateKey,
191+
);
192+
193+
// return decrypted msg data
194+
try {
195+
if (!decryptedMessage) {
196+
throw new Error();
197+
}
198+
const output = naclUtil.encodeUTF8(decryptedMessage);
199+
// TODO: This is probably extraneous but was kept to minimize changes during refactor
200+
if (!output) {
201+
throw new Error();
202+
}
203+
return output;
204+
} catch (err) {
205+
if (err && typeof err.message === 'string' && err.message.length) {
206+
throw new Error(`Decryption failed: ${err.message as string}`);
207+
}
208+
throw new Error(`Decryption failed.`);
209+
}
210+
}
211+
212+
default:
213+
throw new Error('Encryption type/version not supported.');
214+
}
215+
}
216+
217+
/**
218+
* Decrypt a message that has been encrypted using `encryptSafely`.
219+
*
220+
* @param options - The decryption options.
221+
* @param options.encryptedData - The encrypted data.
222+
* @param options.privateKey - The private key to decrypt with.
223+
* @returns The decrypted message.
224+
*/
225+
export function decryptSafely({
226+
encryptedData,
227+
privateKey,
228+
}: {
229+
encryptedData: EthEncryptedData;
230+
privateKey: string;
231+
}): string {
232+
if (isNullish(encryptedData)) {
233+
throw new Error('Missing encryptedData parameter');
234+
} else if (isNullish(privateKey)) {
235+
throw new Error('Missing privateKey parameter');
236+
}
237+
238+
const dataWithPadding = JSON.parse(decrypt({ encryptedData, privateKey }));
239+
return dataWithPadding.data;
240+
}
241+
242+
/**
243+
* Get the encryption public key for the given key.
244+
*
245+
* @param privateKey - The private key to generate the encryption public key with.
246+
* @returns The encryption public key.
247+
*/
248+
export function getEncryptionPublicKey(privateKey: string): string {
249+
const privateKeyUint8Array = naclDecodeHex(privateKey);
250+
const encryptionPublicKey =
251+
nacl.box.keyPair.fromSecretKey(privateKeyUint8Array).publicKey;
252+
return naclUtil.encodeBase64(encryptionPublicKey);
253+
}
254+
255+
/**
256+
* Convert a hex string to the UInt8Array format used by nacl.
257+
*
258+
* @param msgHex - The string to convert.
259+
* @returns The converted string.
260+
*/
261+
function naclDecodeHex(msgHex: string): Uint8Array {
262+
const msgBase64 = Buffer.from(msgHex, 'hex').toString('base64');
263+
return naclUtil.decodeBase64(msgBase64);
264+
}

yarn.lock

+8
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,7 @@ __metadata:
923923
rimraf: ^3.0.2
924924
ts-jest: ^27.0.3
925925
tweetnacl: ^1.0.3
926+
tweetnacl-util: ^0.15.1
926927
typedoc: ^0.24.6
927928
typescript: ~4.8.4
928929
languageName: unknown
@@ -5801,6 +5802,13 @@ __metadata:
58015802
languageName: node
58025803
linkType: hard
58035804

5805+
"tweetnacl-util@npm:^0.15.1":
5806+
version: 0.15.1
5807+
resolution: "tweetnacl-util@npm:0.15.1"
5808+
checksum: ae6aa8a52cdd21a95103a4cc10657d6a2040b36c7a6da7b9d3ab811c6750a2d5db77e8c36969e75fdee11f511aa2b91c552496c6e8e989b6e490e54aca2864fc
5809+
languageName: node
5810+
linkType: hard
5811+
58045812
"tweetnacl@npm:^1.0.3":
58055813
version: 1.0.3
58065814
resolution: "tweetnacl@npm:1.0.3"

0 commit comments

Comments
 (0)