diff --git a/src/app/account.service.ts b/src/app/account.service.ts index c62b4492..0f95d29f 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, @@ -38,6 +39,12 @@ import { MetamaskService } from './metamask.service'; import { SigningService } from './signing.service'; export const ERROR_USER_NOT_FOUND = 'User not found'; +const SUB_ACCOUNT_REVERSE_LOOKUP_KEY = 'subAccountReverseLookup'; + +export interface SubAccountReversLookupEntry { + lookupKey: string; + accountNumber: number; +} @Injectable({ providedIn: 'root', @@ -51,21 +58,89 @@ export class AccountService { constructor( private cryptoService: CryptoService, private globalVars: GlobalVarsService, - private cookieService: CookieService, private entropyService: EntropyService, private signingService: SigningService, private metamaskService: MetamaskService - ) {} + ) { + this.initializeSubAccountReverseLookup(); + } // Public Getters getPublicKeys(): any { - return Object.keys(this.getPrivateUsers()); + return Object.keys(this.getRootLevelUsers()); + } + + 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) { + info = { + ...rootUser, + ...foundAccount, + }; + } + } + + 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 privateUsers = this.getRootLevelUsers(); const publicUsers: { [key: string]: PublicUserInfo } = {}; for (const publicKey of Object.keys(privateUsers)) { @@ -115,14 +190,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 +222,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 +232,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; @@ -181,8 +246,9 @@ export class AccountService { // Derived keys JWT with the same expiration as the derived key. This is needed for some backend endpoints. derivedJwt = this.signingService.signJWT( derivedSeedHex, + 0, // NOTE: derived keys are always generated with account number 0. true, - `${numDaysBeforeExpiration} days` + options ); } else { // If the user has passed in a derived public key, use that instead. @@ -196,9 +262,10 @@ 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, + account.seedHex, + account.accountNumber, isMetamask, - `${numDaysBeforeExpiration} days` + options ); // Generate new btc and eth deposit addresses for the derived key. @@ -295,9 +362,11 @@ export class AccountService { ); } } else { - accessSignature = this.signingService.signHashes(privateUser.seedHex, [ - accessHash, - ])[0]; + accessSignature = this.signingService.signHashes( + account.seedHex, + [accessHash], + account.accountNumber + )[0]; } const { messagingPublicKeyBase58Check, @@ -329,7 +398,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 +504,25 @@ export class AccountService { mnemonic: string, extraText: string, network: Network, - google?: boolean + accountNumber: number, + options: { + google?: boolean; + } = {} ): string { + // if the account number is provided, and it is greater than 0, this is a sub account. + if (typeof accountNumber === 'number' && accountNumber > 0) { + // We've already stored the sub account in the root user's subAccounts array, + // so we can just return it's public key directly here. + const seedHex = this.cryptoService.keychainToSeedHex(keychain); + const keyPair = this.cryptoService.seedHexToKeyPair( + seedHex, + accountNumber + ); + return this.cryptoService.publicKeyToDeSoPublicKey(keyPair, network); + } + const seedHex = this.cryptoService.keychainToSeedHex(keychain); - const keyPair = this.cryptoService.seedHexToPrivateKey(seedHex); + const keyPair = this.cryptoService.seedHexToKeyPair(seedHex, 0); const btcDepositAddress = this.cryptoService.keychainToBtcAddress( // @ts-ignore TODO: add "identifier" to type definition keychain.identifier, @@ -447,7 +531,7 @@ export class AccountService { const ethDepositAddress = this.cryptoService.publicKeyToEthAddress(keyPair); let loginMethod: LoginMethod = LoginMethod.DESO; - if (google) { + if (options.google) { loginMethod = LoginMethod.GOOGLE; } @@ -460,11 +544,12 @@ export class AccountService { network, loginMethod, version: PrivateUserVersion.V2, + lastLoginTimestamp: Date.now(), }); } addUserWithSeedHex(seedHex: string, network: Network): string { - const keyPair = this.cryptoService.seedHexToPrivateKey(seedHex); + const keyPair = this.cryptoService.seedHexToKeyPair(seedHex, 0); const helperKeychain = new HDKey(); helperKeychain.privateKey = Buffer.from(seedHex, 'hex'); // @ts-ignore TODO: add "identifier" to type definition @@ -484,6 +569,7 @@ export class AccountService { network, loginMethod: LoginMethod.DESO, version: PrivateUserVersion.V2, + lastLoginTimestamp: Date.now(), }); } @@ -556,8 +642,9 @@ export class AccountService { // Migrate from V0 -> V1 if (privateUser.version === PrivateUserVersion.V0) { // Add ethDepositAddress field - const keyPair = this.cryptoService.seedHexToPrivateKey( - privateUser.seedHex + const keyPair = this.cryptoService.seedHexToKeyPair( + privateUser.seedHex, + 0 ); privateUser.ethDepositAddress = this.cryptoService.publicKeyToEthAddress(keyPair); @@ -589,12 +676,11 @@ 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, + account.accountNumber + ); const privateKeyBytes = privateKey.getPrivate().toBuffer(undefined, 32); const publicKeyBytes = this.cryptoService.publicKeyToECBuffer(publicKey); const sharedPx = ecies.derive(privateKeyBytes, publicKeyBytes); @@ -606,17 +692,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,9 +725,11 @@ export class AccountService { let messagingKeySignature = ''; if (messagingKeyName === this.globalVars.defaultMessageKeyName) { - messagingKeySignature = this.signingService.signHashes(seedHex, [ - messagingKeyHash, - ])[0]; + messagingKeySignature = this.signingService.signHashes( + account.seedHex, + [messagingKeyHash], + account.accountNumber + )[0]; } return { @@ -740,9 +823,18 @@ export class AccountService { senderGroupKeyName: string, recipientPublicKey: string, message: string, - messagingKeyRandomness: string | undefined + options: { + messagingKeyRandomness?: string; + ownerPublicKeyBase58Check?: string; + } = {} ): any { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const { accountNumber = 0 } = options.ownerPublicKeyBase58Check + ? this.getAccountInfo(options.ownerPublicKeyBase58Check) + : {}; + const privateKey = this.cryptoService.seedHexToKeyPair( + seedHex, + accountNumber + ); const privateKeyBuffer = privateKey.getPrivate().toBuffer(undefined, 32); const publicKeyBuffer = @@ -754,7 +846,7 @@ export class AccountService { privateEncryptionKey = this.getMessagingKeyForSeed( seedHex, senderGroupKeyName, - messagingKeyRandomness + options.messagingKeyRandomness ); } @@ -773,9 +865,16 @@ export class AccountService { // @param encryptedHexes : string[] decryptMessagesLegacy( seedHex: string, - encryptedHexes: any + encryptedHexes: any, + options: { ownerPublicKeyBase58Check?: string } = {} ): { [key: string]: any } { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const { accountNumber = 0 } = options.ownerPublicKeyBase58Check + ? this.getAccountInfo(options.ownerPublicKeyBase58Check) + : {}; + const privateKey = this.cryptoService.seedHexToKeyPair( + seedHex, + accountNumber + ); const privateKeyBuffer = privateKey.getPrivate().toBuffer(undefined, 32); const decryptedHexes: { [key: string]: any } = {}; @@ -798,13 +897,21 @@ export class AccountService { seedHex: string, encryptedMessages: EncryptedMessage[], messagingGroups: MessagingGroup[], - messagingKeyRandomness: string | undefined, - ownerPublicKeyBase58Check: string | undefined + options: { + messagingKeyRandomness?: string; + ownerPublicKeyBase58Check?: string; + } = {} ): Promise<{ [key: string]: any }> { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const { accountNumber = 0 } = options.ownerPublicKeyBase58Check + ? this.getAccountInfo(options.ownerPublicKeyBase58Check) + : {}; + const privateKey = this.cryptoService.seedHexToKeyPair( + seedHex, + accountNumber + ); const myPublicKey = - ownerPublicKeyBase58Check || + options.ownerPublicKeyBase58Check || this.cryptoService.privateKeyToDeSoPublicKey( privateKey, this.globalVars.network @@ -905,7 +1012,7 @@ export class AccountService { this.getMessagingKeyForSeed( seedHex, myMessagingGroupMemberEntry.GroupMemberKeyName, - messagingKeyRandomness + options.messagingKeyRandomness ); privateEncryptionKey = this.signingService .decryptGroupMessagingPrivateKeyToMember( @@ -928,7 +1035,7 @@ export class AccountService { privateEncryptionKey = this.getMessagingKeyForSeed( seedHex, this.globalVars.defaultMessageKeyName, - messagingKeyRandomness + options.messagingKeyRandomness ); } } catch (e: any) { @@ -953,12 +1060,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, 0); // Metamask login will be added with the master public key. let publicKey = this.cryptoService.privateKeyToDeSoPublicKey( @@ -1020,11 +1124,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 +1152,168 @@ 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 parentSeedHex = parentAccount.seedHex; + const childKey = this.cryptoService.getSubAccountKeychain( + parentSeedHex, + accountNumber + ); + 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 +1325,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/approve/approve.component.ts b/src/app/approve/approve.component.ts index 51846e43..9001d3d8 100644 --- a/src/app/approve/approve.component.ts +++ b/src/app/approve/approve.component.ts @@ -100,12 +100,13 @@ 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 + isDerived, + account.accountNumber ); this.finishFlow(signedTransactionHex); } @@ -117,15 +118,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..771dbd52 100644 --- a/src/app/auth/google/google.component.ts +++ b/src/app/auth/google/google.component.ts @@ -93,7 +93,10 @@ export class GoogleComponent implements OnInit { mnemonic, extraText, network, - true + 0, + { + google: true, + } ); } catch (err) { console.error(err); @@ -146,7 +149,10 @@ export class GoogleComponent implements OnInit { mnemonic, extraText, network, - true + 0, + { + google: true, + } ); this.loading = false; }); diff --git a/src/app/backend-api.service.ts b/src/app/backend-api.service.ts index dbf03a47..0a0ca067 100644 --- a/src/app/backend-api.service.ts +++ b/src/app/backend-api.service.ts @@ -291,24 +291,24 @@ 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( + account.seedHex, + account.accountNumber, + isDerived ); - const jwt = this.signingService.signJWT(seedHex, isDerived); return this.post(path, { ...body, ...{ JWT: jwt } }); } diff --git a/src/app/crypto.service.ts b/src/app/crypto.service.ts index 4196272c..f70449dc 100644 --- a/src/app/crypto.service.ts +++ b/src/app/crypto.service.ts @@ -142,25 +142,49 @@ export class CryptoService { nonStandard?: boolean ): HDNode { const seed = bip39.mnemonicToSeedSync(mnemonic, extraText); - // @ts-ignore - return HDKey.fromMasterSeed(seed).derive("m/44'/0'/0'/0/0", nonStandard); + return deriveKeys(seed, 0, { + nonStandard + }); + } + + getSubAccountKeychain(masterSeedHex: string, accountIndex: number): HDNode { + const seedBytes = Buffer.from(masterSeedHex, 'hex'); + return deriveKeys(seedBytes, accountIndex); } keychainToSeedHex(keychain: HDNode): string { return keychain.privateKey.toString('hex'); } - seedHexToPrivateKey(seedHex: string): EC.KeyPair { + /** + * For a given parent seed hex and account number, return the corresponding private key. Public/private + * key pairs are independent and unique based on a combination of the seed hex and account number. + * @param parentSeedHex This is the seed hex used to generate multiple HD wallets/keys from a single seed. + * @param accountNumber This is the account number used to generate unique keys from the parent seed. + * @returns + */ + seedHexToKeyPair(parentSeedHex: string, accountNumber: number): EC.KeyPair { const ec = new EC('secp256k1'); + + if (accountNumber === 0) { + return ec.keyFromPrivate(parentSeedHex); + } + + const hdKeys = this.getSubAccountKeychain( + parentSeedHex, + accountNumber + ); + const seedHex = this.keychainToSeedHex(hdKeys); + return ec.keyFromPrivate(seedHex); } - encryptedSeedHexToPublicKey(encryptedSeedHex: string): string { + encryptedSeedHexToPublicKey(encryptedSeedHex: string, accountNumber: number): string { const seedHex = this.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const privateKey = this.seedHexToPrivateKey(seedHex); + const privateKey = this.seedHexToKeyPair(seedHex, accountNumber); return this.privateKeyToDeSoPublicKey(privateKey, this.globalVars.network); } @@ -279,3 +303,15 @@ 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 deriveKeys(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. + // @ts-ignore + return HDKey.fromMasterSeed(seedBytes).derive(`m/44'/0'/${accountIndex}'/0/0`, !!options?.nonStandard); +} diff --git a/src/app/identity.service.ts b/src/app/identity.service.ts index 3710be5c..070101bb 100644 --- a/src/app/identity.service.ts +++ b/src/app/identity.service.ts @@ -222,15 +222,19 @@ export class IdentityService { const { id, - payload: { encryptedSeedHex, unsignedHashes }, + payload: { encryptedSeedHex, unsignedHashes, ownerPublicKeyBase58Check }, } = data; const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); + const { accountNumber = 0 } = ownerPublicKeyBase58Check + ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) + : {}; const signedHashes = this.signingService.signHashes( seedHex, - unsignedHashes + unsignedHashes, + accountNumber ); this.respond(id, { @@ -245,15 +249,19 @@ export class IdentityService { const { id, - payload: { encryptedSeedHex, unsignedHashes }, + payload: { encryptedSeedHex, unsignedHashes, ownerPublicKeyBase58Check }, } = data; const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); + const { accountNumber = 0 } = ownerPublicKeyBase58Check + ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) + : {}; const signatures = this.signingService.signHashesETH( seedHex, - unsignedHashes + unsignedHashes, + accountNumber ); this.respond(id, { @@ -268,6 +276,7 @@ export class IdentityService { encryptedSeedHex, transactionHex, derivedPublicKeyBase58Check, + ownerPublicKeyBase58Check, }, } = data; @@ -295,13 +304,15 @@ export class IdentityService { encryptedSeedHex, this.globalVars.hostname ); - const isDerived = !!derivedPublicKeyBase58Check; - + const { accountNumber = 0 } = ownerPublicKeyBase58Check + ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) + : {}; const signedTransactionHex = this.signingService.signTransaction( seedHex, transactionHex, - isDerived + isDerived, + accountNumber ); this.respond(id, { @@ -361,7 +372,10 @@ export class IdentityService { senderGroupKeyName, recipientPublicKey, message, - messagingKeyRandomness + { + ownerPublicKeyBase58Check, + messagingKeyRandomness, + } ); this.respond(id, { ...encryptedMessage }); @@ -407,7 +421,8 @@ export class IdentityService { try { const decryptedHexes = this.accountService.decryptMessagesLegacy( seedHex, - encryptedHexes + encryptedHexes, + data.payload.ownerPublicKeyBase58Check ); this.respond(id, { decryptedHexes, @@ -425,8 +440,10 @@ export class IdentityService { seedHex, encryptedMessages, data.payload.messagingGroups || [], - messagingKeyRandomness, - data.payload.ownerPublicKeyBase58Check + { + messagingKeyRandomness, + ownerPublicKeyBase58Check: data.payload.ownerPublicKeyBase58Check, + } ) .then( (res) => this.respond(id, { decryptedHexes: res }), @@ -445,14 +462,17 @@ export class IdentityService { const { id, - payload: { encryptedSeedHex, derivedPublicKeyBase58Check }, + payload: { encryptedSeedHex, derivedPublicKeyBase58Check, ownerPublicKeyBase58Check }, } = data; const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); + const { accountNumber = 0 } = ownerPublicKeyBase58Check + ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) + : {}; const isDerived = !!derivedPublicKeyBase58Check; - const jwt = this.signingService.signJWT(seedHex, isDerived); + const jwt = this.signingService.signJWT(seedHex, accountNumber, isDerived); this.respond(id, { jwt, diff --git a/src/app/log-in-seed/log-in-seed.component.ts b/src/app/log-in-seed/log-in-seed.component.ts index 1e0bc3bc..b04fe945 100644 --- a/src/app/log-in-seed/log-in-seed.component.ts +++ b/src/app/log-in-seed/log-in-seed.component.ts @@ -85,41 +85,40 @@ export class LogInSeedComponent implements OnInit { keychain, mnemonic, extraText, - network + network, + 0 ); // NOTE: Temporary support for 1 in 128 legacy users who have non-standard derivations if (keychain.publicKey !== keychainNonStandard.publicKey) { - const seedHex = - this.cryptoService.keychainToSeedHex(keychainNonStandard); - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const seedHex = this.cryptoService.keychainToSeedHex(keychainNonStandard); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex, 0); const publicKey = this.cryptoService.privateKeyToDeSoPublicKey( privateKey, network ); // We only want to add nonStandard derivations if the account is worth importing - this.backendApi - .GetUsersStateless([publicKey], true, true) - .subscribe((res) => { - if (!res.UserList.length) { - return; - } - const user = res.UserList[0]; - if ( - user.ProfileEntryResponse || - user.BalanceNanos > 0 || - user.UsersYouHODL?.length - ) { - // Add the non-standard key if the user has a profile, a balance, or holdings - userPublicKey = this.accountService.addUser( - keychainNonStandard, - mnemonic, - extraText, - network - ); - } - }); + this.backendApi.GetUsersStateless([publicKey]).subscribe((res) => { + if (!res.UserList.length) { + return; + } + const user = res.UserList[0]; + if ( + user.ProfileEntryResponse || + user.BalanceNanos > 0 || + user.UsersYouHODL?.length + ) { + // Add the non-standard key if the user has a profile, a balance, or holdings + userPublicKey = this.accountService.addUser( + keychainNonStandard, + mnemonic, + extraText, + network, + 0 + ); + } + }); } } 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..f9d33cab 100644 --- a/src/app/sign-up-metamask/sign-up-metamask.component.ts +++ b/src/app/sign-up-metamask/sign-up-metamask.component.ts @@ -236,7 +236,8 @@ export class SignUpMetamaskComponent implements OnInit { const signedTransactionHex = this.signingService.signTransaction( derivedKeyPair.getPrivate().toString('hex'), authorizeDerivedKeyResponse.TransactionHex, - true + true, + 0 ); this.backendApi diff --git a/src/app/sign-up/sign-up.component.ts b/src/app/sign-up/sign-up.component.ts index 947d970e..74c18f96 100644 --- a/src/app/sign-up/sign-up.component.ts +++ b/src/app/sign-up/sign-up.component.ts @@ -117,7 +117,8 @@ export class SignUpComponent implements OnInit, OnDestroy { keychain, mnemonic, extraText, - network + network, + 0 ); this.accountService.setAccessLevel( diff --git a/src/app/signing.service.ts b/src/app/signing.service.ts index 063d02be..3a2b15fe 100644 --- a/src/app/signing.service.ts +++ b/src/app/signing.service.ts @@ -15,18 +15,22 @@ import { GlobalVarsService } from './global-vars.service'; export class SigningService { constructor( private cryptoService: CryptoService, - private globalVars: GlobalVarsService + private globalVars: GlobalVarsService, ) {} signJWT( seedHex: string, + accountNumber: number, isDerived: boolean, - expiration: string | number = 60 * 10 + { expiration = 60 * 10 }: { expiration?: string | number } = {} ): string { const keyEncoder = new KeyEncoder('secp256k1'); - const encodedPrivateKey = keyEncoder.encodePrivate(seedHex, 'raw', 'pem'); + // TODO: make sure the account number stuff works here... + const acctNumber = isDerived ? 0 : accountNumber; + const keys = this.cryptoService.seedHexToKeyPair(seedHex, acctNumber); + const encodedPrivateKey = keyEncoder.encodePrivate(keys.getPrivate('hex'), 'raw', 'pem'); if (isDerived) { - const derivedPrivateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const derivedPrivateKey = this.cryptoService.seedHexToKeyPair(seedHex, accountNumber); const derivedPublicKeyBase58Check = this.cryptoService.privateKeyToDeSoPublicKey( derivedPrivateKey, @@ -52,9 +56,10 @@ export class SigningService { signTransaction( seedHex: string, transactionHex: string, - isDerivedKey: boolean + isDerivedKey: boolean, + accountNumber: number ): string { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex, accountNumber); const transactionBytes = new Buffer(transactionHex, 'hex'); const [_, v1FieldsBuffer] = TransactionV0.fromBytes(transactionBytes) as [ @@ -83,8 +88,8 @@ export class SigningService { ]).toString('hex'); } - signHashes(seedHex: string, unsignedHashes: string[]): string[] { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + signHashes(seedHex: string, unsignedHashes: string[], accountNumber: number): string[] { + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex, accountNumber); const signedHashes = []; for (const unsignedHash of unsignedHashes) { @@ -98,9 +103,10 @@ export class SigningService { signHashesETH( seedHex: string, - unsignedHashes: string[] + unsignedHashes: string[], + accountNumber: number ): { s: any; r: any; v: number | null }[] { - const privateKey = this.cryptoService.seedHexToPrivateKey(seedHex); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex, accountNumber); const signedHashes = []; for (const unsignedHash of unsignedHashes) { diff --git a/src/lib/account-number.ts b/src/lib/account-number.ts new file mode 100644 index 00000000..c14bf0a4 --- /dev/null +++ b/src/lib/account-number.ts @@ -0,0 +1,53 @@ +/** + * https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#extended-keys + * The max value that should be used for an hd key account number is 2^31 -1. In + * practice, we would never reach this via pure incremental account number + * generation, but we allow entering arbitrary account numbers in the UI, so we + * need to explicitly validate them. For whatever reason, the hdkey library + * we use throws for anything over 2^30. + */ +const MAX_UNSIGNED_INT_VALUE = 1073741824; // 2^30 + +export function isValid32BitUnsignedInt(value: number) { + return ( + Number.isInteger(value) && value >= 0 && value <= MAX_UNSIGNED_INT_VALUE + ); +} + +/** + * - The lowest possible sub-account number is 1 (0 is reserved for the "root" account). + * - If the set is empty, we just return 1. + * - Otherwise, we look for the highest number in the set and increment it by 1. + * - If the next incremented number is too big, we look back for the first gap in the numbers and use that instead. + * - If no gap is found (very unlikely), we throw an error. + */ +export function generateAccountNumber(accountNumbers: Set): number { + if (accountNumbers.size === 0) { + return 1; + } + + const sorted = Array.from(accountNumbers).sort((a, b) => a - b); + const currentHighestAccountNumber = sorted[sorted.length - 1]; + const candidate = currentHighestAccountNumber + 1; + + if (candidate <= MAX_UNSIGNED_INT_VALUE) { + return candidate; + } + + // At most we look back 500 numbers. This is a bit arbitrary... but the + // number of values could *technically* be 2^32 - 1, so we just limit the + // number of iterations to some reasonable value. The reason we look back for + // the highest available number instead of picking the lowest number is that + // the lowest number is more likely to have been used in the past and we're + // aiming to get a fresh wallet. + const maxLookBack = Math.max(sorted.length - 500, 0); + let nextExpectedValueInSequence = currentHighestAccountNumber - 1; + for (let i = sorted.length - 2; i >= maxLookBack; i--) { + if (nextExpectedValueInSequence !== sorted[i]) { + return nextExpectedValueInSequence; + } + nextExpectedValueInSequence--; + } + + throw new Error('Cannot generate account number.'); +} diff --git a/src/types/identity.ts b/src/types/identity.ts index b8fb5657..06f71ba0 100644 --- a/src/types/identity.ts +++ b/src/types/identity.ts @@ -1,6 +1,15 @@ import { TransactionSpendingLimit } from 'src/lib/deso/transaction'; -export interface PrivateUserInfo { +export interface AccountMetadata { + isHidden?: boolean; + lastLoginTimestamp?: number; +} + +export interface SubAccountMetadata extends AccountMetadata { + accountNumber: number; +} + +export interface PrivateUserInfo extends AccountMetadata { seedHex: string; mnemonic: string; publicKeyHex?: string; @@ -12,6 +21,12 @@ export interface PrivateUserInfo { version: PrivateUserVersion; messagingKeyRandomness?: string; derivedPublicKeyBase58Check?: string; + /** + * This is a list of the account numbers that this user has used to generate + * sub-accounts. An account number plus the parent account seed is enough to + * generate or recover a unique sub-account. + */ + subAccounts?: SubAccountMetadata[]; /** DEPRECATED in favor of loginMethod */ google?: boolean; @@ -82,8 +97,15 @@ export interface DefaultKeyPrivateInfo { export interface UserProfile { username: string; profilePic: any; + balanceNanos: number; } +export type Account = UserProfile & { + publicKey: string; + accountNumber: number; +}; +export type ParentAccount = Account & { subAccounts: Account[] }; + export interface DerivedKey { derivedPublicKeyBase58Check: string; ownerPublicKeyBase58Check: string;