Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(encryption): add backwards-compatibility-test for encrypt/decrypt #367

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"files": [ "files": [
"dist", "dist",
"!__snapshots__", "!__snapshots__",
"!**/test-legacy-*.d.ts",
"!**/test-legacy-*.js",
"!**/test-legacy-*.js.map",
"!**/*.test.js", "!**/*.test.js",
"!**/*.test.js.map", "!**/*.test.js.map",
"!**/*.test.ts", "!**/*.test.ts",
Expand Down Expand Up @@ -75,6 +78,7 @@
"prettier-plugin-packagejson": "^2.2.11", "prettier-plugin-packagejson": "^2.2.11",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-jest": "^27.0.3", "ts-jest": "^27.0.3",
"tweetnacl-util": "^0.15.1",
"typedoc": "^0.24.6", "typedoc": "^0.24.6",
"typescript": "~4.8.4" "typescript": "~4.8.4"
}, },
Expand Down
43 changes: 42 additions & 1 deletion src/encryption.test.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ import {
encryptSafely, encryptSafely,
getEncryptionPublicKey, getEncryptionPublicKey,
} from './encryption'; } 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,
}) => {
/* eslint-enable @typescript-eslint/no-shadow */
describe('encryption', function () { describe('encryption', function () {
const bob = { const bob = {
ethereumPrivateKey: ethereumPrivateKey:
Expand Down Expand Up @@ -351,3 +367,28 @@ describe('encryption', function () {
}); });
}); });
}); });
};

run({
decrypt,
decryptSafely,
encrypt,
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 Original file line 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 Original file line Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ __metadata:
rimraf: ^3.0.2 rimraf: ^3.0.2
ts-jest: ^27.0.3 ts-jest: ^27.0.3
tweetnacl: ^1.0.3 tweetnacl: ^1.0.3
tweetnacl-util: ^0.15.1
typedoc: ^0.24.6 typedoc: ^0.24.6
typescript: ~4.8.4 typescript: ~4.8.4
languageName: unknown languageName: unknown
Expand Down Expand Up @@ -5801,6 +5802,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "tweetnacl@npm:^1.0.3":
version: 1.0.3 version: 1.0.3
resolution: "tweetnacl@npm:1.0.3" resolution: "tweetnacl@npm:1.0.3"
Expand Down
Loading