diff --git a/packages/jellyfish-wallet-encrypted/__tests__/encrypted_mnemonic_provider.test.ts b/packages/jellyfish-wallet-encrypted/__tests__/encrypted_mnemonic_provider.test.ts new file mode 100644 index 0000000000..a93cffac82 --- /dev/null +++ b/packages/jellyfish-wallet-encrypted/__tests__/encrypted_mnemonic_provider.test.ts @@ -0,0 +1,242 @@ +import BigNumber from 'bignumber.js' +import { OP_CODES, Transaction, Vout } from '@defichain/jellyfish-transaction' +import { OnDemandMnemonicHdNode, EncryptedMnemonicProvider, ScryptStorage, SimpleScryptsy, Storage } from '../src' +import { HASH160 } from '@defichain/jellyfish-crypto' + +// Mock storage +class MockStore implements Storage { + inMemory: string | undefined + async getter (): Promise { + return this.inMemory + } + + async setter (data: string|undefined): Promise { + this.inMemory = data + } +} + +const sampleMnemonicSeed = 'e9873d79c6d87dc0fb6a5778633389f4e93213303da61f20bd67fc233aa33262' +const scryptPovider = new SimpleScryptsy({ + N: 16384, + r: 8, + p: 1 // to speed up test, p = 8 take ~4s for each encrypt or decrypt +}) + +describe('EncryptedMnemonicProvider', () => { + let provider: EncryptedMnemonicProvider + const seedStore = new MockStore() + const seedHashStore = new MockStore() + let node: OnDemandMnemonicHdNode + + beforeEach(async () => { + const seed = Buffer.from(sampleMnemonicSeed, 'hex') + const collectPassphrase = async (): Promise => 'password' + + provider = await EncryptedMnemonicProvider.create({ + scryptStorage: new ScryptStorage( + scryptPovider, // client platform supported encryption implementation + seedStore, // client provided interface to get/set encrypted seed + seedHashStore // client provided interface to get/set encrypted seedHash, for passphrase validation + ), + seed, // new wallet, pass in seed + collectPassphrase, // interface, wait for user input + options: { // bip32 options + bip32: { + public: 0x00000000, + private: 0x00000000 + }, + wif: 0x00 + } + }) + + node = await provider.derive("44'/1129'/0'/0/0") + + const pubKey = await node.publicKey() + const privKey = await node.privateKey() + expect(pubKey.length).toStrictEqual(33) + expect(privKey.length).toStrictEqual(32) + }) + + it('EncryptedMnemonicProvider.load() - should be able to load/restore a provider instance', async () => { + const loaded = await EncryptedMnemonicProvider.load({ + scryptStorage: new ScryptStorage( + scryptPovider, // must have same Scrypt logic + seedStore, // already holding the encrypted seed + seedHashStore // already holding the seed hash + ), + collectPassphrase: async () => 'password', + options: { + bip32: { + public: 0x00000000, + private: 0x00000000 + }, + wif: 0x00 + } + }) + + const pubKey = await node.publicKey() + const privKey = await node.privateKey() + + const loadedNode = await loaded.derive("44'/1129'/0'/0/0") + const loadedPubKey = await loadedNode.publicKey() + const loadedPrivKey = await loadedNode.privateKey() + + expect(pubKey.toString('hex')).toStrictEqual(loadedPubKey.toString('hex')) + expect(privKey.toString('hex')).toStrictEqual(loadedPrivKey.toString('hex')) + }) + + it('Should be able to derive multiple node/elliptic pair and unlockable with same passphrase', async () => { + const node1 = await provider.derive("44'/1129'/1'/0/0") + const pubKey1 = await node1.publicKey() + const privKey1 = await node1.privateKey() + + const node2 = await provider.derive("44'/1129'/2'/0/0") + const pubKey2 = await node2.publicKey() + const privKey2 = await node2.privateKey() + expect(pubKey2.length).toStrictEqual(33) + expect(privKey2.length).toStrictEqual(32) + + expect(pubKey1.toString('hex')).not.toStrictEqual(pubKey2.toString('hex')) + expect(privKey1.toString('hex')).not.toStrictEqual(privKey2.toString('hex')) + }) + + it('Should be able to sign and verify', async () => { + const hash = Buffer.from('e9071e75e25b8a1e298a72f0d2e9f4f95a0f5cdf86a533cda597eb402ed13b3a', 'hex') + const signature = await node.sign(hash) + + expect(signature.length).toBeLessThanOrEqual(70) + expect(signature.length).toBeGreaterThanOrEqual(67) // 0.00001 probability of being this length + + expect(await node.verify(hash, signature)).toBeTruthy() + + // meant to fail, differnt pubkey + const anotherNode = await provider.derive("44'/1129'/1'/0/0") + expect(await anotherNode.verify(hash, signature)).toBeFalsy() + }) + + it('signTx()', async () => { + const transaction: Transaction = { + version: 0x00000004, + lockTime: 0x00000000, + vin: [{ + index: 0, + script: { stack: [] }, + sequence: 4294967278, + txid: '9f96ade4b41d5433f4eda31e1738ec2b36f6e7d1420d94a6af99801a88f7f7ff' + }], + vout: [{ + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(Buffer.from('1d0f172a0ecb48aee1be1f2687d2963ae33f71a1', 'hex'), 'little') + ] + }, + value: new BigNumber('5.98'), + tokenId: 0x00 + }] + } + + const prevout: Vout = { + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(Buffer.from('1d0f172a0ecb48aee1be1f2687d2963ae33f71a1', 'hex'), 'little') + ] + }, + value: new BigNumber('6'), + tokenId: 0x00 + } + + const signed = await node.signTx(transaction, [{ + ...prevout, + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(HASH160(await node.publicKey()), 'little') + ] + } + }]) + + expect(signed.witness.length).toStrictEqual(1) + expect(signed.witness[0].scripts.length).toStrictEqual(2) + + expect(signed.witness[0].scripts[0].hex.length).toBeGreaterThanOrEqual(140) + expect(signed.witness[0].scripts[0].hex.length).toBeLessThanOrEqual(142) + expect(signed.witness[0].scripts[1].hex.length).toStrictEqual(66) + }) + + it('should failed to unlock seed using invalid password thus unable to access any data of hdnode', async () => { + let password = 'valid-password' + const seed = Buffer.from(sampleMnemonicSeed, 'hex') + const collectPassphrase = async (): Promise => password + + const provider = await EncryptedMnemonicProvider.create({ + scryptStorage: new ScryptStorage( + scryptPovider, // client platform supported encryption implementation + seedStore, // client provided interface to get/set encrypted seed + seedHashStore // client provided interface to get/set encrypted seedHash, for passphrase validation + ), + seed, // new wallet, pass in seed + collectPassphrase, // interface, wait for user input + options: { // bip32 options + bip32: { + public: 0x00000000, + private: 0x00000000 + }, + wif: 0x00 + } + }) + + const encryptedHdNode = provider.derive("44'/1129'/0'/0/0") + const publicKey = await encryptedHdNode.publicKey() // no err thrown + + password = 'invalid' + + await expect(encryptedHdNode.publicKey()).rejects.toThrow('InvalidPassphrase') + await expect(encryptedHdNode.privateKey()).rejects.toThrow('InvalidPassphrase') + await expect(encryptedHdNode.sign(Buffer.from('e9071e75e25b8a1e298a72f0d2e9f4f95a0f5cdf86a533cda597eb402ed13b3a', 'hex'))).rejects.toThrow('InvalidPassphrase') + await expect(encryptedHdNode.verify(Buffer.alloc(1), Buffer.alloc(1))).rejects.toThrow('InvalidPassphrase') + + const transaction: Transaction = { + version: 0x00000004, + lockTime: 0x00000000, + vin: [{ + index: 0, + script: { stack: [] }, + sequence: 4294967278, + txid: '9f96ade4b41d5433f4eda31e1738ec2b36f6e7d1420d94a6af99801a88f7f7ff' + }], + vout: [{ + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(Buffer.from('1d0f172a0ecb48aee1be1f2687d2963ae33f71a1', 'hex'), 'little') + ] + }, + value: new BigNumber('5.98'), + tokenId: 0x00 + }] + } + + const prevout: Vout = { + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(Buffer.from('1d0f172a0ecb48aee1be1f2687d2963ae33f71a1', 'hex'), 'little') + ] + }, + value: new BigNumber('6'), + tokenId: 0x00 + } + + await expect(encryptedHdNode.signTx(transaction, [{ + ...prevout, + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(HASH160(publicKey), 'little') + ] + } + }])).rejects.toThrow('InvalidPassphrase') + }) +}) diff --git a/packages/jellyfish-wallet-encrypted/src/bip32-provider.ts b/packages/jellyfish-wallet-encrypted/src/bip32-provider.ts new file mode 100644 index 0000000000..213afb07c2 --- /dev/null +++ b/packages/jellyfish-wallet-encrypted/src/bip32-provider.ts @@ -0,0 +1,26 @@ +import { Bip32Options } from '@defichain/jellyfish-wallet-mnemonic' +import { ScryptStorage } from './scrypt_storage' +import * as bip32 from 'bip32' +import { Bip32Provider } from './encrypted_mnemonic_provider' + +export class EncryptedBip32Provider implements Bip32Provider { + /** + * @param {ScryptStorage} scryptStorage to store encrypted mnemonic seed + * @param {Bip32Options} prefixOptions to reconstruct Bip32Interface when hdnode unlocked with passphrase + * @param {Buffer} seedHash to verify new node derivation is using a valid seed + */ + constructor ( + private readonly collectPassphrase: () => Promise, + private readonly scryptStorage: ScryptStorage, + private readonly options: Bip32Options + ) {} + + async get (): Promise { + const passphrase = await this.collectPassphrase() + const seed = await this.scryptStorage.decrypt(passphrase) + if (seed === null) { + throw new Error('No encrypted seed found in storage') + } + return bip32.fromSeed(seed, this.options) + } +} diff --git a/packages/jellyfish-wallet-encrypted/src/encrypted_mnemonic_provider.ts b/packages/jellyfish-wallet-encrypted/src/encrypted_mnemonic_provider.ts new file mode 100644 index 0000000000..4d9873ae7d --- /dev/null +++ b/packages/jellyfish-wallet-encrypted/src/encrypted_mnemonic_provider.ts @@ -0,0 +1,153 @@ +import { Bip32Options, MnemonicHdNode } from '@defichain/jellyfish-wallet-mnemonic' +import { ScryptStorage } from './scrypt_storage' +import * as bip32 from 'bip32' +import { Transaction, Vout, TransactionSegWit } from '@defichain/jellyfish-transaction' +import { WalletHdNode, WalletHdNodeProvider } from '@defichain/jellyfish-wallet' +import { EncryptedBip32Provider } from './bip32-provider' + +export type CollectPassphrase = () => Promise +export interface Bip32Provider { + get: () => Promise +} + +/** + * Extended implementation of @see {@link MnemonicHdNode} without Bip32Interface pulled on demand + */ +export class OnDemandMnemonicHdNode implements WalletHdNode { + constructor ( + private readonly bip32Provider: Bip32Provider, + private readonly path: string + ) {} + + /** + * Demanding/Instantiate MnemonicHdNode + * @returns {Promise} + */ + private async _getHdNode (): Promise { + return new MnemonicHdNode(await this.bip32Provider.get(), this.path) + } + + /** + * Extended implementation of {@link MnemonicHdNode.verify} + * Pulling passphrase from provided `CollectPassphrase` interface as part of promise + * + * @return {Promise} + */ + async publicKey (): Promise { + return await (await this._getHdNode()).publicKey() + } + + /** + * Extended implementation of {@link MnemonicHdNode.verify} + * Pulling passphrase from provided `CollectPassphrase` interface as part of promise + * + * @param {Buffer} hash to verify with signature + * @param {Buffer} derSignature of the hash in encoded with DER, SIGHASHTYPE must not be included + * @return {Promise} + */ + async verify (hash: Buffer, derSignature: Buffer): Promise { + return await (await this._getHdNode()).verify(hash, derSignature) + } + + /** + * Extended implementation of {@link MnemonicHdNode.privateKey} + * Pulling passphrase from provided `CollectPassphrase` interface as part of promise + * + * @return {Promise} + */ + async privateKey (): Promise { + return await (await this._getHdNode()).privateKey() + } + + /** + * Extended implementation of {@link MnemonicHdNode.sign} + * Pulling passphrase from provided `CollectPassphrase` interface as part of promise + * + * @param {Buffer} hash to sign + * @return {Promise} + */ + async sign (hash: Buffer): Promise { + return await (await this._getHdNode()).sign(hash) + } + + /** + * Extended implementation of {@link MnemonicHdNode.signTx} + * Pulling passphrase from provided `CollectPassphrase` interface as part of promise + * + * @param {Transaction} transaction to sign + * @param {Vout[]} prevouts of transaction to sign, ellipticPair will be mapped to current node + * @return {Promise} + */ + async signTx (transaction: Transaction, prevouts: Vout[]): Promise { + return await (await this._getHdNode()).signTx(transaction, prevouts) + } +} + +export interface LoadEncryptedMnemonicOptions { + collectPassphrase: CollectPassphrase + scryptStorage: ScryptStorage + options: Bip32Options +} + +export interface CreateEncryptedMnemonicOptions extends LoadEncryptedMnemonicOptions { + seed: Buffer +} + +export class EncryptedMnemonicProvider implements WalletHdNodeProvider { + /** + * @param {() => Promise} collectPassphrasea n interface to request password from user + * @param {ScryptStorage} scryptStorage secured storage of mnemonic seed (encrypted) + * @param {Bip32Options} options to reconstruct Bip32Interface on demand after successfully unlocked seed + */ + private constructor ( + private readonly collectPassphrase: () => Promise, + private readonly scryptStorage: ScryptStorage, + private readonly options: Bip32Options + ) {} + + /** + * To create a new provider which able to derive { @see EncryptedMnemonicHdNode } which has similar function as MnemonicHdNode + * with new passphrase + * + * @param {CreateEncryptedMnemonicOptions} encryptedMnemonicOptions + * @param {ScryptStorage} encryptedMnemonicOptions.scryptStorage to store encrypted mnemonic seed + * @param {() => Promise} encryptedMnemonicOptions.collectPassphrase an interface to request password from user + * @param {Buffer} encryptedMnemonicOptions.seed to derive Bip32Interface + * @param {Bip32Options} encryptedMnemonicOptions.options to derive Bip32Interface + * @returns {EncryptedMnemonicProvider} + */ + static async create (encryptedMnemonicOptions: CreateEncryptedMnemonicOptions): Promise { + const { seed, collectPassphrase, scryptStorage, options } = encryptedMnemonicOptions + await scryptStorage.encrypt(seed, await collectPassphrase()) + return new EncryptedMnemonicProvider(collectPassphrase, scryptStorage, options) + } + + /** + * To instantiate an EncryptedMnemonicProvider from existing storage + * valid encryptedMnemonicOptions.passphrase expected to descrypt scrypted data successfully + * + * @param encryptedMnemonicOptions + * @param {ScryptStorage} encryptedMnemonicOptions.scryptStorage to store encrypted mnemonic seed + * @param {() => Promise} encryptedMnemonicOptions.collectPassphrase an interface to request password from user + * @param {Bip32Options} encryptedMnemonicOptions.options to derive Bip32Interface + * @returns {EncryptedMnemonicProvider} + * @throws Error InvalidPassphare if passphare invalid + * @throws Error 'No seed found in storage' if no seed existed in provided ScryptStorage + */ + static async load (encryptedMnemonicOptions: LoadEncryptedMnemonicOptions): Promise { + const { collectPassphrase, scryptStorage, options } = encryptedMnemonicOptions + const passphrase = await collectPassphrase() + const seed = await scryptStorage.decrypt(passphrase) + if (seed === null) { + throw new Error('No seed found in storage') + } + return new EncryptedMnemonicProvider(collectPassphrase, scryptStorage, options) + } + + derive (path: string): OnDemandMnemonicHdNode { + return new OnDemandMnemonicHdNode( + new EncryptedBip32Provider(this.collectPassphrase, this.scryptStorage, this.options), + path + ) + } +} diff --git a/packages/jellyfish-wallet-encrypted/src/index.ts b/packages/jellyfish-wallet-encrypted/src/index.ts index d4ab38bfce..85b4ecc9d5 100644 --- a/packages/jellyfish-wallet-encrypted/src/index.ts +++ b/packages/jellyfish-wallet-encrypted/src/index.ts @@ -1,2 +1,3 @@ export * from './scryptsy' export * from './scrypt_storage' +export * from './encrypted_mnemonic_provider' diff --git a/packages/jellyfish-wallet-encrypted/src/scrypt_storage.ts b/packages/jellyfish-wallet-encrypted/src/scrypt_storage.ts index 9d4cb168b2..db75fa0f21 100644 --- a/packages/jellyfish-wallet-encrypted/src/scrypt_storage.ts +++ b/packages/jellyfish-wallet-encrypted/src/scrypt_storage.ts @@ -73,12 +73,7 @@ export class ScryptStorage { readonly encryptedStorage: Storage, readonly hashStorage: Storage, readonly ivProvider?: InitVectorProvider - ) { - this.scryptProvider = scryptProvider - this.encryptedStorage = encryptedStorage - this.hashStorage = hashStorage - this.ivProvider = ivProvider - } + ) {} /** * To encrypt `data` with a `passphrase` derived secret (derivation based on provided `ScryptProvider`) @@ -119,6 +114,7 @@ export class ScryptStorage { * To decrypt raw data * @param {string} passphrase to decrypted data, utf8 string in normalization format C * @returns {Promise} null if no data found in storage + * @throws Error InvalidPassphrase if passphrase is invalid (decrypted value has no matching hash) */ async decrypt (passphrase: string): Promise { const encrypted = await this.encryptedStorage.getter() diff --git a/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts b/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts index 404e99adff..38838c9dcd 100644 --- a/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts +++ b/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts @@ -85,13 +85,10 @@ export function mnemonicToSeed (mnemonic: string[]): Buffer { * - BIP44 Multi-Account Hierarchy for Deterministic Wallets */ export class MnemonicHdNode implements WalletHdNode { - private readonly root: bip32.BIP32Interface - private readonly path: string - - constructor (root: bip32.BIP32Interface, path: string) { - this.root = root - this.path = path - } + constructor ( + private readonly root: bip32.BIP32Interface, + private readonly path: string + ) {} /** * @private derive current code BIP32Interface, internal @@ -172,11 +169,7 @@ export class MnemonicHdNodeProvider implements WalletHdNodeProvider