diff --git a/package-lock.json b/package-lock.json index d1eb8a3..9847a78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,15 @@ "license": "ISC", "dependencies": { "@toruslabs/constants": "^15.0.0", + "@toruslabs/fetch-node-details": "^15.0.0", "@toruslabs/http-helpers": "^8.1.1", "@toruslabs/loglevel-sentry": "^8.1.0", "@toruslabs/session-manager": "^4.0.2", + "@toruslabs/torus.js": "^16.0.0", "@web3auth/auth": "^10.5.0", "@web3auth/ws-embed": "^5.0.23", "buffer": "^6.0.3", + "jwt-decode": "^4.0.0", "lodash.clonedeep": "^4.5.0", "lodash.merge": "^4.6.2", "lodash.unionby": "^4.8.0", diff --git a/package.json b/package.json index 3758032..303b3bc 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,15 @@ ], "dependencies": { "@toruslabs/constants": "^15.0.0", + "@toruslabs/fetch-node-details": "^15.0.0", "@toruslabs/http-helpers": "^8.1.1", "@toruslabs/loglevel-sentry": "^8.1.0", "@toruslabs/session-manager": "^4.0.2", + "@toruslabs/torus.js": "^16.0.0", "@web3auth/auth": "^10.5.0", "@web3auth/ws-embed": "^5.0.23", "buffer": "^6.0.3", + "jwt-decode": "^4.0.0", "lodash.clonedeep": "^4.5.0", "lodash.merge": "^4.6.2", "lodash.unionby": "^4.8.0", diff --git a/src/Web3Auth.ts b/src/Web3Auth.ts index 2403f2f..50cd902 100644 --- a/src/Web3Auth.ts +++ b/src/Web3Auth.ts @@ -1,8 +1,12 @@ +import { NodeDetailManager } from "@toruslabs/fetch-node-details"; import { SessionManager } from "@toruslabs/session-manager"; +import { keccak256, Torus, TorusKey, VerifierParams } from "@toruslabs/torus.js"; import { AUTH_ACTIONS, + AUTH_CONNECTION, AuthConnectionConfig, type AuthSessionConfig, + AuthUserInfo, type BaseLoginParams, BUILD_ENV, jsonToBase64, @@ -11,6 +15,7 @@ import { type WEB3AUTH_NETWORK_TYPE, } from "@web3auth/auth"; import { type IProvider } from "@web3auth/base"; +import { jwtDecode, JwtPayload } from "jwt-decode"; import clonedeep from "lodash.clonedeep"; import merge from "lodash.merge"; import unionBy from "lodash.unionby"; @@ -18,10 +23,11 @@ import URI from "urijs"; import { Analytics, ANALYTICS_EVENTS, ANALYTICS_INTEGRATION_TYPE, ANALYTICS_SDK_NAME, CHAIN_NAMESPACES, log, sdkVersion } from "./base"; import { InitializationError, LoginError, RequestError } from "./errors"; -import KeyStore from "./session/KeyStore"; +import KeyStore, { KEYSTORE_KEYS } from "./session/KeyStore"; import { EncryptedStorage } from "./types/IEncryptedStorage"; import { SecureStore } from "./types/IExpoSecureStore"; import { + AggregateVerifierParams, AuthSessionData, type ChainsConfig, IWeb3Auth, @@ -30,6 +36,7 @@ import { SdkLoginParams, type SmartAccountsConfig, State, + SubVerifierInfo, WalletLoginParams, } from "./types/interface"; import { IWebBrowser } from "./types/IWebBrowser"; @@ -60,6 +67,10 @@ class Web3Auth implements IWeb3Auth { private analytics: Analytics; + private fetchNodeDetails: NodeDetailManager; + + private torusUtils: Torus; + constructor(webBrowser: IWebBrowser, storage: SecureStore | EncryptedStorage, options: SdkInitParams) { if (!options.clientId) throw InitializationError.invalidParams("clientId is required"); if (!options.privateKeyProvider) { @@ -130,6 +141,13 @@ class Web3Auth implements IWeb3Auth { this.analytics.setGlobalProperties({ integration_type: ANALYTICS_INTEGRATION_TYPE.NATIVE_SDK }); this.privateKeyProvider = options.privateKeyProvider; + this.fetchNodeDetails = new NodeDetailManager({ network: this.options.network }); + this.torusUtils = new Torus({ + network: this.options.network, + clientId: this.options.clientId, + serverTimeOffset: 0, + enableOneKey: true, + }); if (options.accountAbstractionProvider) { this.accountAbstractionProvider = options.accountAbstractionProvider; } @@ -190,10 +208,12 @@ class Web3Auth implements IWeb3Auth { }); try { + const isSFA = await this.checkIsSFAFromStorage(); + this.sessionManager = new SessionManager({ sessionServerBaseUrl: this.options.storageServerUrl, sessionTime: this.options.sessionTime, - sessionNamespace: this.options.sessionNamespace, + sessionNamespace: isSFA ? "sfa" : undefined, }); try { @@ -228,7 +248,20 @@ class Web3Auth implements IWeb3Auth { await this.accountAbstractionProvider.setupProvider(this.privateKeyProvider); } } else { - await this.keyStore.remove("sessionId"); + try { + await this.keyStore.remove("sessionId"); + await this.clearSFAFromStorage(); + } catch (e) { + if (!(await this.handleKeyStoreCorruptedError(e))) { + throw e; + } + this.analytics.track({ + event: ANALYTICS_EVENTS.SDK_INITIALIZATION_KEYSTORE_CORRUPTED, + properties: { + error_message: `SDK initialization keystore corrupted. ${e instanceof Error ? e.message : e}`, + }, + }); + } this.updateState({}); } } @@ -261,89 +294,6 @@ class Web3Auth implements IWeb3Auth { } } - async login(loginParams: SdkLoginParams): Promise { - if (!this.ready) throw InitializationError.notInitialized("Please call init first."); - if (!this.options.redirectUrl) throw InitializationError.invalidParams("redirectUrl is required"); - if (!loginParams.authConnection) throw InitializationError.invalidParams("authConnection is required"); - - const analyticsProperties = { - connector: "auth", - auth_connection: loginParams.authConnection, - auth_connection_id: loginParams.authConnectionId, - group_auth_connection_id: loginParams.groupedAuthConnectionId, - chain_id: this.options.defaultChainId, - dapp_url: loginParams.dappUrl, - chains: this.projectConfig.chains.map((chain) => chain.chainId), - }; - this.analytics.track({ - event: ANALYTICS_EVENTS.CONNECTION_STARTED, - properties: analyticsProperties, - }); - - // check for share - if (this.options.authConnectionConfig) { - const authConnectionConfigItem = this.options.authConnectionConfig[0]; - if (authConnectionConfigItem) { - const share = await this.keyStore.get(authConnectionConfigItem.authConnectionId); - if (share) { - loginParams.dappShare = share; - } - } - } - - const dataObject: AuthSessionConfig = { - actionType: AUTH_ACTIONS.LOGIN, - options: this.options, - params: loginParams, - }; - - const result = await this.authHandler(`${this.baseUrl}/start`, dataObject); - - if (result.type !== "success" || !result.url) { - log.error(`[Web3Auth] login flow failed with error type ${result.type}`); - throw LoginError.loginFailed(`login flow failed with error type ${result.type}`); - } - - const { sessionId, sessionNamespace, error } = getHashQueryParams(result.url); - if (error || !sessionId) { - throw LoginError.loginFailed(error || "SessionId is missing"); - } - - if (sessionId) { - await this.keyStore.set("sessionId", sessionId); - this.sessionManager.sessionId = sessionId; - this.sessionManager.sessionNamespace = sessionNamespace || ""; - } - - const sessionData = await this.authorizeSession(); - - if (!sessionData || Object.keys(sessionData).length === 0) { - throw LoginError.loginFailed("Session data is missing"); - } - - if (sessionData.userInfo?.dappShare.length > 0) { - const verifier = sessionData.userInfo?.groupedAuthConnectionId || sessionData.userInfo?.authConnectionId; - await this.keyStore.set(verifier, sessionData.userInfo?.dappShare); - } - - this.updateState(sessionData); - - const finalPrivKey = this.getFinalPrivKey(); - if (!finalPrivKey) throw LoginError.loginFailed("final private key not found"); - await this.privateKeyProvider.setupProvider(finalPrivKey); - // setup aa provider after private key provider is setup - if (this.accountAbstractionProvider) { - await this.accountAbstractionProvider.setupProvider(this.privateKeyProvider); - } - - this.analytics.track({ - event: ANALYTICS_EVENTS.CONNECTION_COMPLETED, - properties: analyticsProperties, - }); - - return this.provider; - } - async logout(): Promise { if (!this.sessionManager.sessionId) { throw LoginError.userNotLoggedIn(); @@ -357,10 +307,23 @@ class Web3Auth implements IWeb3Auth { const currentUserInfo = this.userInfo(); await this.sessionManager.invalidateSession(); - await this.keyStore.remove("sessionId"); - - if (currentUserInfo.authConnectionId && currentUserInfo.dappShare.length > 0) { - await this.keyStore.remove(currentUserInfo.authConnectionId); + try { + await this.keyStore.remove("sessionId"); + await this.clearSFAFromStorage(); + if (currentUserInfo.authConnectionId && currentUserInfo.dappShare.length > 0) { + const verifier = currentUserInfo.groupedAuthConnectionId || currentUserInfo.authConnectionId; + await this.keyStore.remove(verifier); + } + } catch (e) { + if (!(await this.handleKeyStoreCorruptedError(e))) { + throw e; + } + this.analytics.track({ + event: ANALYTICS_EVENTS.LOGOUT_KEYSTORE_CORRUPTED, + properties: { + error_message: `Logout keystore corrupted. ${e instanceof Error ? e.message : e}`, + }, + }); } this.updateState({ @@ -452,10 +415,13 @@ class Web3Auth implements IWeb3Auth { await this.createLoginSession(loginId, dataObject); const { sessionId } = this.sessionManager; + const isSFA = await this.checkIsSFAFromStorage(); + const configParams: WalletLoginParams = { loginId, sessionId, platform: "react-native", + sessionNamespace: isSFA ? "sfa" : undefined, }; const loginUrl = constructURL({ @@ -704,6 +670,49 @@ class Web3Auth implements IWeb3Auth { throw LoginError.userNotLoggedIn(); } + public async connectTo(loginParams: SdkLoginParams): Promise { + const isSFA = !!loginParams.idToken; + + // recreate session manager with sfa option + this.sessionManager = new SessionManager({ + sessionServerBaseUrl: this.options.storageServerUrl, + sessionTime: this.options.sessionTime, + sessionNamespace: isSFA ? "sfa" : undefined, + }); + + if (isSFA) { + await this.keyStore.set(KEYSTORE_KEYS.IS_SFA, "true"); + if (loginParams.groupedAuthConnectionId) { + const aggregateLoginParams: SdkLoginParams = { + authConnection: AUTH_CONNECTION.CUSTOM, + authConnectionId: loginParams.groupedAuthConnectionId, + idToken: loginParams.idToken, + }; + const subVerifierInfoArray: SubVerifierInfo[] = [ + { + verifier: loginParams.authConnectionId, + idToken: loginParams.idToken, + }, + ]; + await this.connect(aggregateLoginParams, subVerifierInfoArray); + } else { + await this.connect(loginParams); + } + } else { + await this.login(loginParams); + } + + const finalPrivKey = this.getFinalPrivKey(); + if (!finalPrivKey) throw LoginError.loginFailed("final private key not found"); + await this.privateKeyProvider.setupProvider(finalPrivKey); + // setup aa provider after private key provider is setup + if (this.accountAbstractionProvider) { + await this.accountAbstractionProvider.setupProvider(this.privateKeyProvider); + } + + return this.provider; + } + public setAnalyticsProperties(properties: Record) { this.analytics.setGlobalProperties(properties); } @@ -752,6 +761,199 @@ class Web3Auth implements IWeb3Auth { }; } + private async login(loginParams: SdkLoginParams): Promise { + if (!this.ready) throw InitializationError.notInitialized("Please call init first."); + if (!this.options.redirectUrl) throw InitializationError.invalidParams("redirectUrl is required"); + if (!loginParams.authConnection) throw InitializationError.invalidParams("authConnection is required"); + + const analyticsProperties = { + connector: "auth", + auth_connection: loginParams.authConnection, + auth_connection_id: loginParams.authConnectionId, + group_auth_connection_id: loginParams.groupedAuthConnectionId, + chain_id: this.options.defaultChainId, + dapp_url: loginParams.dappUrl, + chains: this.projectConfig.chains.map((chain) => chain.chainId), + }; + this.analytics.track({ + event: ANALYTICS_EVENTS.CONNECTION_STARTED, + properties: analyticsProperties, + }); + + // check for share + if (this.options.authConnectionConfig) { + const authConnectionConfigItem = this.options.authConnectionConfig[0]; + if (authConnectionConfigItem) { + const share = await this.keyStore.get(authConnectionConfigItem.authConnectionId); + if (share) { + loginParams.dappShare = share; + } + } + } + + const dataObject: AuthSessionConfig = { + actionType: AUTH_ACTIONS.LOGIN, + options: this.options, + params: loginParams, + }; + + const result = await this.authHandler(`${this.baseUrl}/start`, dataObject); + + if (result.type !== "success" || !result.url) { + log.error(`[Web3Auth] login flow failed with error type ${result.type}`); + throw LoginError.loginFailed(`login flow failed with error type ${result.type}`); + } + + const { sessionId, sessionNamespace, error } = getHashQueryParams(result.url); + if (error || !sessionId) { + throw LoginError.loginFailed(error || "SessionId is missing"); + } + + if (sessionId) { + await this.keyStore.set("sessionId", sessionId); + this.sessionManager.sessionId = sessionId; + this.sessionManager.sessionNamespace = sessionNamespace || ""; + } + + const sessionData = await this.authorizeSession(); + + if (!sessionData || Object.keys(sessionData).length === 0) { + throw LoginError.loginFailed("Session data is missing"); + } + + if (sessionData.userInfo?.dappShare.length > 0) { + const verifier = sessionData.userInfo?.groupedAuthConnectionId || sessionData.userInfo?.authConnectionId; + await this.keyStore.set(verifier, sessionData.userInfo?.dappShare); + } + + this.updateState(sessionData); + + const finalPrivKey = this.getFinalPrivKey(); + if (!finalPrivKey) throw LoginError.loginFailed("final private key not found"); + await this.privateKeyProvider.setupProvider(finalPrivKey); + // setup aa provider after private key provider is setup + if (this.accountAbstractionProvider) { + await this.accountAbstractionProvider.setupProvider(this.privateKeyProvider); + } + + this.analytics.track({ + event: ANALYTICS_EVENTS.CONNECTION_COMPLETED, + properties: analyticsProperties, + }); + + return this.provider; + } + + /** + * Handle user SFA login. + * @param loginParams - The login parameters. + * @param subVerifierInfoArray - The sub-verifier information array. + * @returns The connected user information. + */ + private async connect(loginParams: SdkLoginParams, subVerifierInfoArray?: SubVerifierInfo[]) { + const torusKey = await this.getTorusKey(loginParams, subVerifierInfoArray); + const privateKey = torusKey.finalKeyData?.privKey ?? torusKey.oAuthKeyData?.privKey; + const decodedUserInfo = jwtDecode( + loginParams.idToken + ); + const userInfo: AuthUserInfo = { + email: decodedUserInfo.email, + name: decodedUserInfo.name ?? decodedUserInfo.nickname, + profileImage: decodedUserInfo.picture, + userId: decodedUserInfo.user_id, + authConnectionId: loginParams.authConnectionId, + authConnection: AUTH_CONNECTION.CUSTOM, + groupedAuthConnectionId: loginParams.groupedAuthConnectionId, + oAuthIdToken: loginParams.idToken, + }; + + const signatures = this.getSessionSignatures(torusKey.sessionData); + + const authSessionData: AuthSessionData = { + privKey: privateKey, + userInfo, + signatures, + }; + const sessionId = SessionManager.generateRandomSessionKey(); + this.sessionManager.sessionId = sessionId; + await this.sessionManager.createSession(authSessionData); + await this.keyStore.set("sessionId", sessionId); + + this.updateState(authSessionData); + } + + private async getTorusKey(loginParams: SdkLoginParams, subVerifierInfoArray?: SubVerifierInfo[]) { + const userId = this.getUserIdFromJWT(loginParams.idToken); + const { torusNodeEndpoints, torusNodePub, torusIndexes } = await this.fetchNodeDetails.getNodeDetails({ + verifier: loginParams.authConnectionId, + verifierId: userId, + }); + + let retrieveSharesResponse: TorusKey; + + if (subVerifierInfoArray && subVerifierInfoArray.length > 0) { + const aggregateIdTokenSeeds: string[] = []; + const subVerifierIds: string[] = []; + const verifyParams: { verifier_id: string; idtoken: string }[] = []; + + for (const subVerifierInfo of subVerifierInfoArray) { + const subVerifierId = this.getUserIdFromJWT(subVerifierInfo.idToken); + subVerifierIds.push(subVerifierId); + aggregateIdTokenSeeds.push(subVerifierInfo.idToken); + verifyParams.push({ verifier_id: userId, idtoken: subVerifierInfo.idToken }); + } + + aggregateIdTokenSeeds.sort(); + + const verifierParams: AggregateVerifierParams = { + verifier_id: userId, + verify_params: verifyParams, + sub_verifier_ids: subVerifierIds, + }; + + const separator = String.fromCharCode(29); + const joined = aggregateIdTokenSeeds.join(separator); + const aggregateIdToken = keccak256(Buffer.from(joined, "utf8")).replace(/^0x/, ""); + + retrieveSharesResponse = await this.torusUtils.retrieveShares({ + endpoints: torusNodeEndpoints, + nodePubkeys: torusNodePub, + indexes: torusIndexes, + verifier: loginParams.authConnectionId, + verifierParams: verifierParams, + idToken: aggregateIdToken, + }); + } else { + const verifierParams: VerifierParams = { + verifier_id: userId, + }; + retrieveSharesResponse = await this.torusUtils.retrieveShares({ + endpoints: torusNodeEndpoints, + nodePubkeys: torusNodePub, + indexes: torusIndexes, + verifier: loginParams.authConnectionId, + verifierParams: verifierParams, + idToken: loginParams.idToken, + }); + } + + const isUpgraded = retrieveSharesResponse.metadata?.upgraded; + if (isUpgraded) { + throw LoginError.mfaAlreadyEnabled(); + } + + return retrieveSharesResponse; + } + + private getSessionSignatures(sessionData: TorusKey["sessionData"]): string[] { + return sessionData.sessionTokenData.filter((i) => Boolean(i)).map((session) => JSON.stringify({ data: session.token, sig: session.signature })); + } + + private getUserIdFromJWT(token: string): string { + const decoded = jwtDecode(token); + return decoded["user_id"]; + } + private updateState(newState: State) { this.state = { ...newState }; } @@ -761,7 +963,6 @@ class Web3Auth implements IWeb3Auth { const loginSessionMgr = new SessionManager({ sessionServerBaseUrl: this.options.storageServerUrl, - sessionNamespace: this.options.sessionNamespace, sessionTime: timeout, // each login key must be used with 10 mins (might be used at the end of popup redirect) sessionId: loginId, }); @@ -777,7 +978,6 @@ class Web3Auth implements IWeb3Auth { const configParams: BaseLoginParams = { loginId, - sessionNamespace: this.options.sessionNamespace, storageServerUrl: this.options.storageServerUrl, }; @@ -789,6 +989,33 @@ class Web3Auth implements IWeb3Auth { return this.webBrowser.openAuthSessionAsync(loginUrl, this.options.redirectUrl); } + private async checkIsSFAFromStorage(): Promise { + const isSFAValue = await this.keyStore.get(KEYSTORE_KEYS.IS_SFA); + return isSFAValue === "true"; + } + + private async clearSFAFromStorage(): Promise { + const sfaValue = await this.keyStore.get(KEYSTORE_KEYS.IS_SFA); + if (sfaValue) { + await this.keyStore.remove(KEYSTORE_KEYS.IS_SFA); + } + } + + /** + * Handle key store corrupted error. + * @param e - The error. + * @returns True if the key store is corrupted, false otherwise. + */ + private async handleKeyStoreCorruptedError(e: unknown): Promise { + if (e instanceof Error && e.message.includes("error occured while removing value")) { + // keystore error might be corrupted, clean up and throw error + await this.keyStore.clear(); + return true; + } + + return false; + } + private async authorizeSession(): Promise { try { const data = await this.sessionManager.authorizeSession({ diff --git a/src/base/analytics.ts b/src/base/analytics.ts index 0d7d022..b04cede 100644 --- a/src/base/analytics.ts +++ b/src/base/analytics.ts @@ -92,6 +92,7 @@ export const ANALYTICS_EVENTS = { // SDK Initialization SDK_INITIALIZATION_COMPLETED: "SDK Initialization Completed", SDK_INITIALIZATION_FAILED: "SDK Initialization Failed", + SDK_INITIALIZATION_KEYSTORE_CORRUPTED: "SDK Initialization Keystore Corrupted", // Connection CONNECTION_STARTED: "Connection Started", CONNECTION_COMPLETED: "Connection Completed", @@ -111,6 +112,7 @@ export const ANALYTICS_EVENTS = { LOGOUT_STARTED: "Logout Started", LOGOUT_COMPLETED: "Logout Completed", LOGOUT_FAILED: "Logout Failed", + LOGOUT_KEYSTORE_CORRUPTED: "Logout Keystore Corrupted", // request REQUEST_FUNCTION_STARTED: "Request Function Started", REQUEST_FUNCTION_COMPLETED: "Request Function Completed", diff --git a/src/session/KeyStore.ts b/src/session/KeyStore.ts index 516da23..6822ca1 100644 --- a/src/session/KeyStore.ts +++ b/src/session/KeyStore.ts @@ -1,6 +1,10 @@ import { EncryptedStorage } from "../types/IEncryptedStorage"; import { SecureStore } from "../types/IExpoSecureStore"; +export const KEYSTORE_KEYS = { + IS_SFA: "sfa_storage_is_sfa", +}; + export default class KeyStore { storage: SecureStore | EncryptedStorage; @@ -28,4 +32,11 @@ export default class KeyStore { } return (this.storage as EncryptedStorage).removeItem(key); } + + async clear() { + if ("clear" in this.storage) { + return (this.storage as EncryptedStorage).clear(); + } + // SecureStore does not have a clear method + } } diff --git a/src/types/interface.ts b/src/types/interface.ts index 61daf03..f285185 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -32,13 +32,15 @@ type SdkSpecificInitParams = { accountAbstractionProvider?: IBaseProvider; }; -export type SdkInitParams = Omit & +export type SdkInitParams = Omit & Required> & { defaultChainId?: string; walletServicesConfig?: WalletServicesConfig; }; -export type SdkLoginParams = Omit; +export type SdkLoginParams = Omit & { + idToken?: string; +}; // export type SdkLogoutParams = Partial & Partial; @@ -68,7 +70,7 @@ export interface IWeb3Auth { provider: IProvider | null; connected: boolean; init: () => Promise; - login: (params: SdkLoginParams) => Promise; + connectTo: (params: SdkLoginParams) => Promise; logout: () => Promise; userInfo: () => State["userInfo"]; enableMFA: () => Promise; @@ -85,6 +87,7 @@ export type WalletLoginParams = { params: unknown[]; }; platform: string; + sessionNamespace?: string; }; export type ChainNamespaceType = (typeof CHAIN_NAMESPACES)[keyof typeof CHAIN_NAMESPACES]; @@ -255,4 +258,15 @@ export type WalletServicesConfig = Omit< modalZIndex?: number; }; +export type SubVerifierInfo = { + verifier: string; + idToken: string; +}; + +export type AggregateVerifierParams = { + verify_params: { verifier_id: string; idtoken: string }[]; + sub_verifier_ids: string[]; + verifier_id: string; +}; + export { AUTH_CONNECTION, BUILD_ENV, LANGUAGES, MFA_FACTOR, MFA_LEVELS, SUPPORTED_KEY_CURVES, THEME_MODES, WEB3AUTH_NETWORK };