Version 1.3.0

* Add the possibility to generate legacy seeds and mnemonics
* Add the possibility to import legacy mnemonic phrases
* Stop throwing errors if given an upper case hex as input
* Return mnemonic also when creating wallet with a legacy seed
* npm audit fix
* Small refactoring
This commit is contained in:
Miro Metsänheimo 2021-01-12 22:30:58 +02:00
commit 1cf9dbf08f
9 changed files with 633 additions and 1046 deletions

View file

@ -6,13 +6,14 @@
Toolkit for Nano cryptocurrency client side offline implementations allowing you to build web- and mobile applications using Nano without compromising the user's keys by sending them out of their own device.
The toolkit supports creating and importing wallets and signing blocks on-device. Meaning that the user's keys should never be required to leave the device.
The toolkit supports creating and importing wallets and signing blocks on-device. Meaning that the user's keys should never be required to leave the device. And much more!
## Features
* Generate wallets with a BIP32 mnemonic phrase
* BIP39/44 private key derivation
* Mnemonic is compatible with the Ledger Nano implementation
* Generate wallets with legacy Nano mnemonic phrases
* BIP32/44 private key derivation
* BIP39 Mnemonic is the same one which Ledger uses in their hardware wallets
* Import wallets with a mnemonic phrase or a seed
* Import wallets with the legacy Nano hex seed
* Sign send, receive and change representative blocks with a private key
@ -44,9 +45,16 @@ import { wallet } from 'nanocurrency-web'
// Notice, that losing the password will make the mnemonic phrase void
const wallet = wallet.generate(entropy?, password?)
// Generates a legacy wallet with a mnemonic phrase, seed and an account
// You can provide your own seed to be used instead
const wallet = wallet.generateLegacey(seed?)
// Import a wallet with the mnemonic phrase
const wallet = wallet.fromMnemonic(mnemonic, seedPassword?)
// Import a wallet with the legacy mnemonic phrase
const wallet = wallet.fromLegacyMnemonic(mnemonic)
// Import a wallet with a seed
const wallet = wallet.fromSeed(seed)
@ -210,7 +218,7 @@ const valid = tools.validateMnemonic('edge defense waste choose enrich upon flee
### In web
```html
<script src="https://unpkg.com/nanocurrency-web@1.2.2" type="text/javascript"></script>
<script src="https://unpkg.com/nanocurrency-web@1.3.0" type="text/javascript"></script>
<script type="text/javascript">
NanocurrencyWeb.wallet.generate(...);
</script>

View file

@ -40,6 +40,25 @@ const wallet = {
return generator.generateWallet(entropy, seedPassword)
},
/**
* Generate a new Nano cryptocurrency wallet
*
* This function generates a legacy wallet from a random seed. Wallet includes
* a mnemonic phrase and a seed, the account is derived from the seed at index 0.
*
* The Nano address is derived from the public key using standard Nano encoding.
* The address is prefixed with 'nano_'.
*
* Generation uses CryptoJS to generate random seed by default. You can give your own seed
* as a parameter and it will be used instead.
*
* @param {string} [seed] - (Optional) 64 byte hexadecimal string seed to be used instead of generating
* @returns the generated mnemonic, seed and account
*/
generateLegacy: (seed: string): Wallet => {
return generator.generateLegacyWallet(seed)
},
/**
* Import a Nano cryptocurrency wallet from a mnemonic phrase
*
@ -59,6 +78,23 @@ const wallet = {
return importer.fromMnemonic(mnemonic, seedPassword)
},
/**
* Import a Nano cryptocurrency wallet from a legacy mnemonic phrase
*
* This function imports a wallet from an old Nano mnemonic phrase. Wallet includes the mnemonic
* phrase, a seed which represents the mnemonic and an account derived from the seed at index 0.
*
* The Nano address is derived from the public key using standard Nano encoding.
* The address is prefixed with 'nano_'.
*
* @param {string} mnemonic - The mnemonic phrase. Words are separated with a space
* @throws Throws an error if the mnemonic phrase doesn't pass validations
* @returns the wallet derived from the mnemonic (mnemonic, seed, account)
*/
fromLegacyMnemonic: (mnemonic: string): Wallet => {
return importer.fromLegacyMnemonic(mnemonic)
},
/**
* Import a Nano cryptocurrency wallet from a seed
*

View file

@ -1,40 +1,35 @@
import Bip32KeyDerivation from './bip32-key-derivation'
import AddressImporter, { Wallet } from './address-importer'
import Bip39Mnemonic from './bip39-mnemonic'
import Ed25519 from './ed25519'
import NanoAddress from './nano-address'
import { Wallet } from './address-importer'
export default class AddressGenerator {
/**
* Generates the wallet
* Generates a hierarchial deterministic BIP32/39/44 wallet
*
* @param {string} [entropy] - (Optional) Custom entropy if the caller doesn't want a default generated entropy
* @param {string} [seedPassword] - (Optional) Password for the seed
*/
generateWallet(entropy = '', seedPassword: string = ''): Wallet {
const bip39 = new Bip39Mnemonic(seedPassword)
const wallet = bip39.createWallet(entropy)
const bip44 = new Bip32KeyDerivation(`44'/165'/0'`, wallet.seed)
const privateKey = bip44.derivePath().key
const ed25519 = new Ed25519()
const keyPair = ed25519.generateKeys(privateKey)
const nano = new NanoAddress()
const address = nano.deriveAddress(keyPair.publicKey)
const mnemonicSeed = bip39.createWallet(entropy)
const wallet = new AddressImporter().fromSeed(mnemonicSeed.seed, 0, 0)
return {
mnemonic: wallet.mnemonic,
seed: wallet.seed,
accounts: [{
accountIndex: 0,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
address,
}],
...wallet,
mnemonic: mnemonicSeed.mnemonic,
}
}
/**
* Generates a legacy Nano wallet
*
*/
generateLegacyWallet(seed?: string): Wallet {
const bip39 = new Bip39Mnemonic()
const mnemonicSeed = bip39.createLegacyWallet(seed)
const wallet = new AddressImporter().fromLegacySeed(mnemonicSeed.seed, 0, 0, mnemonicSeed.mnemonic)
return wallet
}
}

View file

@ -21,7 +21,29 @@ export default class AddressImporter {
}
const seed = bip39.mnemonicToSeed(mnemonic)
return this.nano(seed, 0, 0, mnemonic)
const accounts = this.accounts(seed, 0, 0)
return {
mnemonic,
seed,
accounts,
}
}
/**
* Import a legacy wallet using a mnemonic phrase
*
* @param {string} mnemonic - The mnemonic words to import the wallet from
* @returns {Wallet} - The wallet derived from the mnemonic phrase
*/
fromLegacyMnemonic(mnemonic: string): Wallet {
const bip39 = new Bip39Mnemonic()
if (!bip39.validateMnemonic(mnemonic)) {
throw new Error('Invalid mnemonic phrase')
}
const seed = bip39.mnemonicToLegacySeed(mnemonic)
return this.fromLegacySeed(seed, 0, 0, mnemonic)
}
/**
@ -46,11 +68,17 @@ export default class AddressImporter {
if (seed.length !== 128) {
throw new Error('Invalid seed length, must be a 128 byte hexadecimal string')
}
if (!/^[0-9a-f]+$/i.test(seed)) {
if (!/^[0-9a-fA-F]+$/i.test(seed)) {
throw new Error('Seed is not a valid hexadecimal string')
}
return this.nano(seed, from, to, undefined)
const accounts = this.accounts(seed, from, to)
return {
mnemonic: undefined,
seed,
accounts,
}
}
@ -62,43 +90,30 @@ export default class AddressImporter {
* @param {number} [to] - (Optional) The end index of the private keys to derive to
* @returns {Wallet} The wallet derived from the seed
*/
fromLegacySeed(seed: string, from: number = 0, to: number = 0): Wallet {
const signer = new Signer()
const accounts: Account[] = []
for (let i = from; i <= to; i++) {
const privateKey = Convert.ab2hex(signer.generateHash([seed, Convert.dec2hex(i, 4)]))
const ed25519 = new Ed25519()
const keyPair = ed25519.generateKeys(privateKey)
const nano = new NanoAddress()
const address = nano.deriveAddress(keyPair.publicKey)
accounts.push({
accountIndex: i,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
address,
})
fromLegacySeed(seed: string, from: number = 0, to: number = 0, mnemonic?: string): Wallet {
if (seed.length !== 64) {
throw new Error('Invalid seed length, must be a 64 byte hexadecimal string')
}
if (!/^[0-9a-fA-F]+$/i.test(seed)) {
throw new Error('Seed is not a valid hexadecimal string')
}
const accounts = this.legacyAccounts(seed, from, to)
return {
mnemonic: undefined,
mnemonic: mnemonic || new Bip39Mnemonic().deriveMnemonic(seed),
seed,
accounts,
}
}
/**
* Derives the private keys
* Derives BIP32 private keys
*
* @param {string} seed - The seed to use for private key derivation
* @param {number} from - The start index of private keys to derive from
* @param {number} to - The end index of private keys to derive to
* @param {string} [mnemonic] - (Optional) the mnemonic phrase to return with the wallet
*/
private nano(seed: string, from: number, to: number, mnemonic?: string): Wallet {
private accounts(seed: string, from: number, to: number): Account[] {
const accounts = []
for (let i = from; i <= to; i++) {
@ -119,11 +134,37 @@ export default class AddressImporter {
})
}
return {
mnemonic,
seed,
accounts,
return accounts
}
/**
* Derives legacy private keys
*
* @param {string} seed - The seed to use for private key derivation
* @param {number} from - The start index of private keys to derive from
* @param {number} to - The end index of private keys to derive to
*/
private legacyAccounts(seed: string, from: number, to: number): Account[] {
const signer = new Signer()
const accounts: Account[] = []
for (let i = from; i <= to; i++) {
const privateKey = Convert.ab2hex(signer.generateHash([seed, Convert.dec2hex(i, 4)]))
const ed25519 = new Ed25519()
const keyPair = ed25519.generateKeys(privateKey)
const nano = new NanoAddress()
const address = nano.deriveAddress(keyPair.publicKey)
accounts.push({
accountIndex: i,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
address,
})
}
return accounts
}
}

View file

@ -14,7 +14,7 @@ export default class Bip39Mnemonic {
}
/**
* Creates a new wallet
* Creates a BIP39 wallet
*
* @param {string} [entropy] - (Optional) the entropy to use instead of generating
* @returns {MnemonicSeed} The mnemonic phrase and a seed derived from the (generated) entropy
@ -24,7 +24,7 @@ export default class Bip39Mnemonic {
if (entropy.length !== 64) {
throw new Error('Invalid entropy length, must be a 64 byte hexadecimal string')
}
if (!/^[0-9a-f]+$/i.test(entropy)) {
if (!/^[0-9a-fA-F]+$/i.test(entropy)) {
throw new Error('Entopy is not a valid hexadecimal string')
}
}
@ -33,6 +33,44 @@ export default class Bip39Mnemonic {
entropy = this.randomHex(64)
}
const mnemonic = this.deriveMnemonic(entropy)
const seed = this.mnemonicToSeed(mnemonic)
return {
mnemonic,
seed,
}
}
/**
* Creates an old Nano wallet
*
* @param {string} seed - (Optional) the seed to be used for the wallet
* @returns {MnemonicSeed} The mnemonic phrase and a generated seed if none provided
*/
createLegacyWallet = (seed?: string): MnemonicSeed => {
if (seed) {
if (seed.length !== 64) {
throw new Error('Invalid entropy length, must be a 64 byte hexadecimal string')
}
if (!/^[0-9a-fA-F]+$/i.test(seed)) {
throw new Error('Entopy is not a valid hexadecimal string')
}
}
if (!seed) {
seed = this.randomHex(64)
}
const mnemonic = this.deriveMnemonic(seed)
return {
mnemonic,
seed,
}
}
deriveMnemonic = (entropy: string): string => {
const entropyBinary = Convert.hexStringToBinary(entropy)
const entropySha256Binary = Convert.hexStringToBinary(this.calculateChecksum(entropy))
const entropyBinaryWithChecksum = entropyBinary + entropySha256Binary
@ -42,13 +80,7 @@ export default class Bip39Mnemonic {
mnemonicWords.push(words[parseInt(entropyBinaryWithChecksum.substr(i, 11), 2)])
}
const mnemonicFinal = mnemonicWords.join(' ')
const seed = this.mnemonicToSeed(mnemonicFinal)
return {
mnemonic: mnemonicFinal,
seed,
}
return mnemonicWords.join(' ')
}
/**
@ -68,7 +100,7 @@ export default class Bip39Mnemonic {
if (wordIndex === -1) {
return false
}
return (wordIndex.toString(2)).padStart(11, '0')
return (Convert.dec2bin(wordIndex)).padStart(11, '0')
}).join('')
const dividerIndex = Math.floor(bits.length / 33) * 32
@ -76,9 +108,17 @@ export default class Bip39Mnemonic {
const checksumBits = bits.slice(dividerIndex)
const entropyBytes = entropyBits.match(/(.{1,8})/g).map((bin: string) => parseInt(bin, 2))
if (entropyBytes.length < 16) return false
if (entropyBytes.length > 32) return false
if (entropyBytes.length % 4 !== 0) return false
if (entropyBytes.length < 16) {
return false
}
if (entropyBytes.length > 32) {
return false
}
if (entropyBytes.length % 4 !== 0) {
return false
}
const entropyHex = Convert.bytesToHexString(entropyBytes)
const newChecksum = this.calculateChecksum(entropyHex)
@ -91,6 +131,34 @@ export default class Bip39Mnemonic {
return true
}
/**
* Converts the mnemonic phrase to an old Nano seed
*
* @param {string} mnemonic Mnemonic phrase separated by spaces
*/
mnemonicToLegacySeed = (mnemonic: string): string => {
const wordArray = Util.normalizeUTF8(mnemonic).split(' ')
const bits = wordArray.map((w: string) => {
const wordIndex = words.indexOf(w)
if (wordIndex === -1) {
return false
}
return (Convert.dec2bin(wordIndex)).padStart(11, '0')
}).join('')
const dividerIndex = Math.floor(bits.length / 33) * 32
const entropyBits = bits.slice(0, dividerIndex)
const entropyBytes = entropyBits.match(/(.{1,8})/g).map((bin: string) => parseInt(bin, 2))
const entropyHex = Convert.bytesToHexString(entropyBytes)
return entropyHex
}
/**
* Converts the mnemonic phrase to a BIP39 seed
*
* @param {string} mnemonic Mnemonic phrase separated by spaces
*/
mnemonicToSeed = (mnemonic: string): string => {
const normalizedMnemonic = Util.normalizeUTF8(mnemonic)
const normalizedPassword = 'mnemonic' + Util.normalizeUTF8(this.password)

View file

@ -93,12 +93,16 @@ export default class Convert {
return joined
}
static dec2bin = (dec: number): string => {
return (dec >>> 0).toString(2)
}
static bytesToHexString = (bytes: number[]): string => {
return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
}
static hexStringToBinary = (hex: string): string => {
return [...hex].map(c => (parseInt(c, 16).toString(2)).padStart(4, '0')).join('')
return [...hex].map(c => (Convert.dec2bin(parseInt(c, 16))).padStart(4, '0')).join('')
}
static binaryToHexString = (bin: string): string => {

1366
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "nanocurrency-web",
"version": "1.2.2",
"version": "1.3.0",
"description": "Toolkit for Nano cryptocurrency client side offline integrations",
"author": "Miro Metsänheimo <miro@metsanheimo.fi>",
"license": "MIT",

View file

@ -56,7 +56,7 @@ describe('generate wallet test', () => {
})
// Test vectors from https://docs.nano.org/integration-guides/key-management/
// Test vectors from https://docs.nano.org/integration-guides/key-management/ and elsewhere
describe('import wallet with test vectors test', () => {
it('should successfully import a wallet with the official Nano test vectors mnemonic', () => {
@ -94,7 +94,7 @@ describe('import wallet with test vectors test', () => {
expect(result).to.have.own.property('mnemonic')
expect(result).to.have.own.property('seed')
expect(result).to.have.own.property('accounts')
expect(result.mnemonic).to.be.undefined
expect(result.mnemonic).to.equal('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art')
expect(result.seed).to.equal('0000000000000000000000000000000000000000000000000000000000000000')
expect(result.accounts[0].privateKey).to.equal('9f0e444c69f77a49bd0be89db92c38fe713e0963165cca12faf5712d7657120f')
expect(result.accounts[0].publicKey).to.equal('c008b814a7d269a1fa3c6528b19201a24d797912db9996ff02a1ff356e45552b')
@ -123,6 +123,19 @@ describe('import wallet with test vectors test', () => {
expect(() => wallet.generate('0gc285fde768f7ff29b66ce7252d56ed92fe003b605907f7a4f683c3dc8586d34a914d3c71fc099bb38ee4a59e5b081a3497b7a323e90cc68f67b5837690310c')).to.throw(Error)
})
it('should successfully create a new legacy wallet and get the same result from importing one from the mnemonic', () => {
const result = wallet.generateLegacy('BE3E51EE51BAB11950B2495013512FEB110D9898B4137DA268709621CE2862F4')
expect(result).to.have.own.property('mnemonic')
expect(result).to.have.own.property('seed')
expect(result).to.have.own.property('accounts')
expect(result.mnemonic).to.equal('sail verb knee pet prison million drift empty exotic once episode stomach awkward slush glare list laundry battle bring clump brother before mesh pair')
const imported = wallet.fromLegacyMnemonic(result.mnemonic)
expect(imported.mnemonic).to.equal(result.mnemonic)
expect(imported.seed.toUpperCase()).to.equal(result.seed)
expect(imported.accounts[0].privateKey).to.equal(result.accounts[0].privateKey)
})
})
describe('derive more accounts from the same seed test', () => {