diff --git a/src/app/account.service.ts b/src/app/account.service.ts index c62b4492..84d5b8b8 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,26 +81,127 @@ 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 } { 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; } @@ -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 || []; + subAccounts.forEach((subAccount) => { + const subAccountPublicKey = this.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + const accountInfo = this.getAccountInfo(subAccountPublicKey); + const subAccountEncryptedSeedHex = 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); @@ -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'); @@ -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]; } @@ -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,7 +912,7 @@ 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 } = {}; @@ -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 || @@ -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( @@ -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-routing.module.ts b/src/app/app-routing.module.ts index f67c3ae5..18bf522e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -17,6 +17,7 @@ import { BuyDeSoCompletePageComponent } from './buy-deso/buy-deso-complete-page/ import { BuyOrSendDesoComponent } from './buy-or-send-deso/buy-or-send-deso.component'; import { SignUpMetamaskComponent } from './sign-up-metamask/sign-up-metamask.component'; import { MessagingGroupComponent } from './messaging-group/messaging-group.component'; +import { JwtApproveComponent } from './jwt-approve/jwt-approve.component'; export class RouteNames { public static EMBED = 'embed'; @@ -40,6 +41,7 @@ export class RouteNames { public static BUY_DESO = 'buy-deso'; public static BUY_OR_SEND_DESO = 'buy-or-send-deso'; public static MESSAGING_GROUP = 'messaging-group'; + public static JWT = 'jwt'; } const routes: Routes = [ @@ -96,6 +98,11 @@ const routes: Routes = [ component: MessagingGroupComponent, pathMatch: 'full', }, + { + path: RouteNames.JWT, + component: JwtApproveComponent, + pathMatch: 'full', + }, ]; @NgModule({ 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..d5354a1d 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'; @@ -55,6 +60,7 @@ import { TransactionSpendingLimitDaoCoinLimitOrderComponent } from './transactio import { TransactionSpendingLimitNftComponent } from './transaction-spending-limit/transaction-spending-limit-nft/transaction-spending-limit-nft.component'; import { TransactionSpendingLimitSectionComponent } from './transaction-spending-limit/transaction-spending-limit-section/transaction-spending-limit-section.component'; import { TransactionSpendingLimitComponent } from './transaction-spending-limit/transaction-spending-limit.component'; +import { JwtApproveComponent } from './jwt-approve/jwt-approve.component'; @NgModule({ declarations: [ @@ -98,6 +104,11 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ TransactionSpendingLimitAssociationComponent, TransactionSpendingLimitAccessGroupComponent, TransactionSpendingLimitAccessGroupMemberComponent, + GroupedAccountSelectComponent, + RecoverySecretComponent, + BackupSeedDialogComponent, + RemoveAccountDialogComponent, + JwtApproveComponent, ], imports: [ BrowserModule, @@ -114,6 +125,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..b8ed327d 100644 --- a/src/app/approve/approve.component.ts +++ b/src/app/approve/approve.component.ts @@ -100,10 +100,10 @@ export class ApproveComponent implements OnInit { } onSubmit(): void { - const user = this.accountService.getEncryptedUsers()[this.publicKey]; - const isDerived = this.accountService.isMetamaskAccount(user); + 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 ); @@ -117,15 +117,6 @@ export class ApproveComponent implements OnInit { }); } - 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[] = []; diff --git a/src/app/auth/google/google.component.ts b/src/app/auth/google/google.component.ts index a1f7bdea..0ce22676 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'; @@ -83,17 +83,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); @@ -137,16 +138,17 @@ 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; }); diff --git a/src/app/backend-api.service.ts b/src/app/backend-api.service.ts index ca4bc41b..28ebd385 100644 --- a/src/app/backend-api.service.ts +++ b/src/app/backend-api.service.ts @@ -291,24 +291,20 @@ 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); + const jwt = this.signingService.signJWT(account.seedHex, isDerived); return this.post(path, { ...body, ...{ JWT: jwt } }); } @@ -349,7 +345,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 +354,7 @@ export class BackendAPIService { userProfiles[user.PublicKeyBase58Check] = { username: user.ProfileEntryResponse?.Username, profilePic: user.ProfileEntryResponse?.ProfilePic, + balanceNanos: user.BalanceNanos, }; } return userProfiles; diff --git a/src/app/crypto.service.ts b/src/app/crypto.service.ts index 4196272c..1c451751 100644 --- a/src/app/crypto.service.ts +++ b/src/app/crypto.service.ts @@ -138,20 +138,29 @@ 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); } @@ -160,7 +169,7 @@ export class CryptoService { encryptedSeedHex, this.globalVars.hostname ); - const privateKey = this.seedHexToPrivateKey(seedHex); + const privateKey = this.seedHexToKeyPair(seedHex); return this.privateKeyToDeSoPublicKey(privateKey, this.globalVars.network); } @@ -279,3 +288,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 +64,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; } 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/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..dd328186 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, @@ -55,6 +55,11 @@ export type DerivePayload = { blockHeight: number; }; +export type IdentityJwtPayload = { + publicKey: string; + expirationDays: number; +}; + export type MessagingGroupPayload = { messagingKeySignature: string; encryptedToApplicationGroupMessagingPrivateKey: string; @@ -140,6 +145,33 @@ export class IdentityService { } } + jwt(payload: IdentityJwtPayload): void { + const privateUserInfo = this.accountService.getAccountInfo( + payload.publicKey + ); + const jwt = this.signingService.signJWT(privateUserInfo.seedHex, false, { + expiration: payload.expirationDays, + }); + const response = { + publicKey: payload.publicKey, + jwt, + expirationDays: payload.expirationDays, + }; + if (this.globalVars.callback) { + // If callback is passed, we redirect to it with payload as URL parameters. + let httpParams = new HttpParams(); + for (const key in response) { + if (payload.hasOwnProperty(key)) { + httpParams = httpParams.append(key, (payload as any)[key].toString()); + } + } + window.location.href = + this.globalVars.callback + `?${httpParams.toString()}`; + } else { + this.cast('jwt', response); + } + } + derive(payload: DerivePayload): Promise { return this.accountService .getDerivedPrivateUser( @@ -295,9 +327,7 @@ export class IdentityService { encryptedSeedHex, this.globalVars.hostname ); - const isDerived = !!derivedPublicKeyBase58Check; - const signedTransactionHex = this.signingService.signTransaction( seedHex, transactionHex, @@ -308,6 +338,7 @@ export class IdentityService { signedTransactionHex, }); } + // Encrypt with shared secret private handleEncrypt(data: any): void { if (!this.approve(data, AccessLevel.ApproveAll)) { @@ -541,7 +572,6 @@ export class IdentityService { if (accessLevel < requiredAccessLevel) { return false; } - const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname diff --git a/src/app/jwt-approve/jwt-approve.component.html b/src/app/jwt-approve/jwt-approve.component.html new file mode 100644 index 00000000..7bb32901 --- /dev/null +++ b/src/app/jwt-approve/jwt-approve.component.html @@ -0,0 +1,32 @@ + + +
+

Approve a JWT

+
+

+ {{ globalVars.hostname }} wants to generate a JWT +

+
+ Public Key: {{ publicKey }} +
+
+ Expiration Days: {{ expirationDays }} +
+
+ + + + + + + + +
+
+
diff --git a/src/app/jwt-approve/jwt-approve.component.scss b/src/app/jwt-approve/jwt-approve.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/jwt-approve/jwt-approve.component.spec.ts b/src/app/jwt-approve/jwt-approve.component.spec.ts new file mode 100644 index 00000000..167f8f5b --- /dev/null +++ b/src/app/jwt-approve/jwt-approve.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { JwtApproveComponent } from './jwt-approve.component'; + +describe('JwtApproveComponent', () => { + let component: JwtApproveComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [JwtApproveComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(JwtApproveComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/jwt-approve/jwt-approve.component.ts b/src/app/jwt-approve/jwt-approve.component.ts new file mode 100644 index 00000000..4a2747ab --- /dev/null +++ b/src/app/jwt-approve/jwt-approve.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { GlobalVarsService } from '../global-vars.service'; +import { IdentityService } from '../identity.service'; + +@Component({ + selector: 'app-jwt-approve', + templateUrl: './jwt-approve.component.html', + styleUrls: ['./jwt-approve.component.scss'], +}) +export class JwtApproveComponent implements OnInit { + publicKey: any; + expirationDays = 30; + + constructor( + private activatedRoute: ActivatedRoute, + private identityService: IdentityService, + public globalVars: GlobalVarsService + ) {} + + ngOnInit(): void { + this.activatedRoute.queryParams.subscribe((params) => { + this.publicKey = params.publicKey; + this.expirationDays = Number(params.expirationDays) || 30; + }); + } + + onSubmit(): void { + this.identityService.jwt({ + publicKey: this.publicKey, + expirationDays: this.expirationDays, + }); + } +} 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/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/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