diff --git a/package-lock.json b/package-lock.json index 8c297fe4..1582c3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "@ethereumjs/vm": "^5.9.0", "@metamask/post-message-stream": "^6.1.2", "@metamask/providers": "^11.1.1", + "@noble/ciphers": "^0.3.0", + "@noble/secp256k1": "^1.7.1", "@types/lodash": "^4.14.182", "@types/sha256": "^0.2.0", "@types/sprintf-js": "^1.1.2", @@ -5412,6 +5414,14 @@ "semver": "bin/semver.js" } }, + "node_modules/@noble/ciphers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.3.0.tgz", + "integrity": "sha512-ldbrnOjmNRwFdXcTM6uXDcxpMIFrbzAWNnpBPp4oTJTFF0XByGD6vf45WrehZGXRQTRVV+Zm8YP+EgEf+e4cWA==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz", @@ -5448,6 +5458,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/secp256k1": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -23220,6 +23241,11 @@ "integrity": "sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg==", "dev": true }, + "@noble/ciphers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.3.0.tgz", + "integrity": "sha512-ldbrnOjmNRwFdXcTM6uXDcxpMIFrbzAWNnpBPp4oTJTFF0XByGD6vf45WrehZGXRQTRVV+Zm8YP+EgEf+e4cWA==" + }, "@noble/curves": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz", @@ -23240,6 +23266,11 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==" }, + "@noble/secp256k1": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index b7980733..6f4d7260 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "@ethereumjs/vm": "^5.9.0", "@metamask/post-message-stream": "^6.1.2", "@metamask/providers": "^11.1.1", + "@noble/ciphers": "^0.3.0", + "@noble/secp256k1": "^1.7.1", "@types/lodash": "^4.14.182", "@types/sha256": "^0.2.0", "@types/sprintf-js": "^1.1.2", diff --git a/src/app/account.service.ts b/src/app/account.service.ts index c62b4492..150516f8 100644 --- a/src/app/account.service.ts +++ b/src/app/account.service.ts @@ -5,8 +5,8 @@ import { ec as EC } from 'elliptic'; import HDKey from 'hdkey'; import * as jsonwebtoken from 'jsonwebtoken'; import KeyEncoder from 'key-encoder'; -import { CookieService } from 'ngx-cookie'; import sha256 from 'sha256'; +import { generateAccountNumber } from '../lib/account-number'; import { uint64ToBufBigEndian } from '../lib/bindata/util'; import { Transaction, @@ -24,6 +24,7 @@ import { PrivateUserInfo, PrivateUserVersion, PublicUserInfo, + SubAccountMetadata, } from '../types/identity'; import { BackendAPIService, @@ -39,6 +40,35 @@ import { SigningService } from './signing.service'; export const ERROR_USER_NOT_FOUND = 'User not found'; +/** + * The key used to store the sub-account reverse lookup map in local storage. + * This map is used to look up the account number for a sub-account given the + * public key. Application developers provide the "owner" public key in certain + * scenarios (generating derived keys, for example), and we need to be able to + * look up the account number for that public key in order to generate the + * private key for signing. The structure of the map is: + * + * ```json + * { + * "subAccountPublicKey": { + * "lookupKey": "rootPublicKey", + * "accountNumber": 1 + * } + * } + * ``` + * + * For historical reasons, the "lookupKey" is the root public key, which is the + * sub-account generated for account number 0. This is the "root" account, and + * is used to store the common data for all accounts in a particular account + * group, including its mnemonic and all its sub-account account numbers. + */ +const SUB_ACCOUNT_REVERSE_LOOKUP_KEY = 'subAccountReverseLookup'; + +export interface SubAccountReversLookupEntry { + lookupKey: string; + accountNumber: number; +} + @Injectable({ providedIn: 'root', }) @@ -51,37 +81,138 @@ export class AccountService { constructor( private cryptoService: CryptoService, private globalVars: GlobalVarsService, - private cookieService: CookieService, private entropyService: EntropyService, private signingService: SigningService, private metamaskService: MetamaskService - ) {} + ) { + /** + * We rebuild the sub-account reverse lookup map on every page load. This is + * to ensure there are no stale or missing entries in the map. The number of + * users in local storage is generally small, so this should not be a + * performance issue. If it does become a performance issue, we can consider + * a more sophisticated approach, but the number of users would need to be + * on the order of hundreds or thousands (very unlikely, and maybe literally + * impossible) before this would be a problem. + */ + this.initializeSubAccountReverseLookup(); + } // Public Getters - getPublicKeys(): any { - return Object.keys(this.getPrivateUsers()); + getPublicKeys(): string[] { + const publicKeys: string[] = []; + const rootUsers = this.getRootLevelUsers(); + + Object.keys(rootUsers).forEach((publicKey) => { + publicKeys.push(publicKey); + const subAccounts = rootUsers[publicKey].subAccounts || []; + subAccounts.forEach((subAccount) => { + publicKeys.push( + this.getAccountPublicKeyBase58(publicKey, subAccount.accountNumber) + ); + }); + }); + + return publicKeys; + } + + getAccountInfo(publicKey: string): PrivateUserInfo & SubAccountMetadata { + const privateUsers = this.getRootLevelUsers(); + let info = null; + + if (publicKey in privateUsers) { + info = { + ...privateUsers[publicKey], + // If the user is in the top level users map, their keys were generated + // with account number 0. This is the "root/parent" account. + accountNumber: 0, + }; + } + + // If the user is not found at the top level, it should be a sub account public key. + const lookup = this.getSubAccountReverseLookupMap(); + const mapping = lookup[publicKey]; + + if (mapping) { + const rootUser = privateUsers[mapping.lookupKey]; + + const foundAccount = rootUser.subAccounts?.find( + (a) => a.accountNumber === mapping.accountNumber + ); + + if (foundAccount) { + const keychain = this.cryptoService.mnemonicToKeychain( + rootUser.mnemonic, + { + extraText: rootUser.extraText, + accountNumber: foundAccount.accountNumber, + } + ); + const subAccountSeedHex = + this.cryptoService.keychainToSeedHex(keychain); + info = { + ...rootUser, + ...foundAccount, + seedHex: subAccountSeedHex, + }; + } + } + + if (info === null) { + throw new Error(`No user found for public key ${publicKey}`); + } + + return info; + } + + getSubAccountReverseLookupMap(): { + [subAccountKey: string]: SubAccountReversLookupEntry | undefined; + } { + const json = window.localStorage.getItem(SUB_ACCOUNT_REVERSE_LOOKUP_KEY); + return json ? JSON.parse(json) : {}; + } + + /** + * Add the sub-account public key to a reverse lookup map. We'll need + * this to look up the account number and the seed from the public key. + */ + private updateSubAccountReverseLookupMap({ + lookupKey, + accountNumber, + }: SubAccountReversLookupEntry) { + const keyMap = this.getSubAccountReverseLookupMap(); + const subAccountPublicKey = this.getAccountPublicKeyBase58( + lookupKey, + accountNumber + ); + keyMap[subAccountPublicKey] = {lookupKey, accountNumber}; + + window.localStorage.setItem( + SUB_ACCOUNT_REVERSE_LOOKUP_KEY, + JSON.stringify(keyMap) + ); } - getEncryptedUsers(): { [key: string]: PublicUserInfo } { + async getEncryptedUsers(): Promise<{ [key: string]: PublicUserInfo }> { const hostname = this.globalVars.hostname; - const privateUsers = this.getPrivateUsers(); + const rootUsers = this.getRootLevelUsers(); const publicUsers: { [key: string]: PublicUserInfo } = {}; - for (const publicKey of Object.keys(privateUsers)) { - const privateUser = privateUsers[publicKey]; - const accessLevel = this.getAccessLevel(publicKey, hostname); + for (const rootPublicKey of Object.keys(rootUsers)) { + const privateUser = rootUsers[rootPublicKey]; + + const accessLevel = this.getAccessLevel(rootPublicKey, hostname); if (accessLevel === AccessLevel.None) { continue; } - const encryptedSeedHex = this.cryptoService.encryptSeedHex( + const encryptedSeedHex = await this.cryptoService.encryptSeedHex( privateUser.seedHex, hostname ); let encryptedMessagingKeyRandomness: string | undefined; if (privateUser.messagingKeyRandomness) { - encryptedMessagingKeyRandomness = this.cryptoService.encryptSeedHex( + encryptedMessagingKeyRandomness = await this.cryptoService.encryptSeedHex( privateUser.messagingKeyRandomness, hostname ); @@ -91,19 +222,50 @@ export class AccountService { privateUser.seedHex ); - publicUsers[publicKey] = { + const commonFields = { hasExtraText: privateUser.extraText?.length > 0, btcDepositAddress: privateUser.btcDepositAddress, ethDepositAddress: privateUser.ethDepositAddress, version: privateUser.version, - encryptedSeedHex, network: privateUser.network, loginMethod: privateUser.loginMethod || LoginMethod.DESO, accessLevel, + }; + + publicUsers[rootPublicKey] = { + ...commonFields, + encryptedSeedHex, accessLevelHmac, derivedPublicKeyBase58Check: privateUser.derivedPublicKeyBase58Check, encryptedMessagingKeyRandomness, }; + + // To support sub-accounts for the legacy identity flow, we need to return + // a flat map of all users and their sub-accounts. Each sub-account has a + // unique seed hex that can be used for signing transactions, as well as a + // unique accessLevel hmac. + const subAccounts = privateUser.subAccounts || []; + for (const subAccount of subAccounts) { + const subAccountPublicKey = this.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + const accountInfo = this.getAccountInfo(subAccountPublicKey); + const subAccountEncryptedSeedHex = await this.cryptoService.encryptSeedHex( + accountInfo.seedHex, + hostname + ); + const subAccountAccessLevelHmac = this.cryptoService.accessLevelHmac( + accessLevel, + accountInfo.seedHex + ); + + publicUsers[subAccountPublicKey] = { + ...commonFields, + encryptedSeedHex: subAccountEncryptedSeedHex, + accessLevelHmac: subAccountAccessLevelHmac, + }; + } } return publicUsers; @@ -115,14 +277,8 @@ export class AccountService { } requiresMessagingKeyRandomness(publicKey: string): boolean { - const privateUser = this.getPrivateUsers()[publicKey]; - if (!privateUser) { - console.error('private user not found'); - throw new Error('private user not found'); - } - return ( - this.isMetamaskAccount(privateUser) && !privateUser.messagingKeyRandomness - ); + const account = this.getAccountInfo(publicKey); + return this.isMetamaskAccount(account) && !account.messagingKeyRandomness; } getAccessLevel(publicKey: string, hostname: string): AccessLevel { @@ -153,13 +309,9 @@ export class AccountService { derivedPublicKeyBase58CheckInput?: string, expirationDays?: number ): Promise { - if (!(publicKeyBase58Check in this.getPrivateUsers())) { - return undefined; - } - - const privateUser = this.getPrivateUsers()[publicKeyBase58Check]; - const network = privateUser.network; - const isMetamask = this.isMetamaskAccount(privateUser); + const account = this.getAccountInfo(publicKeyBase58Check); + const network = account.network; + const isMetamask = this.isMetamaskAccount(account); let derivedSeedHex = ''; let derivedPublicKeyBuffer: number[]; @@ -167,7 +319,7 @@ export class AccountService { let jwt = ''; let derivedJwt = ''; const numDaysBeforeExpiration = expirationDays || 30; - + const options = {expiration: `${numDaysBeforeExpiration} days`}; if (!derivedPublicKeyBase58CheckInput) { const derivedKeyData = this.generateDerivedKey(network); derivedPublicKeyBase58Check = derivedKeyData.derivedPublicKeyBase58Check; @@ -179,11 +331,7 @@ export class AccountService { .encode('array', true); // Derived keys JWT with the same expiration as the derived key. This is needed for some backend endpoints. - derivedJwt = this.signingService.signJWT( - derivedSeedHex, - true, - `${numDaysBeforeExpiration} days` - ); + derivedJwt = this.signingService.signJWT(derivedSeedHex, true, options); } else { // If the user has passed in a derived public key, use that instead. // Don't define the derived seed hex (a private key presumably already exists). @@ -195,11 +343,7 @@ export class AccountService { } // Compute the owner-signed JWT with the same expiration as the derived key. This is needed for some backend endpoints. // In case of the metamask log-in, jwt will be signed by a derived key. - jwt = this.signingService.signJWT( - privateUser.seedHex, - isMetamask, - `${numDaysBeforeExpiration} days` - ); + jwt = this.signingService.signJWT(account.seedHex, isMetamask, options); // Generate new btc and eth deposit addresses for the derived key. // const btcDepositAddress = this.cryptoService.keychainToBtcAddress(derivedKeychain, network); @@ -282,7 +426,7 @@ export class AccountService { } await this.metamaskService.connectWallet(); } - const { signature } = + const {signature} = await this.metamaskService.signMessageWithMetamaskAndGetEthAddress( accessBytesHex ); @@ -295,7 +439,7 @@ export class AccountService { ); } } else { - accessSignature = this.signingService.signHashes(privateUser.seedHex, [ + accessSignature = this.signingService.signHashes(account.seedHex, [ accessHash, ])[0]; } @@ -329,7 +473,7 @@ export class AccountService { } getDefaultKeyPrivateUser(publicKey: string, appPublicKey: string): any { - const privateUser = this.getPrivateUsers()[publicKey]; + const privateUser = this.getRootLevelUsers()[publicKey]; const network = privateUser.network; // create jwt with private key and app public key const keyEncoder = new KeyEncoder('secp256k1'); @@ -338,7 +482,7 @@ export class AccountService { 'raw', 'pem' ); - const jwt = jsonwebtoken.sign({ appPublicKey }, encodedPrivateKey, { + const jwt = jsonwebtoken.sign({appPublicKey}, encodedPrivateKey, { algorithm: 'ES256', expiresIn: '30 minutes', }); @@ -435,10 +579,16 @@ export class AccountService { mnemonic: string, extraText: string, network: Network, - google?: boolean + { + lastLoginTimestamp, + loginMethod = LoginMethod.DESO, + }: { + lastLoginTimestamp?: number; + loginMethod?: LoginMethod; + } = {} ): string { const seedHex = this.cryptoService.keychainToSeedHex(keychain); - const keyPair = this.cryptoService.seedHexToPrivateKey(seedHex); + const keyPair = this.cryptoService.seedHexToKeyPair(seedHex); const btcDepositAddress = this.cryptoService.keychainToBtcAddress( // @ts-ignore TODO: add "identifier" to type definition keychain.identifier, @@ -446,11 +596,6 @@ export class AccountService { ); const ethDepositAddress = this.cryptoService.publicKeyToEthAddress(keyPair); - let loginMethod: LoginMethod = LoginMethod.DESO; - if (google) { - loginMethod = LoginMethod.GOOGLE; - } - return this.addPrivateUser({ seedHex, mnemonic, @@ -460,11 +605,12 @@ export class AccountService { network, loginMethod, version: PrivateUserVersion.V2, + ...(lastLoginTimestamp && {lastLoginTimestamp}), }); } addUserWithSeedHex(seedHex: string, network: Network): string { - const keyPair = this.cryptoService.seedHexToPrivateKey(seedHex); + const keyPair = this.cryptoService.seedHexToKeyPair(seedHex); const helperKeychain = new HDKey(); helperKeychain.privateKey = Buffer.from(seedHex, 'hex'); // @ts-ignore TODO: add "identifier" to type definition @@ -556,7 +702,7 @@ export class AccountService { // Migrate from V0 -> V1 if (privateUser.version === PrivateUserVersion.V0) { // Add ethDepositAddress field - const keyPair = this.cryptoService.seedHexToPrivateKey( + const keyPair = this.cryptoService.seedHexToKeyPair( privateUser.seedHex ); privateUser.ethDepositAddress = @@ -589,12 +735,8 @@ export class AccountService { ownerPublicKeyBase58Check: string, publicKey: string ): string { - const privateUsers = this.getPrivateUsers(); - if (!(ownerPublicKeyBase58Check in privateUsers)) { - return ''; - } - const seedHex = privateUsers[ownerPublicKeyBase58Check].seedHex; - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const account = this.getAccountInfo(ownerPublicKeyBase58Check); + const privateKey = this.cryptoService.seedHexToKeyPair(account.seedHex); const privateKeyBytes = privateKey.getPrivate().toBuffer(undefined, 32); const publicKeyBytes = this.cryptoService.publicKeyToECBuffer(publicKey); const sharedPx = ecies.derive(privateKeyBytes, publicKeyBytes); @@ -606,17 +748,12 @@ export class AccountService { ownerPublicKeyBase58Check: string, messagingKeyName: string ): Promise { - const privateUsers = this.getPrivateUsers(); - if (!(ownerPublicKeyBase58Check in privateUsers)) { - throw new Error(ERROR_USER_NOT_FOUND); - } - const privateUser = privateUsers[ownerPublicKeyBase58Check]; - const seedHex = privateUser.seedHex; + const account = this.getAccountInfo(ownerPublicKeyBase58Check); // Compute messaging private key as sha256x2( sha256x2(secret key) || sha256x2(messageKeyname) ) let messagingPrivateKeyBuff; try { messagingPrivateKeyBuff = await this.getMessagingKey( - privateUser, + account, messagingKeyName ); } catch (e) { @@ -644,7 +781,7 @@ export class AccountService { let messagingKeySignature = ''; if (messagingKeyName === this.globalVars.defaultMessageKeyName) { - messagingKeySignature = this.signingService.signHashes(seedHex, [ + messagingKeySignature = this.signingService.signHashes(account.seedHex, [ messagingKeyHash, ])[0]; } @@ -693,7 +830,7 @@ export class AccountService { ); } try { - const { message, signature, publicEthAddress } = + const {message, signature, publicEthAddress} = await this.metamaskService.signMessageWithMetamaskAndGetEthAddress( randomnessString ); @@ -740,9 +877,9 @@ export class AccountService { senderGroupKeyName: string, recipientPublicKey: string, message: string, - messagingKeyRandomness: string | undefined + messagingKeyRandomness?: string ): any { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex); const privateKeyBuffer = privateKey.getPrivate().toBuffer(undefined, 32); const publicKeyBuffer = @@ -775,13 +912,13 @@ export class AccountService { seedHex: string, encryptedHexes: any ): { [key: string]: any } { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex); const privateKeyBuffer = privateKey.getPrivate().toBuffer(undefined, 32); const decryptedHexes: { [key: string]: any } = {}; for (const encryptedHex of encryptedHexes) { const encryptedBytes = new Buffer(encryptedHex, 'hex'); - const opts = { legacy: true }; + const opts = {legacy: true}; try { decryptedHexes[encryptedHex] = ecies .decrypt(privateKeyBuffer, encryptedBytes, opts) @@ -798,10 +935,10 @@ export class AccountService { seedHex: string, encryptedMessages: EncryptedMessage[], messagingGroups: MessagingGroup[], - messagingKeyRandomness: string | undefined, - ownerPublicKeyBase58Check: string | undefined + messagingKeyRandomness?: string, + ownerPublicKeyBase58Check?: string ): Promise<{ [key: string]: any }> { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex); const myPublicKey = ownerPublicKeyBase58Check || @@ -820,7 +957,7 @@ export class AccountService { // If message was encrypted using public key, check the sender to determine if message is decryptable. try { if (!encryptedMessage.IsSender) { - const opts = { legacy: true }; + const opts = {legacy: true}; decryptedHexes[encryptedMessage.EncryptedHex] = ecies .decrypt(privateKeyBuffer, encryptedBytes, opts) .toString(); @@ -953,12 +1090,9 @@ export class AccountService { return decryptedHexes; } - // Private Getters and Modifiers - - // TEMP: public for import flow - public addPrivateUser(userInfo: PrivateUserInfo): string { + addPrivateUser(userInfo: PrivateUserInfo): string { const privateUsers = this.getPrivateUsersRaw(); - const privateKey = this.cryptoService.seedHexToPrivateKey(userInfo.seedHex); + const privateKey = this.cryptoService.seedHexToKeyPair(userInfo.seedHex); // Metamask login will be added with the master public key. let publicKey = this.cryptoService.privateKeyToDeSoPublicKey( @@ -981,7 +1115,7 @@ export class AccountService { if ( privateUsers[publicKey]?.derivedPublicKeyBase58Check && privateUsers[publicKey]?.derivedPublicKeyBase58Check !== - userInfo.derivedPublicKeyBase58Check + userInfo.derivedPublicKeyBase58Check ) { const previousUserInfo = privateUsers[publicKey]; const archivedUserData = JSON.parse( @@ -1020,11 +1154,11 @@ export class AccountService { getLoginMethodWithPublicKeyBase58Check( publicKeyBase58Check: string ): LoginMethod { - const account = this.getPrivateUsers()[publicKeyBase58Check]; + const account = this.getRootLevelUsers()[publicKeyBase58Check]; return account.loginMethod || LoginMethod.DESO; } - private getPrivateUsers(): { [key: string]: PrivateUserInfo } { + getRootLevelUsers(): { [key: string]: PrivateUserInfo } { const privateUsers = this.getPrivateUsersRaw(); const filteredPrivateUsers: { [key: string]: PrivateUserInfo } = {}; @@ -1048,26 +1182,170 @@ export class AccountService { return filteredPrivateUsers; } - private getPrivateUsersRaw(): { [key: string]: PrivateUserInfo } { - return JSON.parse( - localStorage.getItem(AccountService.USERS_STORAGE_KEY) || '{}' + updateAccountInfo(publicKey: string, attrs: Partial): void { + const privateUsers = this.getPrivateUsersRaw(); + + if (!privateUsers[publicKey]) { + // we could be dealing with a sub account. + const lookupMap = this.getSubAccountReverseLookupMap(); + const mapping = lookupMap[publicKey]; + + if (!mapping) { + throw new Error(`User not found for public key: ${publicKey}`); + } + + const rootUser = privateUsers[mapping.lookupKey]; + + if (!rootUser) { + throw new Error(`Root user not found for public key: ${publicKey}`); + } + + const subAccounts = rootUser.subAccounts ?? []; + const subAccountIndex = subAccounts.findIndex( + (a) => a.accountNumber === mapping.accountNumber + ); + + if (subAccountIndex < 0) { + throw new Error( + `Sub account not found for root user public key: ${publicKey} with account number: ${mapping.accountNumber}}` + ); + } + + subAccounts[subAccountIndex] = { + ...subAccounts[subAccountIndex], + ...attrs, + }; + + privateUsers[mapping.lookupKey] = { + ...rootUser, + subAccounts, + }; + } else { + privateUsers[publicKey] = { + ...privateUsers[publicKey], + ...attrs, + }; + } + + this.setPrivateUsersRaw(privateUsers); + } + + /** + * Adds a new sub account entry to the root user's subAccounts array. If the + * account number is provided, we will use it. Otherwise we will generate a + * new account number that is not already in use. If the account number + * provided matches an existing account, we will just make sure it appears in + * the UI again if it had been hidden before. If it matches and the account is + * NOT hidden, then nothing happens. + */ + addSubAccount( + rootPublicKey: string, + options: { accountNumber?: number } = {} + ): number { + // The zeroth account represents the "root" account key so we don't allow it + // for sub-accounts. There is nothing particularly special about the root + // account, but for historical reasons its public key is used to index the + // main users map in local storage. + if (options.accountNumber === 0) { + this.updateAccountInfo(rootPublicKey, {isHidden: false}); + return 0; + } + + const privateUsers = this.getPrivateUsersRaw(); + const parentAccount = privateUsers[rootPublicKey]; + + if (!parentAccount) { + throw new Error( + `Parent account not found for public key: ${rootPublicKey}` + ); + } + + const subAccounts = parentAccount.subAccounts ?? []; + const foundAccountIndex = + typeof options.accountNumber === 'number' + ? subAccounts.findIndex( + (a) => a.accountNumber === options.accountNumber + ) + : -1; + const accountNumbers = new Set(subAccounts.map((a) => a.accountNumber)); + const accountNumber = + options.accountNumber ?? generateAccountNumber(accountNumbers); + + let newSubAccounts: SubAccountMetadata[] = []; + + if (foundAccountIndex !== -1) { + // If accountNumber is provided and we already have it, we just make sure + // the existing account is not hidden. + subAccounts[foundAccountIndex].isHidden = false; + newSubAccounts = subAccounts; + } else { + // otherwise we create a new sub account + newSubAccounts = subAccounts.concat({ + accountNumber, + isHidden: false, + }); + + this.updateSubAccountReverseLookupMap({ + lookupKey: rootPublicKey, + accountNumber, + }); + } + + // sanity check that we're not adding a duplicate account number before we save. + const accountNumbersSet = new Set( + newSubAccounts.map((a) => a.accountNumber) ); + if (accountNumbersSet.size !== newSubAccounts.length) { + throw new Error( + `Duplicate account number ${accountNumber} found for root user public key: ${rootPublicKey}` + ); + } + + this.updateAccountInfo(rootPublicKey, {subAccounts: newSubAccounts}); + + return accountNumber; } - encryptedSeedHexToPublicKeyBase58Check(encryptedSeedHex: string): string { - return this.seedHexToPublicKeyBase58Check( - this.cryptoService.decryptSeedHex( - encryptedSeedHex, - this.globalVars.hostname - ) + getAccountPublicKeyBase58( + rootPublicKeyBase58: string, + accountNumber: number = 0 + ) { + // Account number 0 is reserved for the parent account, so we can just + // return the parent key directly in this case. + if (accountNumber === 0) { + return rootPublicKeyBase58; + } + + const users = this.getRootLevelUsers(); + const parentAccount = users[rootPublicKeyBase58]; + + if (!parentAccount) { + throw new Error( + `Account not found for public key: ${rootPublicKeyBase58}` + ); + } + + const childKey = this.cryptoService.mnemonicToKeychain( + parentAccount.mnemonic, + { + accountNumber, + extraText: parentAccount.extraText, + } + ); + const ec = new EC('secp256k1'); + const keyPair = ec.keyFromPrivate(childKey.privateKey); + + return this.cryptoService.publicKeyToDeSoPublicKey( + keyPair, + parentAccount.network ); } - seedHexToPublicKeyBase58Check(seedHex: string): string { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); - return this.cryptoService.privateKeyToDeSoPublicKey( - privateKey, - this.globalVars.network + // Private Getters and Modifiers + + private getPrivateUsersRaw(): { [key: string]: PrivateUserInfo } { + return JSON.parse( + localStorage.getItem(AccountService.USERS_STORAGE_KEY) || '{}' ); } @@ -1079,4 +1357,33 @@ export class AccountService { JSON.stringify(privateUsers) ); } + + /** + * It's possible for the reverse lookup to get out of sync, especially during + * development or testing. This method will fix any discrepancies by iterating + * through all the accounts and adding any missing entries. + */ + private initializeSubAccountReverseLookup() { + const lookupMap = this.getSubAccountReverseLookupMap(); + const users = this.getRootLevelUsers(); + + Object.keys(users).forEach((lookupKey) => { + const subAccounts = users[lookupKey].subAccounts ?? []; + subAccounts.forEach((subAccount) => { + const publicKey = this.getAccountPublicKeyBase58( + lookupKey, + subAccount.accountNumber + ); + lookupMap[publicKey] = { + lookupKey, + accountNumber: subAccount.accountNumber, + }; + }); + }); + + window.localStorage.setItem( + SUB_ACCOUNT_REVERSE_LOOKUP_KEY, + JSON.stringify(lookupMap) + ); + } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5a11102f..2be3c923 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -73,10 +73,6 @@ export class AppComponent implements OnInit { this.globalVars.authenticatedUsers = authenticatedUsers; } - if (params.get('subAccounts') === 'true') { - this.globalVars.subAccounts = true; - } - // Callback should only be used in mobile applications, where payload is passed through URL parameters. const callback = params.get('callback') || stateParamsFromGoogle.callback; if (callback) { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e59a2157..23933e92 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,6 +1,7 @@ import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BrowserModule } from '@angular/platform-browser'; @@ -31,6 +32,10 @@ import { ErrorCallbackComponent } from './error-callback/error-callback.componen import { FreeDeSoDisclaimerComponent } from './free-deso-message/free-deso-disclaimer/free-deso-disclaimer.component'; import { FreeDesoMessageComponent } from './free-deso-message/free-deso-message.component'; import { GetDesoComponent } from './get-deso/get-deso.component'; +import { BackupSeedDialogComponent } from './grouped-account-select/backup-seed-dialog/backup-seed-dialog.component'; +import { GroupedAccountSelectComponent } from './grouped-account-select/grouped-account-select.component'; +import { RecoverySecretComponent } from './grouped-account-select/recovery-secret/recovery-secret.component'; +import { RemoveAccountDialogComponent } from './grouped-account-select/remove-account-dialog/remove-account-dialog.component'; import { HomeComponent } from './home/home.component'; import { IconsModule } from './icons/icons.module'; import { IdentityService } from './identity.service'; @@ -98,6 +103,10 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ TransactionSpendingLimitAssociationComponent, TransactionSpendingLimitAccessGroupComponent, TransactionSpendingLimitAccessGroupMemberComponent, + GroupedAccountSelectComponent, + RecoverySecretComponent, + BackupSeedDialogComponent, + RemoveAccountDialogComponent, ], imports: [ BrowserModule, @@ -114,6 +123,7 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ }), BuyDeSoComponentWrapper, CookieModule.forRoot(), + MatDialogModule, ], providers: [ IdentityService, diff --git a/src/app/approve/approve.component.ts b/src/app/approve/approve.component.ts index 51846e43..9b6911d8 100644 --- a/src/app/approve/approve.component.ts +++ b/src/app/approve/approve.component.ts @@ -77,7 +77,8 @@ export class ApproveComponent implements OnInit { public globalVars: GlobalVarsService, private signingService: SigningService, private backendApi: BackendAPIService - ) {} + ) { + } ngOnInit(): void { this.activatedRoute.queryParams.subscribe((params) => { @@ -95,37 +96,28 @@ export class ApproveComponent implements OnInit { }); } - onCancel(): void { - this.finishFlow(); + async onCancel(): Promise { + await this.finishFlow(); } - onSubmit(): void { - const user = this.accountService.getEncryptedUsers()[this.publicKey]; - const isDerived = this.accountService.isMetamaskAccount(user); + async onSubmit(): Promise { + const account = this.accountService.getAccountInfo(this.publicKey); + const isDerived = this.accountService.isMetamaskAccount(account); const signedTransactionHex = this.signingService.signTransaction( - this.seedHex(), + account.seedHex, this.transactionHex, isDerived ); - this.finishFlow(signedTransactionHex); + await this.finishFlow(signedTransactionHex); } - finishFlow(signedTransactionHex?: string): void { + async finishFlow(signedTransactionHex?: string): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), signedTransactionHex, }); } - seedHex(): string { - const encryptedSeedHex = - this.accountService.getEncryptedUsers()[this.publicKey].encryptedSeedHex; - return this.cryptoService.decryptSeedHex( - encryptedSeedHex, - this.globalVars.hostname - ); - } - generateTransactionDescription(): void { let description = 'sign an unknown transaction'; let publicKeys: string[] = []; @@ -409,23 +401,23 @@ export class ApproveComponent implements OnInit { exchangeRateCoinsToSellPerCoinToBuy === 0 ? `using ${sellingCoin} at any exchange rate` : `at an exchange rate of ${this.toFixedLengthDecimalString( - exchangeRateCoinsToSellPerCoinToBuy - )} ` + `${sellingCoin} per coin bought`; + exchangeRateCoinsToSellPerCoinToBuy + )} ` + `${sellingCoin} per coin bought`; const exchangeRateCoinsToBuyPerCoinsToSellPhrase = exchangeRateCoinsToSellPerCoinToBuy === 0 ? `for ${buyingCoin} at any exchange rate` : `at an exchange rate of ${this.toFixedLengthDecimalString( - 1 / exchangeRateCoinsToSellPerCoinToBuy - )} ` + `${buyingCoin} per coin sold`; + 1 / exchangeRateCoinsToSellPerCoinToBuy + )} ` + `${buyingCoin} per coin sold`; const daoCoinLimitOrderFillTypePhrase = daoCoinLimitOrderFillType === '1' ? 'a Good-Till-Cancelled' : daoCoinLimitOrderFillType === '2' - ? 'an Immediate-Or-Cancel' - : daoCoinLimitOrderFillType === '3' - ? 'a Fill-Or-Kill' - : `an unknown fill type (${daoCoinLimitOrderFillType})`; + ? 'an Immediate-Or-Cancel' + : daoCoinLimitOrderFillType === '3' + ? 'a Fill-Or-Kill' + : `an unknown fill type (${daoCoinLimitOrderFillType})`; if (daoCoinLimitOrderOperationType === '1') { // -- ASK Order -- diff --git a/src/app/auth/google/google.component.ts b/src/app/auth/google/google.component.ts index a1f7bdea..c264bc45 100644 --- a/src/app/auth/google/google.component.ts +++ b/src/app/auth/google/google.component.ts @@ -2,7 +2,7 @@ import { Component, NgZone, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { environment } from '../../../environments/environment'; -import { GoogleAuthState } from '../../../types/identity'; +import { GoogleAuthState, LoginMethod } from '../../../types/identity'; import { AccountService } from '../../account.service'; import { RouteNames } from '../../app-routing.module'; import { BackendAPIService } from '../../backend-api.service'; @@ -38,7 +38,8 @@ export class GoogleComponent implements OnInit { private zone: NgZone, private route: ActivatedRoute, private backendApi: BackendAPIService - ) {} + ) { + } copySeed(): void { this.textService.copyText(this.mnemonic); @@ -63,7 +64,7 @@ export class GoogleComponent implements OnInit { this.googleDrive.setAccessToken(accessToken); - this.googleDrive.listFiles(this.fileName()).subscribe((res) => { + this.googleDrive.listFiles(this.fileName()).subscribe(async (res) => { if (res.files.length > 0) { this.loadAccounts(res.files); } else { @@ -83,17 +84,18 @@ export class GoogleComponent implements OnInit { const mnemonic = fileContents.mnemonic; const extraText = fileContents.extraText; const network = fileContents.network; - const keychain = this.cryptoService.mnemonicToKeychain( - mnemonic, - extraText - ); + const keychain = this.cryptoService.mnemonicToKeychain(mnemonic, { + extraText, + }); this.publicKey = this.accountService.addUser( keychain, mnemonic, extraText, network, - true + { + loginMethod: LoginMethod.GOOGLE, + } ); } catch (err) { console.error(err); @@ -107,9 +109,9 @@ export class GoogleComponent implements OnInit { }); } - filesLoaded.subscribe(() => { + filesLoaded.subscribe(async () => { if (numLoaded === 1) { - this.finishFlow(false); + await this.finishFlow(false); } else { this.zone.run(() => { this.router.navigate(['/', RouteNames.LOG_IN], { @@ -137,22 +139,23 @@ export class GoogleComponent implements OnInit { this.googleDrive .uploadFile(this.fileName(), JSON.stringify(userInfo)) .subscribe(() => { - const keychain = this.cryptoService.mnemonicToKeychain( - mnemonic, - extraText - ); + const keychain = this.cryptoService.mnemonicToKeychain(mnemonic, { + extraText, + }); this.publicKey = this.accountService.addUser( keychain, mnemonic, extraText, network, - true + { + loginMethod: LoginMethod.GOOGLE, + } ); this.loading = false; }); } - finishFlow(signedUp: boolean): void { + async finishFlow(signedUp: boolean): Promise { this.globalVars.signedUp = signedUp; this.accountService.setAccessLevel( this.publicKey, @@ -161,11 +164,11 @@ export class GoogleComponent implements OnInit { ); if (this.globalVars.derive) { - this.router.navigate(['/', RouteNames.DERIVE], { + await this.router.navigate(['/', RouteNames.DERIVE], { queryParams: { publicKey: this.publicKey, transactionSpendingLimitResponse: - this.globalVars.transactionSpendingLimitResponse, + this.globalVars.transactionSpendingLimitResponse, deleteKey: this.globalVars.deleteKey || undefined, derivedPublicKey: this.globalVars.derivedPublicKey || undefined, expirationDays: this.globalVars.expirationDays || undefined, @@ -174,35 +177,35 @@ export class GoogleComponent implements OnInit { }); } else { if (!this.globalVars.getFreeDeso) { - this.login(signedUp); + await this.login(signedUp); } if (!signedUp) { this.backendApi .GetUsersStateless([this.publicKey], true, true) - .subscribe((res) => { + .subscribe(async (res) => { if (res?.UserList?.length) { if (res.UserList[0].BalanceNanos !== 0) { - this.login(signedUp); + await this.login(signedUp); return; } } - this.router.navigate(['/', RouteNames.GET_DESO], { - queryParams: { publicKey: this.publicKey, signedUp }, + await this.router.navigate(['/', RouteNames.GET_DESO], { + queryParams: {publicKey: this.publicKey, signedUp}, queryParamsHandling: 'merge', }); }); } else { - this.router.navigate(['/', RouteNames.GET_DESO], { - queryParams: { publicKey: this.publicKey, signedUp }, + await this.router.navigate(['/', RouteNames.GET_DESO], { + queryParams: {publicKey: this.publicKey, signedUp}, queryParamsHandling: 'merge', }); } } } - login(signedUp: boolean): void { + async login(signedUp: boolean): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, signedUp, }); diff --git a/src/app/backend-api.service.ts b/src/app/backend-api.service.ts index ca4bc41b..98d915a6 100644 --- a/src/app/backend-api.service.ts +++ b/src/app/backend-api.service.ts @@ -269,7 +269,8 @@ export class BackendAPIService { private signingService: SigningService, private accountService: AccountService, private globalVars: GlobalVarsService - ) {} + ) { + } getRoute(path: string): string { let endpoint = this.endpoint; @@ -291,25 +292,21 @@ export class BackendAPIService { } jwtPost(path: string, publicKey: string, body: any): Observable { - const publicUserInfo = this.accountService.getEncryptedUsers()[publicKey]; + const account = this.accountService.getAccountInfo(publicKey); // NOTE: there are some cases where derived user's were not being sent phone number // verification texts due to missing public user info. This is to log how often // this is happening. logInteractionEvent('backend-api', 'jwt-post', { - hasPublicUserInfo: !!publicUserInfo, + hasPublicUserInfo: !!account, }); - if (!publicUserInfo) { + if (!account) { return of(null); } - const isDerived = this.accountService.isMetamaskAccount(publicUserInfo); + const isDerived = this.accountService.isMetamaskAccount(account); - const seedHex = this.cryptoService.decryptSeedHex( - publicUserInfo.encryptedSeedHex, - this.globalVars.hostname - ); - const jwt = this.signingService.signJWT(seedHex, isDerived); - return this.post(path, { ...body, ...{ JWT: jwt } }); + const jwt = this.signingService.signJWT(account.seedHex, isDerived); + return this.post(path, {...body, ...{JWT: jwt}}); } // Error parsing @@ -349,7 +346,7 @@ export class BackendAPIService { publicKeys: string[] ): Observable<{ [key: string]: UserProfile }> { const userProfiles: { [key: string]: any } = {}; - const req = this.GetUsersStateless(publicKeys, true); + const req = this.GetUsersStateless(publicKeys, true, true); if (publicKeys.length > 0) { return req .pipe( @@ -358,6 +355,7 @@ export class BackendAPIService { userProfiles[user.PublicKeyBase58Check] = { username: user.ProfileEntryResponse?.Username, profilePic: user.ProfileEntryResponse?.ProfilePic, + balanceNanos: user.BalanceNanos, }; } return userProfiles; @@ -437,15 +435,15 @@ export class BackendAPIService { if (res.DerivedKeys.hasOwnProperty(derivedKey)) { derivedKeys[ res.DerivedKeys[derivedKey]?.DerivedPublicKeyBase58Check - ] = { + ] = { derivedPublicKeyBase58Check: - res.DerivedKeys[derivedKey]?.DerivedPublicKeyBase58Check, + res.DerivedKeys[derivedKey]?.DerivedPublicKeyBase58Check, ownerPublicKeyBase58Check: - res.DerivedKeys[derivedKey]?.OwnerPublicKeyBase58Check, + res.DerivedKeys[derivedKey]?.OwnerPublicKeyBase58Check, expirationBlock: res.DerivedKeys[derivedKey]?.ExpirationBlock, isValid: res.DerivedKeys[derivedKey]?.IsValid, transactionSpendingLimit: - res.DerivedKeys[derivedKey]?.TransactionSpendingLimit, + res.DerivedKeys[derivedKey]?.TransactionSpendingLimit, }; } } @@ -694,39 +692,6 @@ export class BackendAPIService { ); } - ExchangeBitcoin( - LatestBitcionAPIResponse: any, - BTCDepositAddress: string, - PublicKeyBase58Check: string, - BurnAmountSatoshis: number, - FeeRateSatoshisPerKB: number, - SignedHashes: string[], - Broadcast: boolean - ): Observable { - // Check if the user is logged in with a derived key and operating as the owner key. - const DerivedPublicKeyBase58Check = - this.accountService.getEncryptedUsers()[PublicKeyBase58Check] - ?.derivedPublicKeyBase58Check; - - const req = this.post('exchange-bitcoin', { - PublicKeyBase58Check, - DerivedPublicKeyBase58Check, - BurnAmountSatoshis, - LatestBitcionAPIResponse, - BTCDepositAddress, - FeeRateSatoshisPerKB, - SignedHashes, - Broadcast, - }); - - return req.pipe( - catchError((err) => { - console.error(JSON.stringify(err)); - return throwError(err); - }) - ); - } - SubmitETHTx( PublicKeyBase58Check: string, Tx: any, diff --git a/src/app/buy-deso/buy-deso-complete/buy-deso-complete.component.ts b/src/app/buy-deso/buy-deso-complete/buy-deso-complete.component.ts index 9a440fd0..7239062d 100644 --- a/src/app/buy-deso/buy-deso-complete/buy-deso-complete.component.ts +++ b/src/app/buy-deso/buy-deso-complete/buy-deso-complete.component.ts @@ -21,16 +21,17 @@ export class BuyDeSoCompleteComponent implements OnInit { private backendApi: BackendAPIService, private identityService: IdentityService, private accountService: AccountService - ) {} + ) { + } triggerBuyMoreDeSo(): void { this.buyMoreDeSoClicked.emit(); } - close(): void { + async close(): Promise { this.closeModal.emit(); this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, signedUp: this.globalVars.signedUp, }); diff --git a/src/app/buy-deso/buy-deso-heroswap/buy-deso-heroswap.component.ts b/src/app/buy-deso/buy-deso-heroswap/buy-deso-heroswap.component.ts index 1b260cee..9fde0ec4 100644 --- a/src/app/buy-deso/buy-deso-heroswap/buy-deso-heroswap.component.ts +++ b/src/app/buy-deso/buy-deso-heroswap/buy-deso-heroswap.component.ts @@ -25,7 +25,8 @@ export class BuyDeSoHeroSwapComponent implements OnInit, OnDestroy { private router: Router, private identityService: IdentityService, private accountService: AccountService - ) {} + ) { + } ngOnInit(): void { window.scroll(0, 0); @@ -53,20 +54,20 @@ export class BuyDeSoHeroSwapComponent implements OnInit, OnDestroy { window.removeEventListener('message', this.#heroswapMessageListener); } - finishFlow(): void { + async finishFlow(): Promise { if (this.globalVars.derive) { - this.router.navigate(['/', RouteNames.DERIVE], { - queryParams: { publicKey: this.publicKey }, + await this.router.navigate(['/', RouteNames.DERIVE], { + queryParams: {publicKey: this.publicKey}, queryParamsHandling: 'merge', }); } else { - this.login(); + await this.login(); } } - login(): void { + async login(): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, signedUp: this.globalVars.signedUp, }); diff --git a/src/app/buy-deso/buy-deso/buy-deso.component.ts b/src/app/buy-deso/buy-deso/buy-deso.component.ts index 44fff935..8cefcec0 100644 --- a/src/app/buy-deso/buy-deso/buy-deso.component.ts +++ b/src/app/buy-deso/buy-deso/buy-deso.component.ts @@ -76,7 +76,7 @@ export class BuyDeSoComponent implements OnInit { ]; buyTabs = this.defaultBuyTabs; activeTab = BuyDeSoComponent.BUY_WITH_HEROSWAP; - linkTabs = { [BuyDeSoComponent.BUY_ON_CB]: BuyDeSoComponent.CB_LINK }; + linkTabs = {[BuyDeSoComponent.BUY_ON_CB]: BuyDeSoComponent.CB_LINK}; satoshisPerDeSoExchangeRate = 0; ProtocolUSDCentsPerBitcoinExchangeRate = 0; @@ -149,8 +149,8 @@ export class BuyDeSoComponent implements OnInit { confirmButtonText: showBuyDeSo ? 'Buy DeSo' : showBuyCreatorCoin - ? 'Buy Creator Coin' - : 'Ok', + ? 'Buy Creator Coin' + : 'Ok', reverseButtons: true, }); } @@ -194,16 +194,16 @@ export class BuyDeSoComponent implements OnInit { ); } - ngOnInit(): void { + async ngOnInit(): Promise { const encryptedUser = - this.accountService.getEncryptedUsers()[this.publicKey]; + (await this.accountService.getEncryptedUsers())[this.publicKey]; // TODO: need some sort of UI for when we can't get encrypted user. if (!encryptedUser) { console.error('Encrypted User not found: Buying DESO will not work.'); this.publicKeyNotInIdentity = true; return; } else { - this.seedHex = this.cryptoService.decryptSeedHex( + this.seedHex = await this.cryptoService.decryptSeedHex( encryptedUser.encryptedSeedHex, this.globalVars.hostname ); @@ -253,4 +253,5 @@ export class BuyDeSoComponent implements OnInit { exports: [BuyDeSoComponent, BuyDeSoCompleteComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) -export class BuyDeSoComponentWrapper {} +export class BuyDeSoComponentWrapper { +} diff --git a/src/app/buy-or-send-deso/buy-or-send-deso.component.ts b/src/app/buy-or-send-deso/buy-or-send-deso.component.ts index 745093d5..2bb88333 100644 --- a/src/app/buy-or-send-deso/buy-or-send-deso.component.ts +++ b/src/app/buy-or-send-deso/buy-or-send-deso.component.ts @@ -50,7 +50,8 @@ export class BuyOrSendDesoComponent implements OnInit { }); } - ngOnInit(): void {} + ngOnInit(): void { + } ////// STEP FIVE BUTTONS | STEP_OBTAIN_DESO /////// @@ -97,27 +98,27 @@ export class BuyOrSendDesoComponent implements OnInit { }, 1000); } - finishFlowTransferDeSo(): void { - this.finishFlow(); + async finishFlowTransferDeSo(): Promise { + await this.finishFlow(); } ////// STEP SIX BUTTONS | STEP_BUY_DESO /////// ////// FINISH FLOW /////// - finishFlow(): void { + async finishFlow(): Promise { if (this.globalVars.derive) { - this.router.navigate(['/', RouteNames.DERIVE], { - queryParams: { publicKey: this.publicKeyAdded }, + await this.router.navigate(['/', RouteNames.DERIVE], { + queryParams: {publicKey: this.publicKeyAdded}, queryParamsHandling: 'merge', }); } else { - this.login(); + await this.login(); } } - login(): void { + async login(): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKeyAdded, signedUp: this.globalVars.signedUp, }); diff --git a/src/app/crypto.service.ts b/src/app/crypto.service.ts index 4196272c..f155fa4d 100644 --- a/src/app/crypto.service.ts +++ b/src/app/crypto.service.ts @@ -15,6 +15,10 @@ import * as sha256 from 'sha256'; import { Keccak } from 'sha3'; import { AccessLevel, Network } from '../types/identity'; import { GlobalVarsService } from './global-vars.service'; +import { aes_256_gcm } from "@noble/ciphers/webcrypto/aes"; +import { + utils as ecUtils +} from '@noble/secp256k1'; @Injectable({ providedIn: 'root', @@ -23,7 +27,8 @@ export class CryptoService { constructor( private cookieService: CookieService, private globalVars: GlobalVarsService - ) {} + ) { + } static PUBLIC_KEY_PREFIXES = { mainnet: { @@ -107,16 +112,26 @@ export class CryptoService { return encryptionKey; } - encryptSeedHex(seedHex: string, hostname: string): string { + async encryptSeedHex(seedHex: string, hostname: string): Promise { const encryptionKey = this.seedHexEncryptionKey(hostname, false); - const cipher = createCipher('aes-256-gcm', encryptionKey); - return cipher.update(seedHex).toString('hex'); + const cipher = aes_256_gcm(ecUtils.hexToBytes(encryptionKey), new Uint8Array(12)); + debugger; + const x = await cipher.encrypt(ecUtils.hexToBytes(seedHex)); + return ecUtils.bytesToHex(x); + // const cipher = createCipher('aes-256-gcm', encryptionKey); + // return cipher.update(seedHex).toString('hex'); } - decryptSeedHex(encryptedSeedHex: string, hostname: string): string { + async decryptSeedHex(encryptedSeedHex: string, hostname: string): Promise { const encryptionKey = this.seedHexEncryptionKey(hostname, false); - const decipher = createDecipher('aes-256-gcm', encryptionKey); - return decipher.update(Buffer.from(encryptedSeedHex, 'hex')).toString(); + const decipher = aes_256_gcm(ecUtils.hexToBytes(encryptionKey), new Uint8Array(12)); + const x = await decipher.decrypt(ecUtils.hexToBytes(encryptedSeedHex)); + return ecUtils.bytesToHex(x); + // const decipher = createDecipher('aes-256-gcm', encryptionKey); + // const buff = decipher.update(Buffer.from(encryptedSeedHex, 'hex')); + // debugger; + // console.log(buff); + // return buff.toString(); } accessLevelHmac(accessLevel: AccessLevel, seedHex: string): string { @@ -138,29 +153,38 @@ export class CryptoService { mnemonicToKeychain( mnemonic: string, - extraText?: string, - nonStandard?: boolean + { + extraText, + nonStandard, + accountNumber = 0, + }: { + extraText?: string; + nonStandard?: boolean; + accountNumber?: number; + } = {} ): HDNode { const seed = bip39.mnemonicToSeedSync(mnemonic, extraText); - // @ts-ignore - return HDKey.fromMasterSeed(seed).derive("m/44'/0'/0'/0/0", nonStandard); + return generateSubAccountKeys(seed, accountNumber, { + nonStandard, + }); } keychainToSeedHex(keychain: HDNode): string { return keychain.privateKey.toString('hex'); } - seedHexToPrivateKey(seedHex: string): EC.KeyPair { + seedHexToKeyPair(seedHex: string): EC.KeyPair { const ec = new EC('secp256k1'); + return ec.keyFromPrivate(seedHex); } - encryptedSeedHexToPublicKey(encryptedSeedHex: string): string { - const seedHex = this.decryptSeedHex( + async encryptedSeedHexToPublicKey(encryptedSeedHex: string): Promise { + const seedHex = await this.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const privateKey = this.seedHexToPrivateKey(seedHex); + const privateKey = this.seedHexToKeyPair(seedHex); return this.privateKeyToDeSoPublicKey(privateKey, this.globalVars.network); } @@ -279,3 +303,22 @@ export class CryptoService { return ethAddressChecksum; } } + +/** + * We set the account according to the following derivation path scheme: + * m / purpose' / coin_type' / account' / change / address_index + * See for more details: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account + */ +function generateSubAccountKeys( + seedBytes: Buffer, + accountIndex: number, + options?: { nonStandard?: boolean } +) { + // We are using a customized version of hdkey and the derive signature types + // are not compatible with the "nonStandard" flag. Hence the ts-ignore. + return HDKey.fromMasterSeed(seedBytes).derive( + `m/44'/0'/${accountIndex}'/0/0`, + // @ts-ignore + !!options?.nonStandard + ); +} diff --git a/src/app/derive/derive.component.html b/src/app/derive/derive.component.html index 44ec34cd..279a0bfa 100644 --- a/src/app/derive/derive.component.html +++ b/src/app/derive/derive.component.html @@ -21,14 +21,7 @@ }} - -
- or -
- +
0; - this.backendApi.GetAppState().subscribe((res) => { this.blockHeight = res.BlockHeight; }); @@ -69,6 +67,11 @@ export class DeriveComponent implements OnInit { throw Error('invalid query parameter permutation'); } if (params.publicKey) { + if (!this.publicKeyBase58Check) { + this.accountService.updateAccountInfo(params.publicKey, { + lastLoginTimestamp: Date.now(), + }); + } this.publicKeyBase58Check = params.publicKey; this.isSingleAccount = true; } @@ -84,7 +87,7 @@ export class DeriveComponent implements OnInit { this.deleteKey = params.deleteKey === 'true'; // We don't want or need to parse transaction spending limit when revoking derived key, // so we initialize a spending limit object with no permissions. - this.transactionSpendingLimitResponse = { GlobalDESOLimit: 0 }; + this.transactionSpendingLimitResponse = {GlobalDESOLimit: 0}; // Setting expiration days to 0 forces us to have a minimum transaction size that is still valid. this.expirationDays = 0; } @@ -185,10 +188,10 @@ export class DeriveComponent implements OnInit { true /*IncludeBalance*/ ) .pipe(take(1)) - .subscribe((res) => { + .subscribe(async (res) => { if (res.UserList?.[0]?.BalanceNanos === 0) { - this.router.navigate(['/', RouteNames.GET_DESO], { - queryParams: { publicKey }, + await this.router.navigate(['/', RouteNames.GET_DESO], { + queryParams: {publicKey}, queryParamsHandling: 'merge', }); return; @@ -198,7 +201,7 @@ export class DeriveComponent implements OnInit { // without approval. if (this.globalVars.authenticatedUsers.has(publicKey)) { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: publicKey, }); diff --git a/src/app/get-deso/get-deso.component.html b/src/app/get-deso/get-deso.component.html index 5f794ec4..e18ed02b 100644 --- a/src/app/get-deso/get-deso.component.html +++ b/src/app/get-deso/get-deso.component.html @@ -24,7 +24,7 @@

Get starter $DESO

*ngIf="!alternativeOptionsEnabled && captchaAvailable" class="padding-bottom--2xlarge text--center" > -
+
Get starter $DESO
Complete a captcha to get free $DESO

- Prove you're not a robot 🤖 and we'll
send you a small amount - of $DESO that will last
you up to thousands of on-chain - transactions. + Prove you're not a robot 🤖 and we'll
+ send you a small amount of free $DESO that will last
+ you up to thousands of on-chain transactions.

Get starter $DESO 1. Get DESO for free by verifying your phone number

- We'll send you a small amount of $DESO that will last
you up to - thousands of on-chain transactions. + We'll send you a small amount of $DESO that will last
+ you up to thousands of on-chain transactions.

diff --git a/src/app/get-deso/get-deso.component.ts b/src/app/get-deso/get-deso.component.ts index 98388649..a17717ad 100644 --- a/src/app/get-deso/get-deso.component.ts +++ b/src/app/get-deso/get-deso.component.ts @@ -51,7 +51,7 @@ export class GetDesoComponent implements OnInit { publicKeyCopied = false; - @ViewChild('captchaElem', { static: false }) captchaElem: any; + @ViewChild('captchaElem', {static: false}) captchaElem: any; constructor( public entropyService: EntropyService, @@ -132,7 +132,7 @@ export class GetDesoComponent implements OnInit { stepThreeNextPhone(): void { this.router.navigate(['/', RouteNames.VERIFY_PHONE_NUMBER], { - queryParams: { public_key: this.publicKeyAdded }, + queryParams: {public_key: this.publicKeyAdded}, queryParamsHandling: 'merge', }); } @@ -161,10 +161,10 @@ export class GetDesoComponent implements OnInit { onCaptchaVerify(token: string): void { this.backendAPIService.VerifyHCaptcha(token, this.publicKeyAdded).subscribe( - (res) => { + async (res) => { if (res.Success) { this.isFinishFlowDisabled = false; - this.finishFlow(); + await this.finishFlow(); } else { this.captchaFailed = true; } @@ -221,23 +221,23 @@ export class GetDesoComponent implements OnInit { ? this.userBalanceNanos < 1e4 : false; - finishFlow(): void { + async finishFlow(): Promise { if (this.isFinishFlowDisabled) { return; } if (this.globalVars.derive) { - this.router.navigate(['/', RouteNames.DERIVE], { - queryParams: { publicKey: this.publicKeyAdded }, + await this.router.navigate(['/', RouteNames.DERIVE], { + queryParams: {publicKey: this.publicKeyAdded}, queryParamsHandling: 'merge', }); } else { - this.login(); + await this.login(); } } - login(): void { + async login(): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKeyAdded, signedUp: this.globalVars.signedUp, }); diff --git a/src/app/global-vars.service.ts b/src/app/global-vars.service.ts index 050ff9de..e8fa4127 100644 --- a/src/app/global-vars.service.ts +++ b/src/app/global-vars.service.ts @@ -62,12 +62,6 @@ export class GlobalVarsService { */ showSkip: boolean = false; - /** - * Flag used to gate the new subAccounts functionality. After some sunset - * period (TBD), we can remove this flag and make this the default behavior. - */ - subAccounts: boolean = false; - /** * Set of public keys that have been authenticated by the calling application. * This is used as a hint to decide whether to show the derived key approval @@ -180,4 +174,36 @@ export class GlobalVarsService { formatTxCountLimit(count: number = 0): string { return count >= 1e9 ? 'UNLIMITED' : count.toLocaleString(); } + + abbreviateNumber(value: number) { + if (value === 0) { + return '0'; + } + + if (value < 0) { + return value.toString(); + } + if (value < 0.01) { + return value.toFixed(5); + } + if (value < 0.1) { + return value.toFixed(4); + } + + let shortValue; + const suffixes = ['', 'K', 'M', 'B', 'e12', 'e15', 'e18', 'e21']; + const suffixNum = Math.floor((('' + value.toFixed(0)).length - 1) / 3); + shortValue = value / Math.pow(1000, suffixNum); + if ( + Math.floor(shortValue / 100) > 0 || + shortValue / 1 === 0 || + suffixNum > 3 + ) { + return shortValue.toFixed(0) + suffixes[suffixNum]; + } + if (Math.floor(shortValue / 10) > 0 || Math.floor(shortValue) > 0) { + return shortValue.toFixed(2) + suffixes[suffixNum]; + } + return shortValue.toFixed(3) + suffixes[suffixNum]; + } } diff --git a/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.html b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.html new file mode 100644 index 00000000..daac2105 --- /dev/null +++ b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.html @@ -0,0 +1,102 @@ +
+
+ +

+ Backup DeSo Seed +

+

Disable Backup

+
+
+
+

+ Your seed phrase is the only way to recover your DeSo account. If you + lose your seed phrase, you will lose access to your DeSo account. Store + it in a safe and secure place. +

+

+ DO NOT share your seed phrase with anyone! Developers and support agents + will never request this. +

+
+ + +
+
+ + +
+

DeSo Seed Phrase:

+ +
+
+

DeSo Pass Phrase:

+ +
+
+

Seed Hex:

+

+ Provides an alternative means of logging in if you don't have a seed + phrase. +

+ +
+
+

+ Disable Backup +

+

+ Disabling backup makes your account more secure by preventing anyone + from revealing your seed in the future, even if they've gained access + to your device. +

+ +
+
+ +
+

+ Disabling backup means you will not be able to access your seed phrase + anymore. +

+
+ Make sure that you've copied your seed phrase and stored it in a safe + place before you proceed. +
+
+ + +
+
+
+
diff --git a/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.scss b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.ts b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.ts new file mode 100644 index 00000000..9433879c --- /dev/null +++ b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.ts @@ -0,0 +1,50 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AccountService } from '../../account.service'; + +@Component({ + selector: 'backup-seed-dialog', + templateUrl: './backup-seed-dialog.component.html', + styleUrls: ['./backup-seed-dialog.component.scss'], +}) +export class BackupSeedDialogComponent { + step = 1; + mnemonic?: string; + extraText?: string; + seedHex?: string; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { rootPublicKey: string }, + private accountService: AccountService + ) {} + + cancel(): void { + this.dialogRef.close(); + } + + showSecrets() { + if (!this.data.rootPublicKey) { + throw new Error('Root public key is required'); + } + + const { mnemonic, extraText, seedHex } = this.accountService.getAccountInfo( + this.data.rootPublicKey + ); + this.mnemonic = mnemonic; + this.extraText = extraText; + this.seedHex = seedHex; + this.step = 2; + } + + showDisableBackupConfirmation() { + this.step = 3; + } + + disableBackup() { + this.accountService.updateAccountInfo(this.data.rootPublicKey, { + exportDisabled: true, + }); + this.dialogRef.close(); + } +} diff --git a/src/app/grouped-account-select/grouped-account-select.component.html b/src/app/grouped-account-select/grouped-account-select.component.html new file mode 100644 index 00000000..bcaf43e6 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.html @@ -0,0 +1,210 @@ +
+ +
+
+ Select an account +
+ + +
+

Account Group

+
    +
  • +
    + +
    + +
    +
    +
  • + +
    +
    + + + +
    +
    + + + +
    +
    +
+
+
+
+
+
+ or +
+ diff --git a/src/app/grouped-account-select/grouped-account-select.component.scss b/src/app/grouped-account-select/grouped-account-select.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/grouped-account-select.component.ts b/src/app/grouped-account-select/grouped-account-select.component.ts new file mode 100644 index 00000000..8d79b4a4 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.ts @@ -0,0 +1,362 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { finalize, take } from 'rxjs/operators'; +import { + LoginMethod, + SubAccountMetadata, + UserProfile, +} from 'src/types/identity'; +import Swal from 'sweetalert2'; +import { isValid32BitUnsignedInt } from '../../lib/account-number'; +import { AccountService } from '../account.service'; +import { BackendAPIService } from '../backend-api.service'; +import { GlobalVarsService } from '../global-vars.service'; +import { BackupSeedDialogComponent } from './backup-seed-dialog/backup-seed-dialog.component'; +import { RemoveAccountDialogComponent } from './remove-account-dialog/remove-account-dialog.component'; + +type AccountViewModel = SubAccountMetadata & + UserProfile & { + rootPublicKey: string; + publicKey: string; + lastUsed?: boolean; + }; + +function sortAccounts(a: AccountViewModel, b: AccountViewModel) { + // sort accounts by last login timestamp DESC, + // secondarily by balance DESC + return ( + (b.lastLoginTimestamp ?? 0) - (a.lastLoginTimestamp ?? 0) || + b.balanceNanos - a.balanceNanos + ); +} + +@Component({ + selector: 'grouped-account-select', + templateUrl: './grouped-account-select.component.html', + styleUrls: ['./grouped-account-select.component.scss'], +}) +export class GroupedAccountSelectComponent implements OnInit { + @Output() onAccountSelect: EventEmitter = new EventEmitter(); + + /** + * Accounts are grouped by root public key. The root public key is the public + * key derived at account index 0 for a given seed phrase. + */ + accountGroups: Map< + string, + { + showRecoverSubAccountInput?: boolean; + accounts: AccountViewModel[]; + } + > = new Map(); + + /** + * Bound to a UI text input and used to recover a sub account. + */ + accountNumberToRecover = 0; + + /** + * UI loading state flag. + */ + loadingAccounts: boolean = false; + + justAddedPublicKey?: string; + + constructor( + public accountService: AccountService, + public globalVars: GlobalVarsService, + private backendApi: BackendAPIService, + public dialog: MatDialog + ) {} + + ngOnInit(): void { + this.initializeAccountGroups(); + } + + initializeAccountGroups() { + this.loadingAccounts = true; + const rootUserEntries = Object.entries( + this.accountService.getRootLevelUsers() + ); + const accountGroupsByRootKey = new Map< + string, + { + rootPublicKey: string; + publicKey: string; + accountNumber: number; + lastLoginTimestamp?: number; + }[] + >(); + + for (const [rootPublicKey, userInfo] of rootUserEntries) { + const accounts = !userInfo.isHidden + ? [ + { + rootPublicKey: rootPublicKey, + publicKey: rootPublicKey, + accountNumber: 0, + lastLoginTimestamp: userInfo.lastLoginTimestamp, + }, + ] + : []; + + const subAccounts = userInfo?.subAccounts ?? []; + + for (const subAccount of subAccounts) { + if (subAccount.isHidden) { + continue; + } + + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + + accounts.push({ + rootPublicKey: rootPublicKey, + publicKey: publicKeyBase58, + accountNumber: subAccount.accountNumber, + lastLoginTimestamp: subAccount.lastLoginTimestamp, + }); + } + + if (accounts.length > 0) { + accountGroupsByRootKey.set(rootPublicKey, accounts); + } + } + + const profileKeysToFetch = Array.from(accountGroupsByRootKey.values()) + .flat() + .map((a) => a.publicKey); + + // Fetch profiles and balances so we can show usernames in the UI (if we have them) + this.backendApi + .GetUserProfiles(profileKeysToFetch) + .pipe( + take(1), + finalize(() => (this.loadingAccounts = false)) + ) + .subscribe((users) => { + const unorderedAccountGroups: typeof this.accountGroups = new Map(); + Array.from(accountGroupsByRootKey.entries()).forEach( + ([key, accounts]) => { + unorderedAccountGroups.set(key, { + accounts: accounts.map((account) => ({ + ...account, + ...users[account.publicKey], + })), + }); + } + ); + + // To sort the accounts holistically across groups, we need to flatten + // the Map values into a single array. Once they're sorted, we can determine + // which account was last used and mark it as such. There can be a case where + // no account is "last used" if the user has never logged in to any account and + // simply loaded or added accounts to the wallet. In this case, we don't mark + // any account as "last used". + const allAccounts = Array.from(unorderedAccountGroups.values()) + .map((a) => a.accounts) + .flat(); + const sortedAccounts = allAccounts.sort(sortAccounts); + const lastUsedAccount = sortedAccounts.find( + (a) => + typeof a.lastLoginTimestamp === 'number' && a.lastLoginTimestamp > 0 + ); + + if (lastUsedAccount) { + lastUsedAccount.lastUsed = true; + } + + sortedAccounts.forEach((account) => { + const group = this.accountGroups.get(account.rootPublicKey); + if (group?.accounts?.length) { + group.accounts.push(account); + } else { + this.accountGroups.set(account.rootPublicKey, { + showRecoverSubAccountInput: false, + accounts: [account], + }); + } + }); + }); + } + + /** + * We need this to address angular's weird default sorting of Maps by key when + * iterating in the template. See this issue for details. We just want to + * preserve the natural order of the Map entries: + * https://github.com/angular/angular/issues/31420 + */ + keyValueSort() { + return 1; + } + + getLoginMethodIcon(loginMethod: LoginMethod = LoginMethod.DESO): string { + return { + [LoginMethod.DESO]: 'assets/logo-deso-mark.svg', + [LoginMethod.GOOGLE]: 'assets/google_logo.svg', + [LoginMethod.METAMASK]: 'assets/metamask.png', + }[loginMethod]; + } + + selectAccount(publicKey: string) { + this.accountService.updateAccountInfo(publicKey, { + lastLoginTimestamp: Date.now(), + }); + this.onAccountSelect.emit(publicKey); + } + + hideAccount(groupKey: string, account: AccountViewModel) { + // NOTE: if there is at least 1 sub account left in the group after hiding this account, + // the user only needs the account number to recover it. If there are no sub accounts left, + // the user needs the seed phrase + the account number to recover it. + const group = this.accountGroups.get(groupKey) ?? { + accounts: [], + }; + // get a copy of the underlying array so we can preview what it looks like when hiding this account + const hiddenPreview = group.accounts + .slice() + .filter((a) => a.accountNumber !== account.accountNumber); + + const dialogRef = this.dialog.open(RemoveAccountDialogComponent, { + data: { + publicKey: account.publicKey, + accountNumber: account.accountNumber, + username: account.username, + isLastAccountInGroup: hiddenPreview.length === 0, + }, + }); + + dialogRef.afterClosed().subscribe((confirmed) => { + if (confirmed) { + this.accountService.updateAccountInfo(account.publicKey, { + isHidden: true, + }); + group.accounts = hiddenPreview; + this.accountGroups.set(groupKey, group); + + // if removing the last used account, select the next last used account + // in the list, if one exists. + if (account.lastUsed) { + const allAccounts = Array.from(this.accountGroups.values()) + .map((a) => a.accounts) + .flat(); + const sortedAccounts = allAccounts.sort(sortAccounts); + const lastUsedAccount = sortedAccounts.find( + (a) => + typeof a.lastLoginTimestamp === 'number' && + a.lastLoginTimestamp > 0 + ); + if (lastUsedAccount) { + lastUsedAccount.lastUsed = true; + } + } + } + }); + } + + addSubAccount( + rootPublicKey: string, + { accountNumber }: { accountNumber?: number } = {} + ) { + const addedAccountNumber = this.accountService.addSubAccount( + rootPublicKey, + { accountNumber } + ); + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + addedAccountNumber + ); + // Check if this account has profile, balance, etc, and add it to the list. + this.backendApi + .GetUserProfiles([publicKeyBase58]) + .pipe(take(1)) + .subscribe((users) => { + const account = { + rootPublicKey: rootPublicKey, + publicKey: publicKeyBase58, + accountNumber: addedAccountNumber, + ...users[publicKeyBase58], + }; + + const group = this.accountGroups.get(rootPublicKey) ?? { + accounts: [], + }; + + // if the account is already in the list, don't add it again... + if (!group.accounts.find((a) => a.accountNumber === accountNumber)) { + group.accounts.push(account); + } + + this.accountGroups.set(rootPublicKey, group); + + // scroll to, and temporarily highlight the account that was just added/recovered + window.requestAnimationFrame(() => { + const scrollContainer = document.getElementById( + 'account-select-group-' + rootPublicKey + ); + const accountElement = document.getElementById( + 'account-select-' + publicKeyBase58 + ); + + if (scrollContainer && accountElement) { + scrollContainer.scrollTop = accountElement.offsetTop; + } + }); + + this.justAddedPublicKey = publicKeyBase58; + setTimeout(() => { + this.justAddedPublicKey = undefined; + }, 2000); + }); + } + + /** + * Shows and hides the "recover sub account" text input. + */ + toggleRecoverSubAccountForm(rootPublicKey: string) { + const group = this.accountGroups.get(rootPublicKey); + if (!group) { + return; + } + group.showRecoverSubAccountInput = !group.showRecoverSubAccountInput; + this.accountGroups.set(rootPublicKey, group); + } + + recoverSubAccount(event: SubmitEvent, rootPublicKey: string) { + event.preventDefault(); + + if (!isValid32BitUnsignedInt(this.accountNumberToRecover)) { + Swal.fire({ + title: 'Invalid Account Number', + html: `Please enter a valid account number.`, + }); + return; + } + + this.addSubAccount(rootPublicKey, { + accountNumber: this.accountNumberToRecover, + }); + } + + getAccountDisplayName(account: { username?: string; publicKey: string }) { + return account.username ?? account.publicKey; + } + + isMetaMaskAccountGroup(rootPublicKey: string) { + const rootAccount = this.accountService.getAccountInfo(rootPublicKey); + return this.accountService.isMetamaskAccount(rootAccount); + } + + shouldShowExportSeedButton(rootPublicKey: string) { + const rootAccount = this.accountService.getAccountInfo(rootPublicKey); + return !rootAccount.exportDisabled; + } + + exportSeed(rootPublicKey: string) { + this.dialog.open(BackupSeedDialogComponent, { + data: { rootPublicKey }, + }); + } +} diff --git a/src/app/grouped-account-select/recovery-secret/recovery-secret.component.html b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.html new file mode 100644 index 00000000..3324136d --- /dev/null +++ b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.html @@ -0,0 +1,28 @@ +
+ {{ this.isRevealed ? secret : maskedSecret }} +
+
+ + +
diff --git a/src/app/grouped-account-select/recovery-secret/recovery-secret.component.scss b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/recovery-secret/recovery-secret.component.ts b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.ts new file mode 100644 index 00000000..f8181fd5 --- /dev/null +++ b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.ts @@ -0,0 +1,31 @@ +import { Component, Input, OnInit } from '@angular/core'; + +@Component({ + selector: 'recovery-secret', + templateUrl: './recovery-secret.component.html', + styleUrls: ['./recovery-secret.component.scss'], +}) +export class RecoverySecretComponent implements OnInit { + @Input() secret = ''; + + maskedSecret = ''; + isRevealed = false; + copySuccess = false; + + ngOnInit(): void { + this.maskedSecret = this.secret.replace(/\S/g, '*'); + } + + copySecret() { + window.navigator.clipboard.writeText(this.secret).then(() => { + this.copySuccess = true; + setTimeout(() => { + this.copySuccess = false; + }, 1500); + }); + } + + toggleRevealSecret() { + this.isRevealed = !this.isRevealed; + } +} diff --git a/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.html b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.html new file mode 100644 index 00000000..03f43252 --- /dev/null +++ b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.html @@ -0,0 +1,57 @@ +
+
+ +

Remove Account

+
+
+ +

+ Make sure you have backed up your seed phrase before continuing! +

+

+ Your account will be irrecoverable if you lose your seed phrase. +

+
+ +

+ You can recover this account as long as you have the account number. +

+
+ Account number:  {{ + this.data.accountNumber + }} + +
+
+
+
+ + +
+
diff --git a/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.scss b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.ts b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.ts new file mode 100644 index 00000000..18e88b58 --- /dev/null +++ b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.ts @@ -0,0 +1,43 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AccountService } from '../../account.service'; + +@Component({ + selector: 'remove-account-dialog', + templateUrl: './remove-account-dialog.component.html', + styleUrls: ['./remove-account-dialog.component.scss'], +}) +export class RemoveAccountDialogComponent { + copySuccess = false; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public data: { + publicKey: string; + accountNumber: number; + username?: string; + isLastAccountInGroup: boolean; + }, + private accountService: AccountService + ) {} + + copyAccountNumber() { + window.navigator.clipboard + .writeText(this.data.accountNumber.toString()) + .then(() => { + this.copySuccess = true; + setTimeout(() => { + this.copySuccess = false; + }, 1500); + }); + } + + cancel() { + this.dialogRef.close(false); + } + + confirm() { + this.dialogRef.close(true); + } +} diff --git a/src/app/icons/icons.module.ts b/src/app/icons/icons.module.ts index 3d3b5aa0..0dfb9723 100644 --- a/src/app/icons/icons.module.ts +++ b/src/app/icons/icons.module.ts @@ -23,6 +23,8 @@ import { CreditCard, DollarSign, ExternalLink, + Eye, + EyeOff, Feather, Flag, FolderMinus, @@ -48,6 +50,7 @@ import { RefreshCw, Repeat, RotateCw, + Save, Search, Send, Settings, @@ -117,6 +120,8 @@ const icons = { DollarSign, Diamond, ExternalLink, + Eye, + EyeOff, Feather, Flag, FolderMinus, @@ -152,6 +157,7 @@ const icons = { RefreshCw, Repeat, RotateCw, + Save, Search, Send, Settings, diff --git a/src/app/identity.service.ts b/src/app/identity.service.ts index 3710be5c..db6c0268 100644 --- a/src/app/identity.service.ts +++ b/src/app/identity.service.ts @@ -24,9 +24,9 @@ import { TransactionMetadataDeleteUserAssociation, TransactionMetadataFollow, TransactionMetadataLike, - TransactionMetadataNewMessage, TransactionMetadataNFTBid, TransactionMetadataNFTTransfer, + TransactionMetadataNewMessage, TransactionMetadataPrivateMessage, TransactionMetadataSubmitPost, TransactionMetadataSwapIdentity, @@ -215,16 +215,16 @@ export class IdentityService { // Incoming Messages - private handleBurn(data: any): void { - if (!this.approve(data, AccessLevel.Full)) { - return; + private async handleBurn(data: any): Promise { + if (!(await this.approve(data, AccessLevel.Full))) { + return Promise.resolve(); } const { id, - payload: { encryptedSeedHex, unsignedHashes }, + payload: {encryptedSeedHex, unsignedHashes}, } = data; - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); @@ -238,16 +238,16 @@ export class IdentityService { }); } - private handleSignETH(data: any): void { - if (!this.approve(data, AccessLevel.Full)) { + private async handleSignETH(data: any): Promise { + if (!(await this.approve(data, AccessLevel.Full))) { return; } const { id, - payload: { encryptedSeedHex, unsignedHashes }, + payload: {encryptedSeedHex, unsignedHashes}, } = data; - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); @@ -261,7 +261,7 @@ export class IdentityService { }); } - private handleSign(data: any): void { + private async handleSign(data: any): Promise { const { id, payload: { @@ -278,7 +278,7 @@ export class IdentityService { // In the case that approve() fails, it responds with a message indicating // that approvalRequired = true, which the caller can then uses to trigger // the /approve UI. - if (!this.approve(data, requiredAccessLevel)) { + if (!(await this.approve(data, requiredAccessLevel))) { return; } @@ -291,13 +291,11 @@ export class IdentityService { return; } - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const isDerived = !!derivedPublicKeyBase58Check; - const signedTransactionHex = this.signingService.signTransaction( seedHex, transactionHex, @@ -308,9 +306,10 @@ export class IdentityService { signedTransactionHex, }); } + // Encrypt with shared secret - private handleEncrypt(data: any): void { - if (!this.approve(data, AccessLevel.ApproveAll)) { + private async handleEncrypt(data: any): Promise { + if (!(await this.approve(data, AccessLevel.ApproveAll))) { return; } @@ -339,13 +338,13 @@ export class IdentityService { }); return; } - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); let messagingKeyRandomness: string | undefined; if (encryptedMessagingKeyRandomness) { - messagingKeyRandomness = this.cryptoService.decryptSeedHex( + messagingKeyRandomness = await this.cryptoService.decryptSeedHex( encryptedMessagingKeyRandomness, this.globalVars.hostname ); @@ -364,11 +363,11 @@ export class IdentityService { messagingKeyRandomness ); - this.respond(id, { ...encryptedMessage }); + this.respond(id, {...encryptedMessage}); } - private handleDecrypt(data: any): void { - if (!this.approve(data, AccessLevel.ApproveAll)) { + private async handleDecrypt(data: any): Promise { + if (!(await this.approve(data, AccessLevel.ApproveAll))) { return; } @@ -388,13 +387,13 @@ export class IdentityService { } const encryptedSeedHex = data.payload.encryptedSeedHex; - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); let messagingKeyRandomness: string | undefined; if (data.payload.encryptedMessagingKeyRandomness) { - messagingKeyRandomness = this.cryptoService.decryptSeedHex( + messagingKeyRandomness = await this.cryptoService.decryptSeedHex( data.payload.encryptedMessagingKeyRandomness, this.globalVars.hostname ); @@ -415,7 +414,7 @@ export class IdentityService { } catch (e: any) { console.error(e); // We include an empty decryptedHexes response to be backward compatible - this.respond(id, { error: e.message, decryptedHexes: {} }); // no suggestion just throw + this.respond(id, {error: e.message, decryptedHexes: {}}); // no suggestion just throw } } else { // Messages can be V1, V2, or V3. The message entries will indicate version. @@ -429,25 +428,25 @@ export class IdentityService { data.payload.ownerPublicKeyBase58Check ) .then( - (res) => this.respond(id, { decryptedHexes: res }), + (res) => this.respond(id, {decryptedHexes: res}), (err) => { console.error(err); - this.respond(id, { decryptedHexes: {}, error: err }); + this.respond(id, {decryptedHexes: {}, error: err}); } ); } } - private handleJwt(data: any): void { - if (!this.approve(data, AccessLevel.ApproveAll)) { + private async handleJwt(data: any): Promise { + if (!(await this.approve(data, AccessLevel.ApproveAll))) { return; } const { id, - payload: { encryptedSeedHex, derivedPublicKeyBase58Check }, + payload: {encryptedSeedHex, derivedPublicKeyBase58Check}, } = data; - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); @@ -534,15 +533,14 @@ export class IdentityService { return AccessLevel.Full; } - private hasAccessLevel(data: any, requiredAccessLevel: AccessLevel): boolean { + private async hasAccessLevel(data: any, requiredAccessLevel: AccessLevel): Promise { const { - payload: { encryptedSeedHex, accessLevel, accessLevelHmac }, + payload: {encryptedSeedHex, accessLevel, accessLevelHmac}, } = data; if (accessLevel < requiredAccessLevel) { return false; } - - const seedHex = this.cryptoService.decryptSeedHex( + const seedHex = await this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); @@ -556,7 +554,7 @@ export class IdentityService { // This method checks if transaction in the payload has correct outputs for requested AccessLevel. private approveSpending(data: any): boolean { const { - payload: { accessLevel, transactionHex }, + payload: {accessLevel, transactionHex}, } = data; // If the requested access level is ApproveLarge, we want to confirm that transaction doesn't @@ -569,7 +567,7 @@ export class IdentityService { output.publicKey.toString('hex') !== transaction.publicKey.toString('hex') ) { - this.respond(data.id, { approvalRequired: true }); + this.respond(data.id, {approvalRequired: true}); return false; } } @@ -577,14 +575,14 @@ export class IdentityService { return true; } - private approve(data: any, accessLevel: AccessLevel): boolean { - const hasAccess = this.hasAccessLevel(data, accessLevel); + private async approve(data: any, accessLevel: AccessLevel): Promise { + const hasAccess = await this.hasAccessLevel(data, accessLevel); const hasEncryptionKey = this.cryptoService.hasSeedHexEncryptionKey( this.globalVars.hostname ); if (!hasAccess || !hasEncryptionKey) { - this.respond(data.id, { approvalRequired: true }); + this.respond(data.id, {approvalRequired: true}); return false; } @@ -594,8 +592,8 @@ export class IdentityService { // Message handling private handleMessage(event: MessageEvent): void { - const { data } = event; - const { service, method } = data; + const {data} = event; + const {service, method} = data; if (service !== 'identity') { return; @@ -609,12 +607,12 @@ export class IdentityService { } } - private handleRequest(event: MessageEvent): void { + private async handleRequest(event: MessageEvent): Promise { const data = event.data; const method = data.method; if (method === 'burn') { - this.handleBurn(data); + await this.handleBurn(data); } else if (method === 'encrypt') { this.handleEncrypt(data); } else if (method === 'decrypt') { @@ -626,7 +624,7 @@ export class IdentityService { } else if (method === 'jwt') { this.handleJwt(data); } else if (method === 'info') { - this.handleInfo(event); + await this.handleInfo(event); } else { console.error('Unhandled identity request'); console.error(event); @@ -635,7 +633,7 @@ export class IdentityService { private handleResponse(event: MessageEvent): void { const { - data: { id, payload }, + data: {id, payload}, origin, } = event; const hostname = new URL(origin).hostname; diff --git a/src/app/jumio/jumio-error/jumio-error.component.ts b/src/app/jumio/jumio-error/jumio-error.component.ts index 9a2364e9..3b2b1b4f 100644 --- a/src/app/jumio/jumio-error/jumio-error.component.ts +++ b/src/app/jumio/jumio-error/jumio-error.component.ts @@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router'; export class JumioErrorComponent implements OnInit, OnDestroy { publicKey = ''; hostname = ''; + constructor( public globalVars: GlobalVarsService, private activatedRoute: ActivatedRoute, @@ -25,13 +26,15 @@ export class JumioErrorComponent implements OnInit, OnDestroy { }); } - ngOnInit(): void {} + ngOnInit(): void { + } - ngOnDestroy(): void {} + ngOnDestroy(): void { + } - finishFlow(): void { + async finishFlow(): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, signedUp: true, jumioSuccess: false, diff --git a/src/app/jumio/jumio-success/jumio-success.component.ts b/src/app/jumio/jumio-success/jumio-success.component.ts index c5817958..babe78a1 100644 --- a/src/app/jumio/jumio-success/jumio-success.component.ts +++ b/src/app/jumio/jumio-success/jumio-success.component.ts @@ -22,9 +22,9 @@ export class JumioSuccessComponent implements OnInit, OnDestroy { const jumioInternalReference = params.customerInternalReference || ''; this.backendApiService .JumioFlowFinished(publicKey, jumioInternalReference) - .subscribe((res) => { + .subscribe(async (res) => { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: publicKey, signedUp: true, jumioSuccess: true, @@ -33,7 +33,9 @@ export class JumioSuccessComponent implements OnInit, OnDestroy { }); } - ngOnInit(): void {} + ngOnInit(): void { + } - ngOnDestroy(): void {} + ngOnDestroy(): void { + } } diff --git a/src/app/log-in-options/log-in-options.component.html b/src/app/log-in-options/log-in-options.component.html index 0bd186ed..a36c34f6 100644 --- a/src/app/log-in-options/log-in-options.component.html +++ b/src/app/log-in-options/log-in-options.component.html @@ -1,4 +1,4 @@ - diff --git a/src/app/log-in/log-in.component.ts b/src/app/log-in/log-in.component.ts index 37467abd..bd3675ff 100644 --- a/src/app/log-in/log-in.component.ts +++ b/src/app/log-in/log-in.component.ts @@ -21,16 +21,17 @@ export class LogInComponent implements OnInit { private backendApi: BackendAPIService, public globalVars: GlobalVarsService, private router: Router - ) {} + ) { + } ngOnInit(): void { // Set showAccessLevels this.showAccessLevels = !this.globalVars.isFullAccessHostname(); } - login(publicKey: string): void { + async login(publicKey: string): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: publicKey, signedUp: false, }); @@ -39,25 +40,25 @@ export class LogInComponent implements OnInit { navigateToGetDeso(publicKey: string): void { this.router.navigate(['/', RouteNames.GET_DESO], { queryParamsHandling: 'merge', - queryParams: { publicKey }, + queryParams: {publicKey}, }); } - onAccountSelect(publicKey: string): void { + async onAccountSelect(publicKey: string): Promise { this.accountService.setAccessLevel( publicKey, this.globalVars.hostname, this.globalVars.accessLevelRequest ); if (!this.globalVars.getFreeDeso) { - this.login(publicKey); + await this.login(publicKey); } else { this.backendApi.GetUsersStateless([publicKey], true, true).subscribe( - (res) => { + async (res) => { if (!res?.UserList.length || res.UserList[0].BalanceNanos === 0) { this.navigateToGetDeso(publicKey); } else { - this.login(publicKey); + await this.login(publicKey); } }, (err) => { diff --git a/src/app/logout/logout.component.ts b/src/app/logout/logout.component.ts index 44aca2bd..1dc6efa2 100644 --- a/src/app/logout/logout.component.ts +++ b/src/app/logout/logout.component.ts @@ -24,7 +24,8 @@ export class LogoutComponent implements OnInit { private identityService: IdentityService, private accountService: AccountService, public globalVars: GlobalVarsService - ) {} + ) { + } ngOnInit(): void { this.activatedRoute.queryParams.subscribe((params) => { @@ -32,14 +33,14 @@ export class LogoutComponent implements OnInit { }); } - onCancel(): void { + async onCancel(): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, }); } - onSubmit(): void { + async onSubmit(): Promise { // We set the accessLevel for the logged out user to None. this.accountService.setAccessLevel( this.publicKey, @@ -50,12 +51,12 @@ export class LogoutComponent implements OnInit { // the logged out user, will regenerate their encryptedSeedHex. Without this, // someone could have reused the encryptedSeedHex of an already logged out user. this.cryptoService.seedHexEncryptionKey(this.globalVars.hostname, true); - this.finishFlow(); + await this.finishFlow(); } - finishFlow(): void { + async finishFlow(): Promise { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), }); } } diff --git a/src/app/messaging-group/messaging-group.component.ts b/src/app/messaging-group/messaging-group.component.ts index e0c956a7..a264134b 100644 --- a/src/app/messaging-group/messaging-group.component.ts +++ b/src/app/messaging-group/messaging-group.component.ts @@ -48,7 +48,8 @@ export class MessagingGroupComponent implements OnInit { private activatedRoute: ActivatedRoute, private cryptoService: CryptoService, private signingService: SigningService - ) {} + ) { + } ngOnInit(): void { try { @@ -182,7 +183,7 @@ export class MessagingGroupComponent implements OnInit { const membersSetNonEmpty = this.updatedMembersPublicKeysBase58Check.length > 0 && this.updatedMembersPublicKeysBase58Check.length === - this.updatedMembersKeyNames.length; + this.updatedMembersKeyNames.length; let validityCondition = groupSet; switch (this.operation) { @@ -252,9 +253,9 @@ export class MessagingGroupComponent implements OnInit { this.updatedGroupKeyName ); let encryptedMessagingKeyRandomness: string | undefined; - const publicUser = - this.accountService.getEncryptedUsers()[ - this.updatedGroupOwnerPublicKeyBase58Check + const publicUser = (await + this.accountService.getEncryptedUsers())[ + this.updatedGroupOwnerPublicKeyBase58Check ]; if (publicUser?.encryptedMessagingKeyRandomness) { encryptedMessagingKeyRandomness = @@ -331,6 +332,7 @@ export class MessagingGroupComponent implements OnInit { throw new Error('Error invalid operation'); } } + respondToClient( messagingKeySignature: string, encryptedToApplicationGroupMessagingPrivateKey: string, @@ -347,5 +349,6 @@ export class MessagingGroupComponent implements OnInit { }); } - onAccountSelect(event: any): void {} + onAccountSelect(event: any): void { + } } diff --git a/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.html b/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.html index 821de89f..c7460a7f 100644 --- a/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.html +++ b/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.html @@ -79,8 +79,8 @@ />

Get free $DESO

- We'll send you a small amount of $DESO that will last
you up to - thousands of on-chain transactions. + We'll send you a small amount of $DESO that will last  
+ you up to thousands of on-chain transactions.

diff --git a/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.ts b/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.ts index 7657cb9e..c7bde0ab 100644 --- a/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.ts +++ b/src/app/sign-up-get-starter-deso/sign-up-get-starter-deso.component.ts @@ -74,7 +74,8 @@ export class SignUpGetStarterDESOComponent implements OnInit { private identityService: IdentityService, private accountService: AccountService, private router: Router - ) {} + ) { + } ngOnInit(): void { this.activatedRoute.queryParams.subscribe((params) => { @@ -148,7 +149,7 @@ export class SignUpGetStarterDESOComponent implements OnInit { checkIsValidPhoneNumber() { this.phoneForm.controls.phone.setErrors( - !!this.intlPhoneInputInstance?.isValidNumber() ? null : { invalid: true } + !!this.intlPhoneInputInstance?.isValidNumber() ? null : {invalid: true} ); } @@ -313,18 +314,18 @@ export class SignUpGetStarterDESOComponent implements OnInit { .add(() => (this.submittingPhoneNumberVerificationCode = false)); } - finishFlow(): void { + async finishFlow(): Promise { this.finishFlowEvent.emit(); if (this.globalVars.derive) { - this.router.navigate(['/', RouteNames.DERIVE], { - queryParams: { publicKey: this.publicKey }, + await this.router.navigate(['/', RouteNames.DERIVE], { + queryParams: {publicKey: this.publicKey}, queryParamsHandling: 'merge', }); return; } if (!this.finishFlowEventOnly) { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, signedUp: this.globalVars.signedUp, phoneNumberSuccess: this.isPhoneNumberSuccess, diff --git a/src/app/sign-up-metamask/sign-up-metamask.component.ts b/src/app/sign-up-metamask/sign-up-metamask.component.ts index 5939bb7d..28abf178 100644 --- a/src/app/sign-up-metamask/sign-up-metamask.component.ts +++ b/src/app/sign-up-metamask/sign-up-metamask.component.ts @@ -18,16 +18,19 @@ export const getSpendingLimitsForMetamask = () => { IsUnlimited: true, }; }; + enum SCREEN { CREATE_ACCOUNT = 0, LOADING = 1, AUTHORIZE_MESSAGES = 3, } + enum METAMASK { START = 0, CONNECT = 1, ERROR = 2, } + @Component({ selector: 'app-sign-up-metamask', templateUrl: './sign-up-metamask.component.html', @@ -44,6 +47,7 @@ export class SignUpMetamaskComponent implements OnInit { errorMessage = ''; existingConnectedWallet = ''; showAlternative = false; + constructor( private accountService: AccountService, private identityService: IdentityService, @@ -53,7 +57,9 @@ export class SignUpMetamaskComponent implements OnInit { private signingService: SigningService, private metamaskService: MetamaskService, private router: Router - ) {} + ) { + } + async ngOnInit(): Promise { if (this.globalVars.isMobile()) { this.currentScreen = SCREEN.LOADING; @@ -92,7 +98,7 @@ export class SignUpMetamaskComponent implements OnInit { const network = this.globalVars.network; const expirationBlock = SignUpMetamaskComponent.UNLIMITED_DERIVED_KEY_EXPIRATION; - const { keychain, mnemonic, derivedPublicKeyBase58Check, derivedKeyPair } = + const {keychain, mnemonic, derivedPublicKeyBase58Check, derivedKeyPair} = this.accountService.generateDerivedKey(network); this.metamaskState = METAMASK.CONNECT; @@ -271,20 +277,20 @@ export class SignUpMetamaskComponent implements OnInit { }); } - public login(): void { + public async login(): Promise { this.accountService.setAccessLevel( this.publicKey, this.globalVars.hostname, this.globalVars.accessLevelRequest ); if (this.globalVars.derive) { - this.router.navigate(['/', RouteNames.DERIVE], { - queryParams: { publicKey: this.publicKey }, + await this.router.navigate(['/', RouteNames.DERIVE], { + queryParams: {publicKey: this.publicKey}, queryParamsHandling: 'merge', }); } else { this.identityService.login({ - users: this.accountService.getEncryptedUsers(), + users: await this.accountService.getEncryptedUsers(), publicKeyAdded: this.publicKey, signedUp: true, }); diff --git a/src/app/sign-up/sign-up.component.html b/src/app/sign-up/sign-up.component.html index 57fef6eb..fb5e4f53 100644 --- a/src/app/sign-up/sign-up.component.html +++ b/src/app/sign-up/sign-up.component.html @@ -24,8 +24,8 @@

Safely store your DeSo seed phrase

- Write, download, print, or copy it somewhere
safe and secure that - only you have access to. + Write, download, print, or copy it somewhere
+ safe and secure that only you have access to.

@@ -275,8 +275,9 @@

Safely store your DeSo seed phrase

- If you lose your DeSo seed phrase your account will be lost forever.
Never - enter it anywhere outside of https://identity.deso.org + If you lose your DeSo seed phrase your account will be lost forever. +
+ Never enter it anywhere outside of https://identity.deso.org