diff --git a/package-lock.json b/package-lock.json index 05920c8b..1a6b374a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3961,6 +3961,14 @@ } } }, + "base-x": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.8.tgz", + "integrity": "sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "base64-arraybuffer": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", @@ -4027,12 +4035,31 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "optional": true, "requires": { "file-uri-to-path": "1.0.0" } }, + "bip32": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.6.tgz", + "integrity": "sha512-HpV5OMLLGTjSVblmrtYRfFFKuQB+GArM0+XP8HGWfJ5vxYBqo+DesvJwOdC2WJ3bCkZShGf0QIfoIpeomVzVdA==", + "requires": { + "@types/node": "10.12.18", + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.1.3", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "dependencies": { + "@types/node": { + "version": "10.12.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.18.tgz", + "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==" + } + } + }, "bip39": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/bip39/-/bip39-2.6.0.tgz", @@ -4271,8 +4298,7 @@ "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", - "dev": true + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, "browserify-aes": { "version": "1.2.0", @@ -4377,6 +4403,24 @@ "https-proxy-agent": "^2.2.1" } }, + "bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha1-vhYedsNU9veIrkBx9j806MTwpCo=", + "requires": { + "base-x": "^3.0.2" + } + }, + "bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "requires": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, "buffer": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", @@ -6953,7 +6997,6 @@ "version": "6.5.3", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", - "dev": true, "requires": { "bn.js": "^4.4.0", "brorand": "^1.0.1", @@ -6967,8 +7010,7 @@ "bn.js": { "version": "4.11.9", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz", - "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==", - "dev": true + "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==" } } }, @@ -7830,9 +7872,7 @@ "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "optional": true + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, "filename-reserved-regex": { "version": "2.0.0", @@ -8692,7 +8732,6 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dev": true, "requires": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" @@ -8708,7 +8747,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", - "dev": true, "requires": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", @@ -11189,14 +11227,12 @@ "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "minimalistic-crypto-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", - "dev": true + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" }, "minimatch": { "version": "3.0.4", @@ -11409,9 +11445,7 @@ "nan": { "version": "2.14.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", - "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", - "dev": true, - "optional": true + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" }, "nanomatch": { "version": "1.2.13", @@ -17467,6 +17501,25 @@ "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", "dev": true }, + "tiny-secp256k1": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-1.1.6.tgz", + "integrity": "sha512-FmqJZGduTyvsr2cF3375fqGHUovSwDi/QytexX1Se4BPuPZpTE5Ftp5fg+EFSuEf3lhZqgCRjEG3ydUQ/aNiwA==", + "requires": { + "bindings": "^1.3.0", + "bn.js": "^4.11.8", + "create-hmac": "^1.1.7", + "elliptic": "^6.4.0", + "nan": "^2.13.2" + }, + "dependencies": { + "bn.js": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz", + "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==" + } + } + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -17755,6 +17808,11 @@ "is-typedarray": "^1.0.0" } }, + "typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" + }, "typescript": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz", @@ -19323,6 +19381,14 @@ } } }, + "wif": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz", + "integrity": "sha1-CNP1IFbGZnkplyb63g1DKudLRwQ=", + "requires": { + "bs58check": "<3.0.0" + } + }, "wildcard": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", diff --git a/package.json b/package.json index bf00708a..c8bf7935 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "angular2-uuid": "^1.1.1", "angularx-qrcode": "^11.0.0", "bignumber.js": "^9.0.0", + "bip32": "^2.0.6", "bip39": "^2.4.0", "cordova-plugin-audioinput": "1.0.1", "cordova-plugin-compat": "1.2.0", @@ -75,6 +76,7 @@ "secrets.js-grempe": "^1.1.0", "tslib": "^1.10.0", "tslint-config-valorsoft": "^2.2.1", + "wif": "^2.0.6", "zone.js": "~0.10.2" }, "devDependencies": { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 1ec3b08a..d286a639 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -90,6 +90,18 @@ const routes: Routes = [ path: 'transaction-signed', loadChildren: () => import('./pages/transaction-signed/transaction-signed.module').then((m) => m.TransactionSignedPageModule) }, + { + path: 'bip85-generate', + loadChildren: () => import('./pages/bip85-generate/bip85-generate.module').then((m) => m.Bip85GeneratePageModule) + }, + { + path: 'bip85-show', + loadChildren: () => import('./pages/bip85-show/bip85-show.module').then((m) => m.Bip85ShowPageModule) + }, + { + path: 'bip85-validate', + loadChildren: () => import('./pages/bip85-validate/bip85-validate.module').then((m) => m.Bip85ValidatePageModule) + }, { path: 'select-account', loadChildren: () => import('./pages/select-account/select-account.module').then((m) => m.SelectAccountPageModule) @@ -104,4 +116,4 @@ const routes: Routes = [ imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules, relativeLinkResolution: 'corrected' })], exports: [RouterModule] }) -export class AppRoutingModule {} +export class AppRoutingModule { } diff --git a/src/app/models/BIP85.spec.ts b/src/app/models/BIP85.spec.ts new file mode 100644 index 00000000..7c40e53f --- /dev/null +++ b/src/app/models/BIP85.spec.ts @@ -0,0 +1,83 @@ +import { BIP85 } from './BIP85' + +// tslint:disable:no-console + +// Test vectors taken from: https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#applications + +// Mnemonic: install scatter logic circle pencil average fall shoe quantum disease suspect usage +const rootKey = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb' + +describe('BIP85: Child Entropy', () => { + it('works for test case 1', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.derive(`m/83696968'/0'/0'`) + + expect(child).toEqual( + 'efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7' + ) + }) + + it('works for test case 2', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.derive(`m/83696968'/0'/1'`) + + expect(child).toEqual( + '70c6e3e8ebee8dc4c0dbba66076819bb8c09672527c4277ca8729532ad711872218f826919f6b67218adde99018a6df9095ab2b58d803b5b93ec9802085a690e' + ) + }) + + it('works for BIP39, 12 words', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveBIP39(0, 12, 0) + + expect(child.toEntropy()).toEqual('6250b68daf746d12a24d58b4787a714b') + expect(child.toMnemonic()).toEqual('girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose') + }) + + it('works for BIP39, 18 words', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveBIP39(0, 18, 0) + + expect(child.toEntropy()).toEqual('938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc') + expect(child.toMnemonic()).toEqual( + 'near account window bike charge season chef number sketch tomorrow excuse sniff circle vital hockey outdoor supply token' + ) + }) + + it('works for BIP39, 24 words', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveBIP39(0, 24, 0) + + expect(child.toEntropy()).toEqual('ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f') + expect(child.toMnemonic()).toEqual( + 'puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano' + ) + }) + + it('works for HD-Seed WIF', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveWIF(0) + + expect(child.toEntropy()).toEqual('7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1') + expect(child.toWIF()).toEqual('Kzyv4uF39d4Jrw2W7UryTHwZr1zQVNk4dAFyqE6BuMrMh1Za7uhp') + }) + + it('works for XPRV', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveXPRV(0) + + expect(child.toEntropy()).toEqual('ead0b33988a616cf6a497f1c169d9e92562604e38305ccd3fc96f2252c177682') + expect(child.toXPRV()).toEqual( + 'xprv9s21ZrQH143K2srSbCSg4m4kLvPMzcWydgmKEnMmoZUurYuBuYG46c6P71UGXMzmriLzCCBvKQWBUv3vPB3m1SATMhp3uEjXHJ42jFg7myX' + ) + }) + + it('works for HEX', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveHex(64, 0) + + expect(child.toEntropy()).toEqual( + '492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c' + ) + }) +}) diff --git a/src/app/models/BIP85.ts b/src/app/models/BIP85.ts new file mode 100644 index 00000000..f9db9dad --- /dev/null +++ b/src/app/models/BIP85.ts @@ -0,0 +1,162 @@ +import { BIP32Interface, fromBase58, fromSeed } from 'bip32' +import { validateMnemonic, entropyToMnemonic, mnemonicToSeed } from 'bip39' +import { BIP85Child } from './BIP85Child' + +import * as createHmac from 'create-hmac' + +export function checkValidIndex(index: number): boolean { + return typeof index === 'number' && index >= 0 +} + +// Copied from https://github.com/bitcoinjs/bip32/blob/master/ts-src/crypto.ts because it is not exported +export function hmacSHA512(key: Buffer, data: Buffer): Buffer { + return createHmac('sha512', key).update(data).digest() +} + +// https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki + +/** + * Constants defined in BIP-85 + */ +const BIP85_KEY: string = 'bip-entropy-from-k' +const BIP85_DERIVATION_PATH: number = 83696968 +export enum BIP85_APPLICATIONS { + BIP39 = 39, + WIF = 2, + XPRV = 32, + HEX = 128169 +} + +/** + * BIP-85 helper types + */ +type BIP85_WORD_LENGTHS = 12 | 18 | 24 + +type BIP39_LANGUAGES = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 + +/** + * Derive BIP-39 child entropy from a BIP-32 root key + */ +export class BIP85 { + private node: BIP32Interface + + constructor(node: BIP32Interface) { + this.node = node + } + + deriveBIP39(language: BIP39_LANGUAGES, words: BIP85_WORD_LENGTHS, index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('BIP39 invalid index') + } + + if (typeof language !== 'number') { + throw new Error('BIP39 invalid language type') + } + + if (!(language >= 0 && language <= 8)) { + throw new Error('BIP39 invalid language') + } + + const entropyLength: 16 | 24 | 32 = ((): 16 | 24 | 32 => { + switch (words) { + case 12: + return 16 + case 18: + return 24 + case 24: + return 32 + + default: + throw new Error('BIP39 invalid mnemonic length') + } + })() + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.BIP39}'/${language}'/${words}'/${index}'`, entropyLength) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.BIP39) + } + + deriveWIF(index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('WIF invalid index') + } + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.WIF}'/${index}'`, 32) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.WIF) + } + + deriveXPRV(index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('XPRV invalid index') + } + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.XPRV}'/${index}'`, 64) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.XPRV) + } + + deriveHex(numBytes: number, index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('HEX invalid index') + } + + if (typeof numBytes !== 'number') { + throw new Error('HEX invalid byte length type') + } + + if (numBytes < 16 || numBytes > 64) { + throw new Error('HEX invalid byte length') + } + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.HEX}'/${numBytes}'/${index}'`, numBytes) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.HEX) + } + + derive(path: string, bytesLength: number = 64): string { + const childNode: BIP32Interface = this.node.derivePath(path) + const childPrivateKey: Buffer = childNode.privateKey! // Child derived from root key always has private key + + const hash: Buffer = hmacSHA512(Buffer.from(BIP85_KEY), childPrivateKey) + const truncatedHash: Buffer = hash.slice(0, bytesLength) + + const childEntropy: string = truncatedHash.toString('hex') + + return childEntropy + } + + static fromBase58(bip32seed: string): BIP85 { + const node: BIP32Interface = fromBase58(bip32seed) + if (node.depth !== 0) { + throw new Error('Expected master, got child') + } + + return new BIP85(node) + } + + static fromSeed(bip32seed: Buffer): BIP85 { + const node: BIP32Interface = fromSeed(bip32seed) + if (node.depth !== 0) { + throw new Error('Expected master, got child') + } + + return new BIP85(node) + } + + static fromEntropy(entropy: string, password: string = ''): BIP85 { + const mnemonic = entropyToMnemonic(entropy) + + return BIP85.fromMnemonic(mnemonic, password) + } + + static fromMnemonic(mnemonic: string, password: string = ''): BIP85 { + if (!validateMnemonic(mnemonic)) { + throw new Error('Invalid mnemonic') + } + + const seed = mnemonicToSeed(mnemonic, password) + + return BIP85.fromSeed(seed) + } +} diff --git a/src/app/models/BIP85Child.ts b/src/app/models/BIP85Child.ts new file mode 100644 index 00000000..7767e38b --- /dev/null +++ b/src/app/models/BIP85Child.ts @@ -0,0 +1,45 @@ +import { encode } from 'wif' +import { fromPrivateKey } from 'bip32' +import { entropyToMnemonic } from 'bip39' +import { BIP85_APPLICATIONS } from './BIP85' + +export class BIP85Child { + constructor(private readonly entropy: string, private readonly type: BIP85_APPLICATIONS) {} + + toEntropy(): string { + if (this.type === BIP85_APPLICATIONS.XPRV) { + return this.entropy.slice(64, 128) + } else { + return this.entropy + } + } + + toMnemonic(): string { + if (this.type !== BIP85_APPLICATIONS.BIP39) { + throw new Error('BIP85Child type is not BIP39') + } + + return entropyToMnemonic(this.entropy) + } + + toWIF(): string { + if (this.type !== BIP85_APPLICATIONS.WIF) { + throw new Error('BIP85Child type is not WIF') + } + + const buf = Buffer.from(this.entropy, 'hex') + + return encode(128, buf, true) + } + + toXPRV(): string { + if (this.type !== BIP85_APPLICATIONS.XPRV) { + throw new Error('BIP85Child type is not XPRV') + } + + const chainCode = Buffer.from(this.entropy.slice(0, 64), 'hex') + const privateKey = Buffer.from(this.entropy.slice(64, 128), 'hex') + + return fromPrivateKey(privateKey, chainCode).toBase58() + } +} diff --git a/src/app/pages/bip85-generate/bip85-generate-routing.module.ts b/src/app/pages/bip85-generate/bip85-generate-routing.module.ts new file mode 100644 index 00000000..00c6d30d --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +import { Bip85GeneratePage } from './bip85-generate.page' + +const routes: Routes = [ + { + path: '', + component: Bip85GeneratePage + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class Bip85GeneratePageRoutingModule {} diff --git a/src/app/pages/bip85-generate/bip85-generate.module.ts b/src/app/pages/bip85-generate/bip85-generate.module.ts new file mode 100644 index 00000000..4aa0ac7b --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' + +import { IonicModule } from '@ionic/angular' + +import { Bip85GeneratePageRoutingModule } from './bip85-generate-routing.module' + +import { Bip85GeneratePage } from './bip85-generate.page' +import { TranslateModule } from '@ngx-translate/core' + +@NgModule({ + imports: [CommonModule, FormsModule, IonicModule, Bip85GeneratePageRoutingModule, TranslateModule], + declarations: [Bip85GeneratePage] +}) +export class Bip85GeneratePageModule {} diff --git a/src/app/pages/bip85-generate/bip85-generate.page.html b/src/app/pages/bip85-generate/bip85-generate.page.html new file mode 100644 index 00000000..020ecc32 --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.page.html @@ -0,0 +1,64 @@ + + + + + + {{ 'bip85-generate.title' | translate }} + + + + +

{{ 'bip85-generate.text' | translate }}

+ + + + {{ 'bip85-generate.mnemonic-length' | translate }} + + 12 + 18 + 24 + + + + + {{ 'bip85-generate.index' | translate }} + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + + + + + {{ 'bip85-generate.advanced_label' | translate }} + + + + + + {{ 'bip85-generate.bip39-passphrase' | translate }} + + + + {{ 'bip85-generate.bip39-passphrase-reveal' | translate }} + + + + + + + {{ 'bip85-generate.generate' | translate }} + +
diff --git a/src/app/pages/bip85-generate/bip85-generate.page.scss b/src/app/pages/bip85-generate/bip85-generate.page.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/bip85-generate/bip85-generate.page.spec.ts b/src/app/pages/bip85-generate/bip85-generate.page.spec.ts new file mode 100644 index 00000000..c7451dca --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.page.spec.ts @@ -0,0 +1,24 @@ +// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +// import { IonicModule } from '@ionic/angular'; + +// import { Bip85GeneratePage } from './bip85-generate.page'; + +// describe('Bip85GeneratePage', () => { +// let component: Bip85GeneratePage; +// let fixture: ComponentFixture; + +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [ Bip85GeneratePage ], +// imports: [IonicModule.forRoot()] +// }).compileComponents(); + +// fixture = TestBed.createComponent(Bip85GeneratePage); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// })); + +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/src/app/pages/bip85-generate/bip85-generate.page.ts b/src/app/pages/bip85-generate/bip85-generate.page.ts new file mode 100644 index 00000000..d4b9b623 --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.page.ts @@ -0,0 +1,76 @@ +import { Component } from '@angular/core' +import { AlertController } from '@ionic/angular' +import { Secret } from 'src/app/models/secret' +import { ErrorCategory, handleErrorLocal } from 'src/app/services/error-handler/error-handler.service' +import { NavigationService } from 'src/app/services/navigation/navigation.service' + +@Component({ + selector: 'airgap-bip85-generate', + templateUrl: './bip85-generate.page.html', + styleUrls: ['./bip85-generate.page.scss'] +}) +export class Bip85GeneratePage { + public secret: Secret + + public mnemonicLength: '12' | '18' | '24' = '24' + + public index: string = '0' + + public isAdvancedMode: boolean = false + public revealBip39Passphrase: boolean = false + public bip39Passphrase: string = '' + + constructor(private readonly navigationService: NavigationService, private readonly alertController: AlertController) { + if (this.navigationService.getState()) { + this.secret = this.navigationService.getState().secret + console.log(this.secret) + } + } + + public async generateChildSeed() { + if (this.bip39Passphrase.length > 0) { + const alert = await this.alertController.create({ + header: 'BIP39 Passphrase', + message: 'You set a BIP39 Passphrase. You will need to enter this passphrase again when you try to derive the same child key!', + backdropDismiss: false, + inputs: [ + { + name: 'understood', + type: 'checkbox', + label: 'I understand', + value: 'understood', + checked: false + } + ], + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + { + text: 'Ok', + handler: async (result: string[]) => { + if (result.includes('understood')) { + this.navigateToNextPage() + } + } + } + ] + }) + alert.present() + } else { + this.navigateToNextPage() + } + } + + private async navigateToNextPage() { + this.navigationService + .routeWithState('/bip85-show', { + secret: this.secret, + bip39Passphrase: this.isAdvancedMode ? this.bip39Passphrase : '', + mnemonicLength: Number(this.mnemonicLength), + index: Number(this.index) + }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } +} diff --git a/src/app/pages/bip85-show/bip85-show-routing.module.ts b/src/app/pages/bip85-show/bip85-show-routing.module.ts new file mode 100644 index 00000000..3cf2292c --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +import { Bip85ShowPage } from './bip85-show.page' + +const routes: Routes = [ + { + path: '', + component: Bip85ShowPage + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class Bip85ShowPageRoutingModule {} diff --git a/src/app/pages/bip85-show/bip85-show.module.ts b/src/app/pages/bip85-show/bip85-show.module.ts new file mode 100644 index 00000000..4d4c585c --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' + +import { IonicModule } from '@ionic/angular' + +import { Bip85ShowPageRoutingModule } from './bip85-show-routing.module' + +import { Bip85ShowPage } from './bip85-show.page' +import { TranslateModule } from '@ngx-translate/core' + +@NgModule({ + imports: [CommonModule, FormsModule, IonicModule, Bip85ShowPageRoutingModule, TranslateModule], + declarations: [Bip85ShowPage] +}) +export class Bip85ShowPageModule {} diff --git a/src/app/pages/bip85-show/bip85-show.page.html b/src/app/pages/bip85-show/bip85-show.page.html new file mode 100644 index 00000000..2e896e0e --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.page.html @@ -0,0 +1,21 @@ + + + + + + {{ 'bip85-show.title' | translate }} + + + + +

{{ 'bip85-show.text' | translate }}

+

{{ 'bip85-show.mnemonic-length' | translate }}

+

{{mnemonicLength}}

+

{{ 'bip85-show.index' | translate }}

+

{{index}}

+
{{ childMnemonic }}
+ + + {{ 'bip85-show.validate' | translate }} + +
diff --git a/src/app/pages/bip85-show/bip85-show.page.scss b/src/app/pages/bip85-show/bip85-show.page.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/bip85-show/bip85-show.page.spec.ts b/src/app/pages/bip85-show/bip85-show.page.spec.ts new file mode 100644 index 00000000..7522b455 --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.page.spec.ts @@ -0,0 +1,24 @@ +// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +// import { IonicModule } from '@ionic/angular'; + +// import { Bip85ShowPage } from './bip85-show.page'; + +// describe('Bip85ShowPage', () => { +// let component: Bip85ShowPage; +// let fixture: ComponentFixture; + +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [ Bip85ShowPage ], +// imports: [IonicModule.forRoot()] +// }).compileComponents(); + +// fixture = TestBed.createComponent(Bip85ShowPage); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// })); + +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/src/app/pages/bip85-show/bip85-show.page.ts b/src/app/pages/bip85-show/bip85-show.page.ts new file mode 100644 index 00000000..617339a0 --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.page.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core' +import { BIP85 } from 'src/app/models/BIP85' +import { Secret } from 'src/app/models/secret' +import { DeviceService } from 'src/app/services/device/device.service' +import { ErrorCategory, handleErrorLocal } from 'src/app/services/error-handler/error-handler.service' +import { NavigationService } from 'src/app/services/navigation/navigation.service' +import { SecureStorage, SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' + +@Component({ + selector: 'airgap-bip85-show', + templateUrl: './bip85-show.page.html', + styleUrls: ['./bip85-show.page.scss'] +}) +export class Bip85ShowPage { + private secret: Secret + public mnemonicLength: 12 | 18 | 24 + public index: number + + public childMnemonic: string | undefined + + public bip39Passphrase: string = '' + + constructor( + private readonly deviceService: DeviceService, + private readonly navigationService: NavigationService, + private readonly secureStorageService: SecureStorageService + ) { + if (this.navigationService.getState()) { + this.secret = this.navigationService.getState().secret + this.mnemonicLength = this.navigationService.getState().mnemonicLength + this.index = this.navigationService.getState().index + this.bip39Passphrase = this.navigationService.getState().bip39Passphrase + + this.generateChildMnemonic(this.secret, this.mnemonicLength, this.index) + } + } + + public ionViewDidEnter(): void { + this.deviceService.enableScreenshotProtection({ routeBack: 'tab-settings' }) + } + + public ionViewWillLeave(): void { + this.deviceService.disableScreenshotProtection() + } + + public goToValidateSecret(): void { + this.navigationService + .routeWithState('bip85-validate', { + secret: this.secret, + bip39Passphrase: this.bip39Passphrase, + mnemonicLength: this.mnemonicLength, + index: this.index + }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } + + private async generateChildMnemonic(secret: Secret, length: 12 | 18 | 24, index: number) { + const secureStorage: SecureStorage = await this.secureStorageService.get(secret.id, secret.isParanoia) + + try { + const secretHex = await secureStorage.getItem(secret.id).then((result) => result.value) + + const masterSeed = BIP85.fromEntropy(secretHex, this.bip39Passphrase) + + const childEntropy = masterSeed.deriveBIP39(0, length, index) + + this.childMnemonic = childEntropy.toMnemonic() + } catch (error) { + throw error + } + } +} diff --git a/src/app/pages/bip85-validate/bip85-validate-routing.module.ts b/src/app/pages/bip85-validate/bip85-validate-routing.module.ts new file mode 100644 index 00000000..f732b21c --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +import { Bip85ValidatePage } from './bip85-validate.page' + +const routes: Routes = [ + { + path: '', + component: Bip85ValidatePage + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class Bip85ValidatePageRoutingModule {} diff --git a/src/app/pages/bip85-validate/bip85-validate.module.ts b/src/app/pages/bip85-validate/bip85-validate.module.ts new file mode 100644 index 00000000..41c456e3 --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' + +import { IonicModule } from '@ionic/angular' + +import { Bip85ValidatePageRoutingModule } from './bip85-validate-routing.module' + +import { Bip85ValidatePage } from './bip85-validate.page' +import { TranslateModule } from '@ngx-translate/core' +import { ComponentsModule } from 'src/app/components/components.module' + +@NgModule({ + imports: [CommonModule, FormsModule, IonicModule, Bip85ValidatePageRoutingModule, TranslateModule, ComponentsModule], + declarations: [Bip85ValidatePage] +}) +export class Bip85ValidatePageModule {} diff --git a/src/app/pages/bip85-validate/bip85-validate.page.html b/src/app/pages/bip85-validate/bip85-validate.page.html new file mode 100644 index 00000000..a3c66f1e --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.page.html @@ -0,0 +1,15 @@ + + + + + + {{ 'bip85-validate.title' | translate }} + + + + + +

{{ 'bip85-validate.text' | translate }}

+ +
+
diff --git a/src/app/pages/bip85-validate/bip85-validate.page.scss b/src/app/pages/bip85-validate/bip85-validate.page.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/bip85-validate/bip85-validate.page.spec.ts b/src/app/pages/bip85-validate/bip85-validate.page.spec.ts new file mode 100644 index 00000000..f148e476 --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.page.spec.ts @@ -0,0 +1,24 @@ +// import { async, ComponentFixture, TestBed } from '@angular/core/testing' +// import { IonicModule } from '@ionic/angular' + +// import { Bip85ValidatePage } from './bip85-validate.page' + +// describe('Bip85ValidatePage', () => { +// let component: Bip85ValidatePage +// let fixture: ComponentFixture + +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [Bip85ValidatePage], +// imports: [IonicModule.forRoot()] +// }).compileComponents() + +// fixture = TestBed.createComponent(Bip85ValidatePage) +// component = fixture.componentInstance +// fixture.detectChanges() +// })) + +// it('should create', () => { +// expect(component).toBeTruthy() +// }) +// }) diff --git a/src/app/pages/bip85-validate/bip85-validate.page.ts b/src/app/pages/bip85-validate/bip85-validate.page.ts new file mode 100644 index 00000000..8a2a9dca --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.page.ts @@ -0,0 +1,75 @@ +import { Component, ViewChild } from '@angular/core' +import { BIP85 } from 'src/app/models/BIP85' +import { VerifyKeyComponent } from 'src/app/components/verify-key/verify-key.component' +import { Secret } from 'src/app/models/secret' +import { DeviceService } from 'src/app/services/device/device.service' +import { ErrorCategory, handleErrorLocal } from 'src/app/services/error-handler/error-handler.service' +import { NavigationService } from 'src/app/services/navigation/navigation.service' +import { SecureStorage, SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' + +@Component({ + selector: 'airgap-bip85-validate', + templateUrl: './bip85-validate.page.html', + styleUrls: ['./bip85-validate.page.scss'] +}) +export class Bip85ValidatePage { + @ViewChild('verify', { static: true }) + public verify: VerifyKeyComponent + + public readonly secret: Secret + public mnemonicLength: 12 | 18 | 24 + public index: number + + public childMnemonic: string | undefined + + public bip39Passphrase: string = '' + + constructor( + private readonly deviceService: DeviceService, + private readonly navigationService: NavigationService, + private readonly secureStorageService: SecureStorageService + ) { + if (this.navigationService.getState()) { + this.secret = this.navigationService.getState().secret + this.mnemonicLength = this.navigationService.getState().mnemonicLength + this.index = this.navigationService.getState().index + this.bip39Passphrase = this.navigationService.getState().bip39Passphrase + + this.generateChildMnemonic(this.secret, this.mnemonicLength, this.index) + } + } + + public ionViewDidEnter(): void { + this.deviceService.enableScreenshotProtection({ routeBack: 'tab-settings' }) + } + + public ionViewWillLeave(): void { + this.deviceService.disableScreenshotProtection() + } + + public onContinue(): void { + this.goToSecretEditPage() + } + + public goToSecretEditPage(): void { + this.navigationService + .routeWithState('secret-edit', { secret: this.secret, isGenerating: false }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } + + private async generateChildMnemonic(secret: Secret, length: 12 | 18 | 24, index: number) { + const secureStorage: SecureStorage = await this.secureStorageService.get(secret.id, secret.isParanoia) + + try { + const secretHex = await secureStorage.getItem(secret.id).then((result) => result.value) + + const masterSeed = BIP85.fromEntropy(secretHex, this.bip39Passphrase) + + const childEntropy = masterSeed.deriveBIP39(0, length, index) + + this.childMnemonic = childEntropy.toMnemonic() + } catch (error) { + throw error + } + } +} diff --git a/src/app/pages/secret-edit/secret-edit.page.html b/src/app/pages/secret-edit/secret-edit.page.html index cbecdf0b..2a4c9eba 100644 --- a/src/app/pages/secret-edit/secret-edit.page.html +++ b/src/app/pages/secret-edit/secret-edit.page.html @@ -81,6 +81,19 @@

{{ 'secret-edit.social-recovery.label' | translate }}

+

{{ 'secret-edit.advanced' | translate }}

+ + +
+ +
+
+ +

{{ 'secret-edit.bip85.generate' | translate }}

+

+
+
+

{{ 'secret-edit.show-mnemonic.label' | translate }}

diff --git a/src/app/pages/secret-edit/secret-edit.page.ts b/src/app/pages/secret-edit/secret-edit.page.ts index 8e8b0ed9..f27711db 100644 --- a/src/app/pages/secret-edit/secret-edit.page.ts +++ b/src/app/pages/secret-edit/secret-edit.page.ts @@ -86,6 +86,12 @@ export class SecretEditPage { .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) } + public goToBip85ChildSeed(): void { + this.navigationService + .routeWithState('/bip85-generate', { secret: this.secret }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } + public async resetRecoveryPassword(): Promise { try { const recoveryKey = await this.secretsService.resetRecoveryPassword(this.secret) diff --git a/src/app/pages/secret-import/secret-import.page.html b/src/app/pages/secret-import/secret-import.page.html index 5e22558a..683d8cc1 100644 --- a/src/app/pages/secret-import/secret-import.page.html +++ b/src/app/pages/secret-import/secret-import.page.html @@ -1,5 +1,5 @@ - + diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 00b84ee4..6c2cf3ad 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -87,6 +87,7 @@ "title": " Your Secret", "text": "Give your secret a name and select the security level.", "secret_input_label": "Label of your secret", + "advanced": "Advanced Options", "security-level": { "heading": "Security Level", "text": "You can encrypt your secret additionally using a passcode." @@ -120,6 +121,10 @@ "copied": "Recovery key copied", "reset-error": "Could not set the recovery key" }, + "bip85": { + "generate": "Generate BIP85 Child Mnemonic", + "text": "Securely generate a child mnemonic out of your master mnemonic." + }, "show-mnemonic": { "label": "Show Mnemonic", "text": "Display the mnemonic associated with this secret.", @@ -133,6 +138,7 @@ }, "confirm_label": "Confirm" }, + "secret-edit-delete-popover": { "title": "Confirm Secret Removal", "text": "Do you really want to remove this secret? You won't be able to restore it without a backup!", @@ -406,6 +412,31 @@ "understood_label": "I understand and accept" } }, + + "bip85-generate": { + "title": "Generate BIP85", + "text": "BIP85 allows you to securely derive a new mnemonic out of your main mnemonic. As long as you have access to your main mnemonic, you will always be able to re-generate your child mnemonics.", + "mnemonic-length": "Mnemonic Length", + "index": "Index", + "generate": "Generate", + "advanced_label": "Advanced Mode", + "bip39-passphrase": "BIP-39 Passphrase", + "bip39-passphrase-reveal": "Reveal Passphrase" + }, + + "bip85-show": { + "title": "Show BIP85 Details", + "text": "Write down all the words on a piece of paper. You will have to verify the mnemonic on the next page.", + "mnemonic-length": "Mnemonic Length", + "index": "Index", + "validate": "Validate" + }, + + "bip85-validate": { + "title": "Verify BIP85", + "text": "Match the order of your recovery phrase by selecting the correct words." + }, + "message-signing-request": { "title": "Signed Message", "payload_label": "Message to sign. Make sure you know what you are signing.", diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 7c18a1de..fa93c27b 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -78,6 +78,9 @@ airgap-account-add, airgap-account-address, airgap-account-share, +airgap-bip85-generate, +airgap-bip85-show, +airgap-bip85-validate, airgap-secret-create, airgap-secret-generate, airgap-secret-import, @@ -98,6 +101,9 @@ airgap-secret-show, airgap-social-recovery-show-share, airgap-social-recovery-validate-share, airgap-current-secret, +airgap-bip85-generate, +airgap-bip85-show, +airgap-bip85-validate, airgap-local-authentication-onboarding, airgap-deserialized-detail { --ion-background-color: var(--ion-color-secondary);