diff --git a/package.json b/package.json index 7d0e3e1b..078e1bd8 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" }, diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 779bc2e9..94f87542 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -5,349 +5,390 @@ import { encryptSafely, getEncryptionPublicKey, } from './encryption'; - -describe('encryption', function () { - const bob = { - ethereumPrivateKey: - '7e5374ec2ef0d91761a6e72fdf8f6ac665519bfdf6da0a2329cf0d804514b816', - encryptionPrivateKey: 'flN07C7w2Rdhpucv349qxmVRm/322gojKc8NgEUUuBY=', - encryptionPublicKey: 'C5YMNdqE4kLgxQhJO1MfuQcHP5hjVSXzamzd/TxlR0U=', - }; - - const secretMessage = 'My name is Satoshi Buterin'; - - const encryptedData = { - version: 'x25519-xsalsa20-poly1305', - nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', - ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', - ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', - }; - - it("getting bob's encryptionPublicKey", async function () { - const result = getEncryptionPublicKey(bob.ethereumPrivateKey); - expect(result).toBe(bob.encryptionPublicKey); - }); - - // encryption test - it("alice encrypts message with bob's encryptionPublicKey", async function () { - const result = encrypt({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version: 'x25519-xsalsa20-poly1305', - }); - - expect(result.ciphertext).toHaveLength(56); - expect(result.ephemPublicKey).toHaveLength(44); - expect(result.nonce).toHaveLength(32); - expect(result.version).toBe('x25519-xsalsa20-poly1305'); - }); - - // safe encryption test - it("alice encryptsSafely message with bob's encryptionPublicKey", async function () { - const version = 'x25519-xsalsa20-poly1305'; - const result = encryptSafely({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version, - }); - - expect(result.ciphertext).toHaveLength(2732); - expect(result.ephemPublicKey).toHaveLength(44); - expect(result.nonce).toHaveLength(32); - expect(result.version).toBe('x25519-xsalsa20-poly1305'); - }); - - // safe decryption test - it('bob decryptSafely message that Alice encryptSafely for him', async function () { - const version = 'x25519-xsalsa20-poly1305'; - const result = encryptSafely({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version, - }); - - const plaintext = decryptSafely({ - encryptedData: result, - privateKey: bob.ethereumPrivateKey, - }); - expect(plaintext).toBe(secretMessage); - }); - - // decryption test - it('bob decrypts message that Alice sent to him', function () { - const result = decrypt({ - encryptedData, - privateKey: bob.ethereumPrivateKey, - }); - expect(result).toBe(secretMessage); - }); - - it('decryption failed because version is wrong or missing', function () { - const badVersionData = { - version: 'x256k1-aes256cbc', - nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', - ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', - ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', - }; - - expect(() => - decrypt({ - encryptedData: badVersionData, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Encryption type/version not supported.'); - }); - - it('decryption failed because nonce is wrong or missing', function () { - // encrypted data - const badNonceData = { - version: 'x25519-xsalsa20-poly1305', - nonce: '', - ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', - ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', - }; - - expect(() => - decrypt({ - encryptedData: badNonceData, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('bad nonce size'); - }); - - it('decryption failed because ephemPublicKey is wrong or missing', function () { - // encrypted data - const badEphemData = { - version: 'x25519-xsalsa20-poly1305', - nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', - ephemPublicKey: 'FFFF/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', - ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', +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 () { + const bob = { + ethereumPrivateKey: + '7e5374ec2ef0d91761a6e72fdf8f6ac665519bfdf6da0a2329cf0d804514b816', + encryptionPrivateKey: 'flN07C7w2Rdhpucv349qxmVRm/322gojKc8NgEUUuBY=', + encryptionPublicKey: 'C5YMNdqE4kLgxQhJO1MfuQcHP5hjVSXzamzd/TxlR0U=', }; - expect(() => - decrypt({ - encryptedData: badEphemData, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Decryption failed.'); - }); + const secretMessage = 'My name is Satoshi Buterin'; - it('decryption failed because cyphertext is wrong or missing', function () { - // encrypted data - const badEphemData = { + const encryptedData = { version: 'x25519-xsalsa20-poly1305', nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', - ciphertext: 'ffffff/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', + ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', }; - expect(() => - decrypt({ - encryptedData: badEphemData, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Decryption failed.'); - }); + it("getting bob's encryptionPublicKey", async function () { + const result = getEncryptionPublicKey(bob.ethereumPrivateKey); + expect(result).toBe(bob.encryptionPublicKey); + }); - describe('validation', function () { - describe('encrypt', function () { - it('should throw if passed null public key', function () { - expect(() => - encrypt({ - publicKey: null as any, - data: secretMessage, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing publicKey parameter'); + // encryption test + it("alice encrypts message with bob's encryptionPublicKey", async function () { + const result = encrypt({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version: 'x25519-xsalsa20-poly1305', }); - it('should throw if passed undefined public key', function () { - expect(() => - encrypt({ - publicKey: undefined as any, - data: secretMessage, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing publicKey parameter'); - }); + expect(result.ciphertext).toHaveLength(56); + expect(result.ephemPublicKey).toHaveLength(44); + expect(result.nonce).toHaveLength(32); + expect(result.version).toBe('x25519-xsalsa20-poly1305'); + }); - it('should throw if passed null data', function () { - expect(() => - encrypt({ - publicKey: bob.encryptionPublicKey, - data: null, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing data parameter'); + // safe encryption test + it("alice encryptsSafely message with bob's encryptionPublicKey", async function () { + const version = 'x25519-xsalsa20-poly1305'; + const result = encryptSafely({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version, }); - it('should throw if passed undefined data', function () { - expect(() => - encrypt({ - publicKey: bob.encryptionPublicKey, - data: undefined, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing data parameter'); - }); + expect(result.ciphertext).toHaveLength(2732); + expect(result.ephemPublicKey).toHaveLength(44); + expect(result.nonce).toHaveLength(32); + expect(result.version).toBe('x25519-xsalsa20-poly1305'); + }); - it('should throw if passed null version', function () { - expect(() => - encrypt({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version: null as any, - }), - ).toThrow('Missing version parameter'); + // safe decryption test + it('bob decryptSafely message that Alice encryptSafely for him', async function () { + const version = 'x25519-xsalsa20-poly1305'; + const result = encryptSafely({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version, }); - it('should throw if passed undefined version', function () { - expect(() => - encrypt({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version: undefined as any, - }), - ).toThrow('Missing version parameter'); + const plaintext = decryptSafely({ + encryptedData: result, + privateKey: bob.ethereumPrivateKey, }); + expect(plaintext).toBe(secretMessage); }); - describe('encryptSafely', function () { - it('should throw if passed null public key', function () { - expect(() => - encryptSafely({ - publicKey: null as any, - data: secretMessage, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing publicKey parameter'); - }); - - it('should throw if passed undefined public key', function () { - expect(() => - encryptSafely({ - publicKey: undefined as any, - data: secretMessage, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing publicKey parameter'); + // decryption test + it('bob decrypts message that Alice sent to him', function () { + const result = decrypt({ + encryptedData, + privateKey: bob.ethereumPrivateKey, }); + expect(result).toBe(secretMessage); + }); - it('should throw if passed null data', function () { - expect(() => - encryptSafely({ - publicKey: bob.encryptionPublicKey, - data: null, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing data parameter'); - }); + it('decryption failed because version is wrong or missing', function () { + const badVersionData = { + version: 'x256k1-aes256cbc', + nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', + ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', + ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', + }; + + expect(() => + decrypt({ + encryptedData: badVersionData, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Encryption type/version not supported.'); + }); - it('should throw if passed undefined data', function () { - expect(() => - encryptSafely({ - publicKey: bob.encryptionPublicKey, - data: undefined, - version: 'x25519-xsalsa20-poly1305', - }), - ).toThrow('Missing data parameter'); - }); + it('decryption failed because nonce is wrong or missing', function () { + // encrypted data + const badNonceData = { + version: 'x25519-xsalsa20-poly1305', + nonce: '', + ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', + ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', + }; + + expect(() => + decrypt({ + encryptedData: badNonceData, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('bad nonce size'); + }); - it('should throw if passed null version', function () { - expect(() => - encryptSafely({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version: null as any, - }), - ).toThrow('Missing version parameter'); - }); + it('decryption failed because ephemPublicKey is wrong or missing', function () { + // encrypted data + const badEphemData = { + version: 'x25519-xsalsa20-poly1305', + nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', + ephemPublicKey: 'FFFF/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', + ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', + }; + + expect(() => + decrypt({ + encryptedData: badEphemData, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Decryption failed.'); + }); - it('should throw if passed undefined version', function () { - expect(() => - encryptSafely({ - publicKey: bob.encryptionPublicKey, - data: secretMessage, - version: undefined as any, - }), - ).toThrow('Missing version parameter'); - }); + it('decryption failed because cyphertext is wrong or missing', function () { + // encrypted data + const badEphemData = { + version: 'x25519-xsalsa20-poly1305', + nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', + ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', + ciphertext: 'ffffff/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', + }; + + expect(() => + decrypt({ + encryptedData: badEphemData, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Decryption failed.'); }); - describe('decrypt', function () { - it('should throw if passed null encrypted data', function () { - expect(() => - decrypt({ - encryptedData: null as any, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Missing encryptedData parameter'); + describe('validation', function () { + describe('encrypt', function () { + it('should throw if passed null public key', function () { + expect(() => + encrypt({ + publicKey: null as any, + data: secretMessage, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing publicKey parameter'); + }); + + it('should throw if passed undefined public key', function () { + expect(() => + encrypt({ + publicKey: undefined as any, + data: secretMessage, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing publicKey parameter'); + }); + + it('should throw if passed null data', function () { + expect(() => + encrypt({ + publicKey: bob.encryptionPublicKey, + data: null, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing data parameter'); + }); + + it('should throw if passed undefined data', function () { + expect(() => + encrypt({ + publicKey: bob.encryptionPublicKey, + data: undefined, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing data parameter'); + }); + + it('should throw if passed null version', function () { + expect(() => + encrypt({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version: null as any, + }), + ).toThrow('Missing version parameter'); + }); + + it('should throw if passed undefined version', function () { + expect(() => + encrypt({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version: undefined as any, + }), + ).toThrow('Missing version parameter'); + }); }); - it('should throw if passed undefined encrypted data', function () { - expect(() => - decrypt({ - encryptedData: undefined as any, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Missing encryptedData parameter'); + describe('encryptSafely', function () { + it('should throw if passed null public key', function () { + expect(() => + encryptSafely({ + publicKey: null as any, + data: secretMessage, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing publicKey parameter'); + }); + + it('should throw if passed undefined public key', function () { + expect(() => + encryptSafely({ + publicKey: undefined as any, + data: secretMessage, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing publicKey parameter'); + }); + + it('should throw if passed null data', function () { + expect(() => + encryptSafely({ + publicKey: bob.encryptionPublicKey, + data: null, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing data parameter'); + }); + + it('should throw if passed undefined data', function () { + expect(() => + encryptSafely({ + publicKey: bob.encryptionPublicKey, + data: undefined, + version: 'x25519-xsalsa20-poly1305', + }), + ).toThrow('Missing data parameter'); + }); + + it('should throw if passed null version', function () { + expect(() => + encryptSafely({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version: null as any, + }), + ).toThrow('Missing version parameter'); + }); + + it('should throw if passed undefined version', function () { + expect(() => + encryptSafely({ + publicKey: bob.encryptionPublicKey, + data: secretMessage, + version: undefined as any, + }), + ).toThrow('Missing version parameter'); + }); }); - it('should throw if passed null private key', function () { - expect(() => - decrypt({ - encryptedData, - privateKey: null as any, - }), - ).toThrow('Missing privateKey parameter'); + describe('decrypt', function () { + it('should throw if passed null encrypted data', function () { + expect(() => + decrypt({ + encryptedData: null as any, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Missing encryptedData parameter'); + }); + + it('should throw if passed undefined encrypted data', function () { + expect(() => + decrypt({ + encryptedData: undefined as any, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Missing encryptedData parameter'); + }); + + it('should throw if passed null private key', function () { + expect(() => + decrypt({ + encryptedData, + privateKey: null as any, + }), + ).toThrow('Missing privateKey parameter'); + }); + + it('should throw if passed undefined private key', function () { + expect(() => + decrypt({ + encryptedData, + privateKey: undefined as any, + }), + ).toThrow('Missing privateKey parameter'); + }); }); - it('should throw if passed undefined private key', function () { - expect(() => - decrypt({ - encryptedData, - privateKey: undefined as any, - }), - ).toThrow('Missing privateKey parameter'); + describe('decryptSafely', function () { + it('should throw if passed null encrypted data', function () { + expect(() => + decryptSafely({ + encryptedData: null as any, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Missing encryptedData parameter'); + }); + + it('should throw if passed undefined encrypted data', function () { + expect(() => + decryptSafely({ + encryptedData: undefined as any, + privateKey: bob.ethereumPrivateKey, + }), + ).toThrow('Missing encryptedData parameter'); + }); + + it('should throw if passed null private key', function () { + expect(() => + decryptSafely({ + encryptedData, + privateKey: null as any, + }), + ).toThrow('Missing privateKey parameter'); + }); + + it('should throw if passed undefined private key', function () { + expect(() => + decryptSafely({ + encryptedData, + privateKey: undefined as any, + }), + ).toThrow('Missing privateKey parameter'); + }); }); }); + }); +}; - describe('decryptSafely', function () { - it('should throw if passed null encrypted data', function () { - expect(() => - decryptSafely({ - encryptedData: null as any, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Missing encryptedData parameter'); - }); - - it('should throw if passed undefined encrypted data', function () { - expect(() => - decryptSafely({ - encryptedData: undefined as any, - privateKey: bob.ethereumPrivateKey, - }), - ).toThrow('Missing encryptedData parameter'); - }); +run({ + decrypt, + decryptSafely, + encrypt, + encryptSafely, + getEncryptionPublicKey, +}); - it('should throw if passed null private key', function () { - expect(() => - decryptSafely({ - encryptedData, - privateKey: null as any, - }), - ).toThrow('Missing privateKey parameter'); - }); +run({ + decrypt, + decryptSafely, + encrypt: legacyEncrypt, + encryptSafely: legacyEncryptSafely, + getEncryptionPublicKey: legacyGetEncryptionPublicKey, +}); - it('should throw if passed undefined private key', function () { - expect(() => - decryptSafely({ - encryptedData, - privateKey: undefined as any, - }), - ).toThrow('Missing privateKey parameter'); - }); - }); - }); +run({ + decrypt: legacyDecrypt, + decryptSafely: legacyDecryptSafely, + encrypt, + encryptSafely, + getEncryptionPublicKey, }); diff --git a/src/test-legacy-encryption.ts b/src/test-legacy-encryption.ts new file mode 100644 index 00000000..81e382a6 --- /dev/null +++ b/src/test-legacy-encryption.ts @@ -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); +} diff --git a/yarn.lock b/yarn.lock index 78cc0a48..3e28d8c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -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"