import { Either , merged , ProtocolService } from '@airgap/angular-core' ; import { AirGapWallet , AirGapWalletStatus , ICoinProtocol , MainProtocolSymbols , ProtocolSymbols , SerializedAirGapWallet } from '@airgap/coinlib-core' ; import { Injectable } from '@angular/core' ; import { AlertController , LoadingController } from '@ionic/angular' ; import * as bip32 from 'bip32' ; import * as bip39 from 'bip39' ; import { Observable , ReplaySubject } from 'rxjs' ; import { MnemonicSecret } from '../../models/secret' ; import { ErrorCategory , handleErrorLocal } from '../error-handler/error-handler.service' ; import { NavigationService } from '../navigation/navigation.service' ; import { SecureStorage , SecureStorageService } from '../secure-storage/secure-storage.service' ; import { VaultStorageKey , VaultStorageService } from '../storage/storage.service' ; import * as bitcoinJS from 'bitcoinjs-lib' ; import * as bs58check from 'bs58check' ; class ExtendedPublicKey { private readonly rawKey : Buffer ; constructor ( extendedPublicKey ) { this . rawKey = bs58check . decode ( extendedPublicKey ) . slice ( 4 ) ; } toXpub ( ) { return this . addPrefix ( '0488b21e' ) ; } toYPub ( ) { return this . addPrefix ( '049d7cb2' ) ; } toZPub ( ) { return this . addPrefix ( '04b24746' ) ; } private addPrefix ( prefix ) { const data = Buffer . concat ( [ Buffer . from ( prefix , 'hex' ) , this . rawKey ] ) ; return bs58check . encode ( data ) ; } } interface AddWalletConifg { protocolIdentifier : ProtocolSymbols ; isHDWallet : boolean ; customDerivationPath : string ; bip39Passphrase : string ; isActive : boolean ; } @ Injectable ( { providedIn : 'root' } ) export class SecretsService { private readonly ready : Promise < void > ; private readonly secretsList : MnemonicSecret [ ] = [ ] ; private activeSecret : MnemonicSecret ; private readonly activeSecret$ : ReplaySubject < MnemonicSecret > = new ReplaySubject ( 1 ) ; private readonly secrets$ : ReplaySubject < MnemonicSecret [ ] > = new ReplaySubject ( 1 ) ; constructor ( private readonly secureStorageService , private readonly storageService , private readonly protocolService , private readonly navigationService , private readonly loadingCtrl , private readonly alertCtrl ) { this . ready = this . init ( ) ; } private async init ( ) { const secrets = await this . read ( ) ; this . secretsList . push ( ... secrets . map ( ( obj ) => MnemonicSecret . init ( obj ) ) ) ; this . secrets$ . next ( this . secretsList ) ; this . activeSecret = this . secretsList [ 0 ] ; this . activeSecret$ . next ( this . activeSecret ) ; } public isReady ( ) { return this . ready ; } private async read ( ) { const rawSecretsPayload = await this . storageService . get ( VaultStorageKey . AIRGAP_SECRET_LIST ) ; let secrets = typeof rawSecretsPayload === 'string' ? JSON . parse ( rawSecretsPayload ) : rawSecretsPayload ; if ( ! secrets ) { secrets = [ ] ; } for ( let k = 0 ; k < secrets . length ; k ++ ) { const secret = secrets [ k ] ; if ( secret . wallets ) { const serializedWallets = secret . wallets as unknown as SerializedAirGapWallet [ ] ; const wallets = ( await Promise . all ( serializedWallets . map ( async ( serializedWallet ) => { const protocol = await this . protocolService . getProtocol ( serializedWallet . protocolIdentifier ) . catch ( ( error ) => { console . error ( error ) ; return undefined ; } ) ; if ( protocol === undefined ) { return undefined ; } const airGapWallet = new AirGapWallet ( protocol , serializedWallet . publicKey , serializedWallet . isExtendedPublicKey , serializedWallet . derivationPath , serializedWallet . masterFingerprint ?? '' , serializedWallet . status ?? AirGapWalletStatus . ACTIVE ) ; airGapWallet . addresses = serializedWallet . addresses ; return airGapWallet ; } ) ) ) . filter ( ( wallet ) => wallet !== undefined ) ; secrets [ k ] . wallets = wallets ; } else { secrets [ k ] . wallets = [ ] ; } } return secrets ; } public async addOrUpdateSecret ( secret , options = { setActive : true } ) { if ( ! secret . wallets ) { secret . wallets = [ ] ; } if ( ! secret . secretHex ) { this . secretsList [ this . secretsList . findIndex ( ( item ) => item . id === secret . id ) ] = secret ; if ( options . setActive ) { this . setActiveSecret ( secret ) ; } return this . persist ( ) ; } else { const secureStorage = await this . secureStorageService . get ( secret . id , secret . isParanoia ) ; await secureStorage . setItem ( secret . id , secret . secretHex ) ; secret . flushSecret ( ) ; if ( this . secretsList . findIndex ( ( item ) => item . id === secret . id ) === - 1 ) { this . secretsList . push ( secret ) ; this . secrets$ . next ( this . secretsList ) ; } if ( options . setActive ) { this . setActiveSecret ( secret ) ; } return this . persist ( ) ; } } public async remove ( secret ) { const secureStorage = await this . secureStorageService . get ( secret . id , secret . isParanoia ) ; await secureStorage . removeItem ( secret . id ) ; this . secretsList . splice ( this . secretsList . indexOf ( secret ) , 1 ) ; this . secrets$ . next ( this . secretsList ) ; if ( this . activeSecret === secret ) { this . setActiveSecret ( this . secretsList [ 0 ] ) ; } await this . persist ( ) ; } public async resetRecoveryPassword ( secret ) { const secureStorage = await this . secureStorageService . get ( secret . id , secret . isParanoia ) ; try { const secretHex = await secureStorage . getItem ( secret . id ) . then ( ( result ) => result . value ) ; return secureStorage . setupRecoveryPassword ( secret . id , secretHex ) . then ( ( result ) => { secret . hasRecoveryKey = true ; this . addOrUpdateSecret ( secret ) ; return result . recoveryKey ; } ) ; } catch ( error ) { if ( error . message . startsWith ( 'Could not read from the secure storage.' ) ) { this . handleCorruptedSecret ( error ) ; } throw error ; } } public async retrieveEntropyForSecret ( secret ) { const secureStorage = await this . secureStorageService . get ( secret . id , secret . isParanoia ) ; return secureStorage . getItem ( secret . id ) . then ( ( result ) => { return result . value ; } ) . catch ( ( error ) => { if ( error . message . startsWith ( 'Could not read from the secure storage.' ) ) { this . handleCorruptedSecret ( error ) ; } throw error ; } ) ; } public findByFingerprint ( fingerprint ) { for ( const secret of this . secretsList ) { const foundWallet = secret . wallets . find ( ( wallet ) => wallet . masterFingerprint === fingerprint ) ; if ( foundWallet !== undefined ) { return secret ; } } return undefined ; } public findByPublicKey ( pubKey ) { for ( const secret of this . secretsList ) { const foundWallet = secret . wallets . find ( ( wallet ) => wallet . publicKey === pubKey ) ; if ( foundWallet !== undefined ) { return secret ; } } return undefined ; } public getWallets ( ) { const walletList = [ ] ; for ( const secret of this . secretsList ) { walletList . push ( ... secret . wallets ) ; } return walletList ; } public async removeWallet ( wallet ) { const secret = this . findByPublicKey ( wallet . publicKey ) ; if ( ! secret ) { return undefined ; } wallet . status = AirGapWalletStatus . DELETED ; return this . addOrUpdateSecret ( secret ) ; } public findWalletByPublicKeyAndProtocolIdentifier ( pubKey , protocolIdentifier ) { const secret = this . findByPublicKey ( pubKey ) ; if ( ! secret ) { return undefined ; } const foundWallet = secret . wallets . find ( ( wallet ) => wallet . publicKey === pubKey && wallet . protocol . identifier === protocolIdentifier ) ; return foundWallet ; } public findWalletByFingerprintDerivationPathAndProtocolIdentifier ( fingerprint , protocolIdentifier , derivationPath , publicKey ) { const secret = this . findByFingerprint ( fingerprint ) ; if ( ! secret ) { return undefined ; } const foundWallet = secret . wallets . find ( ( wallet ) => { const match = wallet . masterFingerprint === fingerprint && wallet . protocol . identifier === protocolIdentifier ; if ( match ) { if ( ! derivationPath . startsWith ( wallet . derivationPath ) ) { return false ; } const bip32PK = bitcoinJS . bip32 . fromBase58 ( new ExtendedPublicKey ( wallet . publicKey ) . toXpub ( ) ) ; const cutoffFrom = derivationPath . lastIndexOf ( "'" ) || derivationPath . lastIndexOf ( 'h' ) ; const childPath = derivationPath . substr ( cutoffFrom + 2 ) ; const walletPublicKey = bip32PK . derivePath ( childPath ) . publicKey ; return publicKey . equals ( walletPublicKey ) ; } return false ; } ) ; return foundWallet ; } param sourceFingerprint param protocolIdentifier param derivationPath returns public findWalletByXPubFingerprintDerivationPathAndProtocolIdentifier ( sourceFingerprint , protocolIdentifier , derivationPath ) { const allWallets = this . secretsList . reduce ( ( pv , cv ) => pv . concat ( cv . wallets ) , [ ] as AirGapWallet [ ] ) . filter ( ( wallet ) => wallet . isExtendedPublicKey && wallet . protocol . identifier === protocolIdentifier && `m/${ derivationPath }` . startsWith ( wallet . derivationPath ) ) ; const foundWallet = allWallets . find ( ( wallet ) => { const bip32PK = bitcoinJS . bip32 . fromBase58 ( new ExtendedPublicKey ( wallet . publicKey ) . toXpub ( ) ) ; return bip32PK . fingerprint . toString ( 'hex' ) === sourceFingerprint ; } ) ; return foundWallet ; } public findBaseWalletByPublicKeyAndProtocolIdentifier ( pubKey , protocolIdentifier ) { const secret = this . findByPublicKey ( pubKey ) ; if ( ! secret ) { return undefined ; } return secret . wallets . find ( ( wallet ) => wallet . publicKey === pubKey && protocolIdentifier . startsWith ( wallet . protocol . identifier ) ) ; } public getActiveSecret ( ) { return this . activeSecret || this . secretsList [ 0 ] ; } public setActiveSecret ( secret ) { this . activeSecret = secret ; this . activeSecret$ . next ( secret ) ; } public getActiveSecretObservable ( ) { return this . activeSecret$ . asObservable ( ) ; } public getSecretsObservable ( ) { return this . secrets$ . asObservable ( ) ; } private async persist ( ) { this . secretsList . forEach ( ( secret ) => { secret . flushSecret ( ) ; } ) ; const rawSecretsPayload = await this . storageService . get ( VaultStorageKey . AIRGAP_SECRET_LIST ) ; const storedSecrets = typeof rawSecretsPayload === 'string' ? JSON . parse ( rawSecretsPayload ) : rawSecretsPayload ; const secrets = this . secretsList . map ( ( secret ) => { const storedSecret = storedSecrets . find ( ( storedSecret ) => storedSecret . id === secret . getIdentifier ( ) ) ; if ( storedSecret === undefined ) { return secret ; } const wallets = secret . wallets . slice ( 0 ) ; for ( let i = 0 ; i < storedSecret . wallets . length ; ++ i ) { const serializedWallet = storedSecret . wallets [ i ] as unknown as SerializedAirGapWallet ; const found = wallets . find ( ( wallet ) => isAirGapWallet ( wallet ) && wallet . protocol . identifier === serializedWallet . protocolIdentifier && wallet . publicKey === serializedWallet . publicKey ) ; if ( found === undefined ) { wallets . push ( serializedWallet ) ; } } const result = MnemonicSecret . init ( secret ) ; result . wallets = wallets as unknown as AirGapWallet [ ] ; return result ; } ) ; return this . storageService . set ( VaultStorageKey . AIRGAP_SECRET_LIST , secrets ) ; } public async updateWallet ( wallet ) { const secret = this . findByPublicKey ( wallet . publicKey ) ; if ( secret === undefined ) { return ; } await this . addOrUpdateSecret ( secret ) ; } public async addWallets ( configs ) { const loading = await this . loadingCtrl . create ( { message : 'Deriving your wallet...' } ) ; loading . present ( ) . catch ( handleErrorLocal ( ErrorCategory . IONIC_LOADER ) ) ; try { const secret = this . getActiveSecret ( ) ; const entropy = await this . retrieveEntropyForSecret ( secret ) ; const createdOrUpdated = ( await Promise . all ( configs . map ( ( config ) => this . activateOrCreateWallet ( entropy , config ) ) ) ) . filter ( ( createdOrUpdated ) => createdOrUpdated !== undefined ) ; const [ createdWallets , updatedWallets ] = merged ( createdOrUpdated ) ; if ( createdWallets . length > 0 || updatedWallets . length > 0 ) { secret . wallets . push ( ... createdWallets ) ; await this . addOrUpdateSecret ( secret ) ; } loading . dismiss ( ) . catch ( handleErrorLocal ( ErrorCategory . IONIC_LOADER ) ) ; } catch ( error ) { loading . dismiss ( ) . catch ( handleErrorLocal ( ErrorCategory . IONIC_LOADER ) ) ; let header ; let message ; if ( error . message ?. toLowerCase ( ) . startsWith ( 'Expected BIP32 derivation path' ) ) { message = 'Expected BIP32 derivation path, got invalid string' ; } else if ( error . message ?. toLowerCase ( ) . startsWith ( 'wallet already exists' ) ) { header = 'Wallet already exists' ; message = 'You already have added this specific wallet. Please change its derivation path to add another address (advanced mode).' ; } if ( message ) { this . showAlert ( header ?? 'Error' , message ) . catch ( handleErrorLocal ( ErrorCategory . IONIC_ALERT ) ) ; } throw error ; } } private async activateOrCreateWallet ( entropy , config ) { const newWallet = await this . createNewWallet ( entropy , config ) ; const existingWallet = this . findWalletByPublicKeyAndProtocolIdentifier ( newWallet . publicKey , newWallet . protocol . identifier ) ; if ( existingWallet === undefined ) { return [ newWallet , undefined ] ; } else if ( newWallet . status === AirGapWalletStatus . ACTIVE && existingWallet . status !== AirGapWalletStatus . ACTIVE ) { existingWallet . status = AirGapWalletStatus . ACTIVE ; return [ undefined , existingWallet ] ; } else if ( newWallet . status === AirGapWalletStatus . ACTIVE && existingWallet . status === AirGapWalletStatus . ACTIVE ) { throw new Error ( 'Wallet already exists' ) ; } else { return undefined ; } } private async createNewWallet ( entropy , config ) { const protocol = await this . protocolService . getProtocol ( config . protocolIdentifier ) ; const mnemonic = bip39 . entropyToMnemonic ( entropy ) ; const seed = await bip39 . mnemonicToSeed ( mnemonic , config . bip39Passphrase ) ; const bip32Node = bip32 . fromSeed ( seed ) ; const publicKey = config . isHDWallet && config . protocolIdentifier === 'eth' ? await ( protocol as any ) . getExtendedPublicKeyFromMnemonic ( mnemonic , config . customDerivationPath , config . bip39Passphrase ) : await protocol . getPublicKeyFromMnemonic ( mnemonic , config . customDerivationPath , config . bip39Passphrase ) ; const fingerprint = bip32Node . fingerprint . toString ( 'hex' ) ; const wallet = new AirGapWallet ( protocol , publicKey , config . isHDWallet , config . customDerivationPath , fingerprint , config . isActive ? AirGapWalletStatus . ACTIVE : AirGapWalletStatus . HIDDEN ) ; const addresses = await wallet . deriveAddresses ( 1 ) ; wallet . addresses = addresses ; return wallet ; } public getKnownViewingKeys ( ) { return this . getWallets ( ) . filter ( ( wallet ) => wallet . protocol . identifier === MainProtocolSymbols . XTZ_SHIELDED ) . map ( ( wallet ) => wallet . publicKey ) ; } public async showAlert ( title , message ) { const alert = await this . alertCtrl . create ( { header : title , message , backdropDismiss : false , buttons : [ { text : 'Okay!' , role : 'cancel' } ] } ) ; alert . present ( ) . catch ( handleErrorLocal ( ErrorCategory . IONIC_ALERT ) ) ; } private async handleCorruptedSecret ( error ) { error . message += ' Please, re-import your secret.' ; error . ignore = true ; await this . showAlert ( 'Error' , error . message ) ; await this . navigationService . routeToAccountsTab ( true ) ; } } function isAirGapWallet ( value ) { return ( value as any ) . protocol !== undefined ; }