Skip to content

Commit

Permalink
test(encryption): add legacy-compatibility test
Browse files Browse the repository at this point in the history
  • Loading branch information
legobeat committed May 1, 2024
1 parent f2bd22d commit 7c59ecb
Show file tree
Hide file tree
Showing 4 changed files with 308 additions and 7 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"files": [
"dist",
"!__snapshots__",
"!**/test-legacy-*.d.ts",
"!**/test-legacy-*.js",
"!**/test-legacy-*.js.map",
"!**/*.test.js",
"!**/*.test.js.map",
"!**/*.test.ts",
Expand Down Expand Up @@ -75,6 +78,7 @@
"prettier-plugin-packagejson": "^2.2.11",
"rimraf": "^3.0.2",
"ts-jest": "^27.0.3",
"tweetnacl-util": "^0.15.1",
"typedoc": "^0.24.6",
"typescript": "~4.8.4"
},
Expand Down
39 changes: 32 additions & 7 deletions src/encryption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@ import {
encryptSafely,
getEncryptionPublicKey,
} from './encryption';

import {
decrypt as legacyDecrypt,
decryptSafely as legacyDecryptSafely,
encrypt as legacyEncrypt,
encryptSafely as legacyEncryptSafely,
getEncryptionPublicKey as legacyGetEncryptionPublicKey,
} from './test-legacy-encryption';

/* eslint-disable @typescript-eslint/no-shadow */
const run = ({
decrypt,
decryptSafely,
encrypt,
encryptSafely,
getEncryptionPublicKey,
}) => {
decrypt,
decryptSafely,
encrypt,
encryptSafely,
getEncryptionPublicKey,
}) => {
/* eslint-enable @typescript-eslint/no-shadow */
describe('encryption', function () {
const bob = {
ethereumPrivateKey:
Expand Down Expand Up @@ -367,3 +376,19 @@ run({
encryptSafely,
getEncryptionPublicKey,
});

run({
decrypt,
decryptSafely,
encrypt: legacyEncrypt,
encryptSafely: legacyEncryptSafely,
getEncryptionPublicKey: legacyGetEncryptionPublicKey,
});

run({
decrypt: legacyDecrypt,
decryptSafely: legacyDecryptSafely,
encrypt,
encryptSafely,
getEncryptionPublicKey,
});
264 changes: 264 additions & 0 deletions src/test-legacy-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// This is a copy of encryption.ts from eth-sig-util v7.0.1.
// It is here for the sake of compatibility testing as the library moves from tweetnacl
// Implementation bugs in this file should in general not be addressed (unless backported to a @metamask/eth-sig-util v7.x release)

import * as nacl from 'tweetnacl';
import * as naclUtil from 'tweetnacl-util';

import { isNullish } from './utils';

export type EthEncryptedData = {
version: string;
nonce: string;
ephemPublicKey: string;
ciphertext: string;
};

/**
* Encrypt a message.
*
* @param options - The encryption options.
* @param options.publicKey - The public key of the message recipient.
* @param options.data - The message data.
* @param options.version - The type of encryption to use.
* @returns The encrypted data.
*/
export function encrypt({
publicKey,
data,
version,
}: {
publicKey: string;
data: unknown;
version: string;
}): EthEncryptedData {
if (isNullish(publicKey)) {
throw new Error('Missing publicKey parameter');
} else if (isNullish(data)) {
throw new Error('Missing data parameter');
} else if (isNullish(version)) {
throw new Error('Missing version parameter');
}

switch (version) {
case 'x25519-xsalsa20-poly1305': {
if (typeof data !== 'string') {
throw new Error('Message data must be given as a string');
}
// generate ephemeral keypair
const ephemeralKeyPair = nacl.box.keyPair();

// assemble encryption parameters - from string to UInt8
let pubKeyUInt8Array: Uint8Array;
try {
pubKeyUInt8Array = naclUtil.decodeBase64(publicKey);
} catch (err) {
throw new Error('Bad public key');
}

const msgParamsUInt8Array = naclUtil.decodeUTF8(data);
const nonce = nacl.randomBytes(nacl.box.nonceLength);

// encrypt
const encryptedMessage = nacl.box(
msgParamsUInt8Array,
nonce,
pubKeyUInt8Array,
ephemeralKeyPair.secretKey,
);

// handle encrypted data
const output = {
version: 'x25519-xsalsa20-poly1305',
nonce: naclUtil.encodeBase64(nonce),
ephemPublicKey: naclUtil.encodeBase64(ephemeralKeyPair.publicKey),
ciphertext: naclUtil.encodeBase64(encryptedMessage),
};
// return encrypted msg data
return output;
}

default:
throw new Error('Encryption type/version not supported');
}
}

/**
* Encrypt a message in a way that obscures the message length.
*
* The message is padded to a multiple of 2048 before being encrypted so that the length of the
* resulting encrypted message can't be used to guess the exact length of the original message.
*
* @param options - The encryption options.
* @param options.publicKey - The public key of the message recipient.
* @param options.data - The message data.
* @param options.version - The type of encryption to use.
* @returns The encrypted data.
*/
export function encryptSafely({
publicKey,
data,
version,
}: {
publicKey: string;
data: unknown;
version: string;
}): EthEncryptedData {
if (isNullish(publicKey)) {
throw new Error('Missing publicKey parameter');
} else if (isNullish(data)) {
throw new Error('Missing data parameter');
} else if (isNullish(version)) {
throw new Error('Missing version parameter');
}

const DEFAULT_PADDING_LENGTH = 2 ** 11;
const NACL_EXTRA_BYTES = 16;

if (typeof data === 'object' && data && 'toJSON' in data) {
// remove toJSON attack vector
// TODO, check all possible children
throw new Error(
'Cannot encrypt with toJSON property. Please remove toJSON property',
);
}

// add padding
const dataWithPadding = {
data,
padding: '',
};

// calculate padding
const dataLength = Buffer.byteLength(
JSON.stringify(dataWithPadding),
'utf-8',
);
const modVal = dataLength % DEFAULT_PADDING_LENGTH;
let padLength = 0;
// Only pad if necessary
if (modVal > 0) {
padLength = DEFAULT_PADDING_LENGTH - modVal - NACL_EXTRA_BYTES; // nacl extra bytes
}
dataWithPadding.padding = '0'.repeat(padLength);

const paddedMessage = JSON.stringify(dataWithPadding);
return encrypt({ publicKey, data: paddedMessage, version });
}

/**
* Decrypt a message.
*
* @param options - The decryption options.
* @param options.encryptedData - The encrypted data.
* @param options.privateKey - The private key to decrypt with.
* @returns The decrypted message.
*/
export function decrypt({
encryptedData,
privateKey,
}: {
encryptedData: EthEncryptedData;
privateKey: string;
}): string {
if (isNullish(encryptedData)) {
throw new Error('Missing encryptedData parameter');
} else if (isNullish(privateKey)) {
throw new Error('Missing privateKey parameter');
}

switch (encryptedData.version) {
case 'x25519-xsalsa20-poly1305': {
// string to buffer to UInt8Array
const receiverPrivateKeyUint8Array = naclDecodeHex(privateKey);
const receiverEncryptionPrivateKey = nacl.box.keyPair.fromSecretKey(
receiverPrivateKeyUint8Array,
).secretKey;

// assemble decryption parameters
const nonce = naclUtil.decodeBase64(encryptedData.nonce);
const ciphertext = naclUtil.decodeBase64(encryptedData.ciphertext);
const ephemPublicKey = naclUtil.decodeBase64(
encryptedData.ephemPublicKey,
);

// decrypt
const decryptedMessage = nacl.box.open(
ciphertext,
nonce,
ephemPublicKey,
receiverEncryptionPrivateKey,
);

// return decrypted msg data
try {
if (!decryptedMessage) {
throw new Error();
}
const output = naclUtil.encodeUTF8(decryptedMessage);
// TODO: This is probably extraneous but was kept to minimize changes during refactor
if (!output) {
throw new Error();
}
return output;
} catch (err) {
if (err && typeof err.message === 'string' && err.message.length) {
throw new Error(`Decryption failed: ${err.message as string}`);
}
throw new Error(`Decryption failed.`);
}
}

default:
throw new Error('Encryption type/version not supported.');
}
}

/**
* Decrypt a message that has been encrypted using `encryptSafely`.
*
* @param options - The decryption options.
* @param options.encryptedData - The encrypted data.
* @param options.privateKey - The private key to decrypt with.
* @returns The decrypted message.
*/
export function decryptSafely({
encryptedData,
privateKey,
}: {
encryptedData: EthEncryptedData;
privateKey: string;
}): string {
if (isNullish(encryptedData)) {
throw new Error('Missing encryptedData parameter');
} else if (isNullish(privateKey)) {
throw new Error('Missing privateKey parameter');
}

const dataWithPadding = JSON.parse(decrypt({ encryptedData, privateKey }));
return dataWithPadding.data;
}

/**
* Get the encryption public key for the given key.
*
* @param privateKey - The private key to generate the encryption public key with.
* @returns The encryption public key.
*/
export function getEncryptionPublicKey(privateKey: string): string {
const privateKeyUint8Array = naclDecodeHex(privateKey);
const encryptionPublicKey =
nacl.box.keyPair.fromSecretKey(privateKeyUint8Array).publicKey;
return naclUtil.encodeBase64(encryptionPublicKey);
}

/**
* Convert a hex string to the UInt8Array format used by nacl.
*
* @param msgHex - The string to convert.
* @returns The converted string.
*/
function naclDecodeHex(msgHex: string): Uint8Array {
const msgBase64 = Buffer.from(msgHex, 'hex').toString('base64');
return naclUtil.decodeBase64(msgBase64);
}
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ __metadata:
rimraf: ^3.0.2
ts-jest: ^27.0.3
tweetnacl: ^1.0.3
tweetnacl-util: ^0.15.1
typedoc: ^0.24.6
typescript: ~4.8.4
languageName: unknown
Expand Down Expand Up @@ -5801,6 +5802,13 @@ __metadata:
languageName: node
linkType: hard

"tweetnacl-util@npm:^0.15.1":
version: 0.15.1
resolution: "tweetnacl-util@npm:0.15.1"
checksum: ae6aa8a52cdd21a95103a4cc10657d6a2040b36c7a6da7b9d3ab811c6750a2d5db77e8c36969e75fdee11f511aa2b91c552496c6e8e989b6e490e54aca2864fc
languageName: node
linkType: hard

"tweetnacl@npm:^1.0.3":
version: 1.0.3
resolution: "tweetnacl@npm:1.0.3"
Expand Down

0 comments on commit 7c59ecb

Please sign in to comment.