UNPKG

@ideem/zsm-react-native

Version:

ZSM makes 2FA easy and invisible for everyone, all the time, using advanced cryptography like MPC to establish cryptographic proof of the origin of any transaction or login attempt, while eliminating opportunities for social engineering. ZSM has no relian

1,118 lines (1,024 loc) 40 kB
import WebAuthnClient from "./webauthn-client"; import ZSMLogger from "./zsm-logger"; import { version as packageVersion } from "../package.json"; import { NativeModules, Platform } from "react-native"; const { ZSM } = NativeModules; // Platform detection helpers const isIOS = Platform.OS === "ios"; const isAndroid = Platform.OS === "android"; /** * Detects if an error represents a passkey ceremony cancellation by the user. * Works across both iOS and Android native bridges. * @param {Error} error - The error to check * @returns {boolean} True if the user cancelled the passkey/biometric prompt */ function isPasskeyCancellation(error) { if (!error) return false; const msg = (error.message || error.toString() || "").toLowerCase(); const code = error.code || ""; // iOS: ASAuthorizationError code 1001 // Android: credential manager cancellation return ( msg.includes("cancel") || msg.includes("cancelled") || msg.includes("user canceled") || msg.includes("1001") || code === "ERR_CANCELED" || code === "ZSM_1001" ); } /** * Extracts passkey fallback metadata from an enroll/authenticate result. * @param {Object} result - The result from enroll/authenticate native bridge call * @returns {Object|null} Fallback info or null if no fallback occurred */ function extractFallbackMetadata(result) { const metadata = result?.metadata; if (!metadata) return null; const passkeyFallback = metadata.passkeyFallback === true || metadata.passkeyFallback === "true"; if (!passkeyFallback) return null; return { passkeyFallback: true, passkeyFailureReason: metadata.passkeyFailureReason || metadata.passkeyNotSupportedReason || "Unknown", authenticationMethod: metadata.authenticationMethod || "zsm_only", }; } function normalizeEnrollmentInfo(enrollmentInfo) { if (!enrollmentInfo || typeof enrollmentInfo !== "object") return enrollmentInfo; const normalizedState = String( enrollmentInfo.stateString || enrollmentInfo.state || "", ).toLowerCase(); const hasMpcFromState = normalizedState === "both" || normalizedState === "mpc_only" || normalizedState === "mpc-only"; const hasPasskeyFromState = normalizedState === "both" || normalizedState === "passkey_only" || normalizedState === "passkey-only"; const hasMpc = enrollmentInfo.mpcEnrollment === true || enrollmentInfo.zsmEnrollment === true || enrollmentInfo.has_mpc === true || enrollmentInfo.hasZSMCred === true || !!enrollmentInfo.mpc_credential_id || !!enrollmentInfo.mpcCredentialId || !!enrollmentInfo.zsmCredentialID || !!enrollmentInfo.zsmCredID || hasMpcFromState; const hasPasskey = enrollmentInfo.passkeyEnrollment === true || enrollmentInfo.has_passkey === true || enrollmentInfo.hasLocalPasskey === true || enrollmentInfo.hasRemotePasskey === true || !!enrollmentInfo.passkey_credential_id || !!enrollmentInfo.passkeyCredentialId || !!enrollmentInfo.pkpCredentialID || !!enrollmentInfo.pkpCredID || hasPasskeyFromState; return { ...enrollmentInfo, mpcEnrollment: hasMpc, zsmEnrollment: hasMpc, passkeyEnrollment: hasPasskey, has_mpc: hasMpc, has_passkey: hasPasskey, hasZSMCred: hasMpc, hasLocalPasskey: hasPasskey, hasRemotePasskey: enrollmentInfo.hasRemotePasskey === true || hasPasskey, enrollmentActive: enrollmentInfo.enrollmentActive === true || hasMpc, is_enrolled: enrollmentInfo.is_enrolled === true || hasMpc || hasPasskey, }; } class UMFAClient { zsmAPI; get version() { return packageVersion; } constructor(config) { window.zsm = {}; this.config = config; this.zsmAPI = new WebAuthnClient(config); this.checkEnrollment = this.checkEnrollment.bind(this); this.enroll = this.enroll.bind(this); this.authenticate = this.authenticate.bind(this); this.resetDevice = this.resetDevice.bind(this); this.listRegisteredUsers = this.listRegisteredUsers.bind(this); this.getCredentialState = this.getCredentialState.bind(this); // Passkeys+ methods - delegate to zsmAPI (WebAuthnClient) this.isPasskeySupported = this.isPasskeySupported.bind(this); this.passkeyNotSupportedReason = this.passkeyNotSupportedReason.bind(this); this.getServerCredentialState = this.getServerCredentialState.bind(this); this.addPasskeyToExistingIdentity = this.addPasskeyToExistingIdentity.bind(this); this.addZsmToPasskeyUser = this.addZsmToPasskeyUser.bind(this); this.bootstrapFromPasskey = this.bootstrapFromPasskey.bind(this); this.isUserEnrolled = this.isUserEnrolled.bind(this); this.getNativeEnrollmentInfo = this.getNativeEnrollmentInfo.bind(this); this.checkAllEnrollments = this.checkAllEnrollments.bind(this); // New passkey-aware authenticate this.authenticateWithPasskey = this.authenticateWithPasskey.bind(this); this.hasPasskeyCredential = this.hasPasskeyCredential.bind(this); } get userIdentifier() { return this.zsmAPI.userIdentifier; } get credentialID() { return this.zsmAPI.credentialID; } transformUserIdentifier(userIdentifier) { return userIdentifier; } checkEnrollment = async (userIdentifier = this.userIdentifier) => { userIdentifier = this.transformUserIdentifier(userIdentifier); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `checkEnrollment called with userIdentifier: ${userIdentifier}, platform: ${Platform.OS}`, traceId, ); if (!ZSM) throw new Error("ZSM native module is not available"); if (isIOS) { // iOS: Use native UMFAClient.checkEnrollment which queries stored credentials const enrollmentInfo = await ZSM.checkEnrollment( userIdentifier, traceId, ); ZSMLogger.debug( `checkEnrollment result (iOS): ${JSON.stringify(enrollmentInfo)}`, traceId, ); // Return boolean: true if enrolled, false otherwise const isEnrolled = enrollmentInfo !== null && enrollmentInfo !== undefined && Object.keys(enrollmentInfo).length > 0; return isEnrolled; } else { // Android: Use native checkEnrollment which queries the server await this.zsmAPI.initializeZsm(userIdentifier); const enrollmentInfo = await ZSM.checkEnrollment( userIdentifier, traceId, ); ZSMLogger.debug( `checkEnrollment result (Android): ${JSON.stringify(enrollmentInfo)}`, traceId, ); // Return boolean: true if enrolled, false otherwise const isEnrolled = enrollmentInfo !== null && enrollmentInfo !== undefined && (typeof enrollmentInfo === "object" ? Object.keys(enrollmentInfo).length > 0 : !!enrollmentInfo); return isEnrolled; } } catch (e) { ZSMLogger.debug(`checkEnrollment failed: ${e.message}`, traceId); // Return false like web SDK when not enrolled or on error return false; } }; getNativeEnrollmentInfo = async ( userIdentifier = this.userIdentifier, forceRemoteCheck = false, ) => { const traceId = ZSMLogger.generateTraceId(); const originalUserIdentifier = userIdentifier; userIdentifier = this.transformUserIdentifier(userIdentifier); ZSMLogger.info( `getNativeEnrollmentInfo called with userIdentifier: ${originalUserIdentifier}, transformedUserIdentifier: ${userIdentifier}, forceRemoteCheck: ${forceRemoteCheck}, platform: ${Platform.OS}`, traceId, ); if (!ZSM) throw new Error("ZSM native module is not available"); if (typeof ZSM.checkAllEnrollmentsWithRemoteCheck === "function") { ZSMLogger.info( `getNativeEnrollmentInfo using native checkAllEnrollmentsWithRemoteCheck for userIdentifier: ${userIdentifier}`, traceId, ); const nativeEnrollmentInfo = await ZSM.checkAllEnrollmentsWithRemoteCheck( userIdentifier, forceRemoteCheck, ); console.log( `checkAllEnrollments localInfo: ${JSON.stringify(nativeEnrollmentInfo)}`, ); // Android bridge adds hasMpcRemote/hasPasskeyRemote/hasRcr from the raw // /api/v1/enrollment-details response. iOS compiled framework does not expose // these fields — fall back to the merged result which already reflects server state. const hasRawServerFields = nativeEnrollmentInfo?.hasMpcRemote !== undefined; const serverState = hasRawServerFields ? { hasMpcCredential: nativeEnrollmentInfo.hasMpcRemote, hasPasskeyCredential: nativeEnrollmentInfo.hasPasskeyRemote, identityId: nativeEnrollmentInfo?.identity_id || nativeEnrollmentInfo?.identityId, hasRcr: nativeEnrollmentInfo.hasRcr, } : { note: "iOS: raw server fields not exposed by framework; values derived from server-merged result", hasMpcCredential: nativeEnrollmentInfo?.has_mpc ?? nativeEnrollmentInfo?.mpcEnrollment, hasPasskeyCredential: nativeEnrollmentInfo?.has_passkey ?? nativeEnrollmentInfo?.passkeyEnrollment, identityId: nativeEnrollmentInfo?.identity_id || nativeEnrollmentInfo?.identityId, }; console.log( `checkAllEnrollments serverState: ${JSON.stringify(serverState)}`, ); const normalizedEnrollmentInfo = normalizeEnrollmentInfo(nativeEnrollmentInfo); console.log( `checkAllEnrollments mergedInfo: ${JSON.stringify(normalizedEnrollmentInfo)}`, ); return normalizedEnrollmentInfo; } ZSMLogger.info( `getNativeEnrollmentInfo using legacy native checkAllEnrollments for userIdentifier: ${userIdentifier}`, traceId, ); const nativeEnrollmentInfo = await ZSM.checkAllEnrollments(userIdentifier); console.log( `checkAllEnrollments localInfo: ${JSON.stringify(nativeEnrollmentInfo)}`, ); const normalizedEnrollmentInfo = normalizeEnrollmentInfo(nativeEnrollmentInfo); console.log( `checkAllEnrollments mergedInfo: ${JSON.stringify(normalizedEnrollmentInfo)}`, ); return normalizedEnrollmentInfo; }; checkAllEnrollments = async ( userIdentifier = this.userIdentifier, forceRemoteCheck = false, ) => { const traceId = ZSMLogger.generateTraceId(); ZSMLogger.info( `checkAllEnrollments called with userIdentifier: ${userIdentifier}, forceRemoteCheck: ${forceRemoteCheck}, platform: ${Platform.OS}`, traceId, ); const enrollmentInfo = await this.getNativeEnrollmentInfo( userIdentifier, forceRemoteCheck, ); ZSMLogger.info( `checkAllEnrollments mergedInfo (returning): ${JSON.stringify(enrollmentInfo)}`, traceId, ); return enrollmentInfo; }; enroll = async (userIdentifier = this.userIdentifier, options = {}) => { userIdentifier = this.transformUserIdentifier(userIdentifier); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `enroll called with userIdentifier: ${userIdentifier}, platform: ${Platform.OS}`, traceId, ); if (!ZSM) throw new Error("ZSM native module is not available"); // userVerification controls passkey behavior directly on each call: // - "preferred": Create Passkeys+ (passkey + ZSM) if supported // - "prevented": Create ZSM-only (no passkey) // - "required": Must use passkey, fails if unavailable // - "discouraged": Minimize biometric friction const userVerification = options.userVerification || "prevented"; ZSMLogger.debug( `Enrolling with userVerification: ${userVerification}`, traceId, ); if (isIOS) { // iOS: Use native UMFAClient.enroll which handles: // 1. Creating identity on server // 2. Setting up consumer_id properly // 3. Creating MPC credential // 4. Creating passkey if userVerification != "prevented" // CRITICAL: Initialize native module with config BEFORE enrollment // This ensures defer_registration_finish and other config options are applied await this.zsmAPI.initializeZsm(userIdentifier); // PRE-ENROLLMENT CHECK: If user already has an identity_id stored in native storage, // they're already enrolled and we should NOT re-enroll (would fail with 403) // This is critical when passkeys are disabled - checkEnrollment returns null // but the user may still have MPC enrollment stored in native NSUserDefaults try { const existingEnrollment = await this.getNativeEnrollmentInfo( userIdentifier, true, ); ZSMLogger.debug( `[iOS-PRE-ENROLL-CHECK] checkAllEnrollments returned: ${JSON.stringify(existingEnrollment)}`, traceId, ); if (existingEnrollment?.identity_id) { ZSMLogger.debug( `[iOS-PRE-ENROLL-CHECK] User already enrolled with identity_id: ${existingEnrollment.identity_id}, skipping re-enrollment`, traceId, ); // User is already enrolled - sync to AsyncStorage and return success await this.zsmAPI.relyingParty.storeIdentityMapping( userIdentifier, existingEnrollment.identity_id, ); return { success: true, identity: { id: existingEnrollment.identity_id }, alreadyEnrolled: true, }; } } catch (checkErr) { ZSMLogger.debug( `[iOS-PRE-ENROLL-CHECK] checkAllEnrollments failed (proceeding with enrollment): ${checkErr?.message || checkErr}`, traceId, ); } const result = await ZSM.umfaEnroll( userIdentifier, userVerification, traceId, ); ZSMLogger.debug( `umfaEnroll succeeded (iOS): ${JSON.stringify(result)}`, traceId, ); // Store identity mapping for future use // iOS native SDK stores identity_id internally but doesn't return it in the callback // We need to query it from native storage via checkAllEnrollments let identityId = result?.result?.identity_id; if (!identityId) { // iOS doesn't return identity_id in callback, but stores it in native storage // Query native storage to sync identity mapping to AsyncStorage try { ZSMLogger.debug( `[iOS-IDENTITY-FIX] identity_id not in result, querying native storage via checkAllEnrollments`, traceId, ); const enrollmentInfo = await this.getNativeEnrollmentInfo( userIdentifier, true, ); ZSMLogger.debug( `[iOS-IDENTITY-FIX] checkAllEnrollments returned: ${JSON.stringify(enrollmentInfo)}`, traceId, ); if (enrollmentInfo?.identity_id) { identityId = enrollmentInfo.identity_id; ZSMLogger.debug( `[iOS-IDENTITY-FIX] Found identity_id from native storage: ${identityId}`, traceId, ); } } catch (e) { ZSMLogger.warn( `[iOS-IDENTITY-FIX] Failed to query checkAllEnrollments: ${e?.message || e}`, traceId, ); } } if (identityId) { await this.zsmAPI.relyingParty.storeIdentityMapping( userIdentifier, identityId, ); ZSMLogger.debug( `Stored identity mapping: ${userIdentifier} -> ${identityId}`, traceId, ); } else { ZSMLogger.warn( `[iOS-IDENTITY-FIX] Could not retrieve identity_id for user ${userIdentifier} - multi-user support may not work correctly`, traceId, ); } // Re-initialize to pick up the new identity await this.zsmAPI.initializeZsm(userIdentifier); return result; } else { // Android: Use native umfaEnroll which properly handles passkey prompt ZSMLogger.debug(`Using Android umfaEnroll for enrollment`, traceId); // Initialize for this user await this.zsmAPI.initializeZsm(userIdentifier); // PRE-ENROLLMENT CHECK: If user already has an identity_id stored in native storage, // they're already enrolled and we should NOT re-enroll (would fail with 403) try { const existingEnrollment = await this.getNativeEnrollmentInfo( userIdentifier, true, ); ZSMLogger.debug( `[Android-PRE-ENROLL-CHECK] checkAllEnrollments returned: ${JSON.stringify(existingEnrollment)}`, traceId, ); if (existingEnrollment?.identity_id) { ZSMLogger.debug( `[Android-PRE-ENROLL-CHECK] User already enrolled with identity_id: ${existingEnrollment.identity_id}, skipping re-enrollment`, traceId, ); // User is already enrolled - sync to AsyncStorage and return success await this.zsmAPI.relyingParty.storeIdentityMapping( userIdentifier, existingEnrollment.identity_id, ); return { success: true, identity: { id: existingEnrollment.identity_id }, alreadyEnrolled: true, }; } } catch (checkErr) { ZSMLogger.debug( `[Android-PRE-ENROLL-CHECK] checkAllEnrollments failed (proceeding with enrollment): ${checkErr?.message || checkErr}`, traceId, ); } // NOTE: We do NOT call ZSM.configure() here because: // 1. It breaks passkey support (config may not have passkey_rp_id) // 2. The native SDK handles MPC storage isolation via identity_id automatically // The native EnrollmentMapper stores identity_id per userId for proper multi-user support. // Use native UMFA enroll like iOS const result = await ZSM.umfaEnroll( userIdentifier, userVerification, traceId, ); ZSMLogger.debug( `umfaEnroll succeeded (Android): ${JSON.stringify(result)}`, traceId, ); // Store identity mapping in AsyncStorage for JS layer const identityId = result?.result?.identity_id || result?.metadata?.identity_id || ""; if (identityId) { await this.zsmAPI.relyingParty.storeIdentityMapping( userIdentifier, identityId, ); ZSMLogger.debug( `[Android-IDENTITY-FIX] Stored identity mapping: ${userIdentifier} -> ${identityId}`, traceId, ); } return result; } } catch (e) { e = e.message || e; e = "Unable to complete enrollment: " + e; ZSMLogger.trace(`enroll failed: ${e}`); return new Error(e); } }; /** * Authenticate with passkey support. * Uses native UMFAClient.authenticate which properly checks credential state * and shows passkey prompt when appropriate. * * @param {string} userIdentifier - The user identifier * @param {object} options - Authentication options * @param {string} options.userVerification - Controls passkey behavior: * - "required": Must use passkey, fails if unavailable * - "preferred": Use passkey if available, otherwise MPC (default) * - "discouraged": Prefer MPC, but use passkey if MPC unavailable * - "prevented": Force MPC-only, never use passkey * @returns {Promise<Object>} Authentication result */ authenticateWithPasskey = async ( userIdentifier = this.userIdentifier, options = {}, ) => { userIdentifier = this.transformUserIdentifier(userIdentifier); const traceId = ZSMLogger.generateTraceId(); const userVerification = options.userVerification || "preferred"; try { ZSMLogger.debug( `authenticateWithPasskey called with userIdentifier: ${userIdentifier}, userVerification: ${userVerification}`, traceId, ); // Initialize ZSM to ensure native module is ready await this.zsmAPI.initializeZsm(userIdentifier); if (!ZSM) throw new Error("ZSM native module is not available"); if (isIOS) { // iOS: Use native umfaAuthenticate which properly handles passkey logic const result = await ZSM.umfaAuthenticate( userIdentifier, userVerification, traceId, ); ZSMLogger.debug( `authenticateWithPasskey succeeded (iOS): ${JSON.stringify(result)}`, traceId, ); return result; } else { // Android: Use native umfaAuthenticate which properly handles passkey prompt ZSMLogger.debug( `Using Android umfaAuthenticate for authentication`, traceId, ); await this.zsmAPI.initializeZsm(userIdentifier); // Sync native enrollment identity to AsyncStorage before authentication // This ensures multi-user scenarios work correctly when switching between users // NOTE: We do NOT call ZSM.configure() because it breaks passkey support // The native SDK handles MPC storage isolation via its internal identity_id mapping try { const existingEnrollment = await this.getNativeEnrollmentInfo( userIdentifier, true, ); ZSMLogger.debug( `[Android-AUTH-SYNC] checkAllEnrollments returned: ${JSON.stringify(existingEnrollment)}`, traceId, ); if (existingEnrollment?.identity_id) { await this.zsmAPI.relyingParty.storeIdentityMapping( userIdentifier, existingEnrollment.identity_id, ); ZSMLogger.debug( `[Android-AUTH-SYNC] Synced native identity to AsyncStorage: ${userIdentifier} -> ${existingEnrollment.identity_id}`, traceId, ); } } catch (syncErr) { ZSMLogger.debug( `[Android-AUTH-SYNC] checkAllEnrollments failed: ${syncErr?.message || syncErr}`, traceId, ); } const result = await ZSM.umfaAuthenticate( userIdentifier, userVerification, traceId, ); ZSMLogger.debug( `umfaAuthenticate succeeded (Android): ${JSON.stringify(result)}`, traceId, ); return result; } } catch (e) { e = e.message || e; e = "Unable to complete authentication: " + e; ZSMLogger.trace(`authenticateWithPasskey failed: ${e}`); return new Error(e); } }; /** * Check if user has a passkey credential available. * iOS: Uses native hasPasskeyCredential * Android: Checks credential state for passkey presence * @param {string} userIdentifier - The user identifier * @returns {Promise<boolean>} True if passkey is available */ hasPasskeyCredential = async (userIdentifier = this.userIdentifier) => { userIdentifier = this.transformUserIdentifier(userIdentifier); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `hasPasskeyCredential called for user: ${userIdentifier}, platform: ${Platform.OS}`, traceId, ); if (!ZSM) throw new Error("ZSM native module is not available"); await this.zsmAPI.initializeZsm(userIdentifier); if (isIOS) { const hasPasskey = await ZSM.hasPasskeyCredential(userIdentifier); ZSMLogger.debug( `hasPasskeyCredential result (iOS): ${hasPasskey}`, traceId, ); return hasPasskey; } else { // Android: Check credential state for passkey presence const state = await ZSM.getCredentialState(userIdentifier, traceId); // State 2 = passkey-only, State 3 = both (has passkey) const hasPasskey = state?.state === 2 || state?.state === 3; ZSMLogger.debug( `hasPasskeyCredential result (Android): ${hasPasskey} (state: ${state?.state})`, traceId, ); return hasPasskey; } } catch (e) { ZSMLogger.error(`hasPasskeyCredential failed: ${e.message}`, traceId); return false; } }; /** * Authenticate user - platform-aware authentication. * iOS: Uses native UMFAClient.authenticate * Android: Uses native umfaAuthenticate (same as iOS) * Handles both passkey and MPC-only authentication. */ authenticate = async (userIdentifier = this.userIdentifier, options = {}) => { userIdentifier = this.transformUserIdentifier(userIdentifier); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `authenticate called with userIdentifier: ${userIdentifier}, platform: ${Platform.OS}`, traceId, ); if (!ZSM) throw new Error("ZSM native module is not available"); // userVerification controls passkey behavior directly on each call: // - "preferred": Use passkey if available, otherwise ZSM // - "prevented": Force ZSM-only, never use passkey // - "required": Must use passkey, fails if unavailable // - "discouraged": Minimize biometric friction const userVerification = options.userVerification || "preferred"; ZSMLogger.debug( `Authenticating with userVerification: ${userVerification}`, traceId, ); if (isIOS) { // iOS: Use native UMFAClient.authenticate which properly handles: // 1. Checking credential state // 2. Passkey vs MPC selection based on userVerification // 3. Biometric prompts // CRITICAL: Initialize native module with config BEFORE authentication // This ensures identity mapping and other config options are applied await this.zsmAPI.initializeZsm(userIdentifier); const result = await ZSM.umfaAuthenticate( userIdentifier, userVerification, traceId, ); ZSMLogger.debug( `umfaAuthenticate succeeded (iOS): ${JSON.stringify(result)}`, traceId, ); return result; } else { // Android: Use native umfaAuthenticate which properly handles passkey prompt ZSMLogger.debug( `Using Android umfaAuthenticate for authentication`, traceId, ); await this.zsmAPI.initializeZsm(userIdentifier); // Sync native enrollment identity to AsyncStorage before authentication // This ensures multi-user scenarios work correctly when switching between users // NOTE: We do NOT call ZSM.configure() because it breaks passkey support // The native SDK handles MPC storage isolation via its internal identity_id mapping try { const existingEnrollment = await this.getNativeEnrollmentInfo( userIdentifier, true, ); ZSMLogger.debug( `[Android-AUTH-SYNC] checkAllEnrollments returned: ${JSON.stringify(existingEnrollment)}`, traceId, ); if (existingEnrollment?.identity_id) { // Sync the native identity_id to AsyncStorage await this.zsmAPI.relyingParty.storeIdentityMapping( userIdentifier, existingEnrollment.identity_id, ); ZSMLogger.debug( `[Android-AUTH-SYNC] Synced native identity to AsyncStorage: ${userIdentifier} -> ${existingEnrollment.identity_id}`, traceId, ); } } catch (syncErr) { ZSMLogger.debug( `[Android-AUTH-SYNC] checkAllEnrollments failed (proceeding anyway): ${syncErr?.message || syncErr}`, traceId, ); } const result = await ZSM.umfaAuthenticate( userIdentifier, userVerification, traceId, ); ZSMLogger.debug( `umfaAuthenticate succeeded (Android): ${JSON.stringify(result)}`, traceId, ); return result; } } catch (e) { e = e.message || e; e = "Unable to complete authentication: " + e; ZSMLogger.trace(`authenticate failed: ${e}`); return new Error(e); } }; resetDevice = async () => { this.zsmAPI.webauthnReset(); }; /** * Lists all registered users from local storage. * @param {string} [traceId] - Optional trace ID for logging * @returns {Promise<string[]>} Array of registered user identifiers */ listRegisteredUsers = async (traceId = null) => { const actualTraceId = traceId || ZSMLogger.generateTraceId(); try { ZSMLogger.debug(`listRegisteredUsers called`, actualTraceId); if (!ZSM) throw new Error("ZSM native module is not available"); // Ensure native module is initialized before calling listRegisteredUsers await this.zsmAPI.initializeZsm(this.userIdentifier || "default"); const users = await ZSM.listRegisteredUsers(actualTraceId); ZSMLogger.debug( `listRegisteredUsers returned ${users?.length || 0} users`, actualTraceId, ); return users || []; } catch (e) { ZSMLogger.error( `listRegisteredUsers failed: ${e.message}`, actualTraceId, ); return []; } }; /** * Gets the credential state for a user. * @param {string} userIdentifier - The user identifier to check * @param {string} [traceId] - Optional trace ID for logging * @returns {Promise<{state: number, stateString: string}>} Credential state */ getCredentialState = async ( userIdentifier = this.userIdentifier, traceId = null, ) => { userIdentifier = this.transformUserIdentifier(userIdentifier); const actualTraceId = traceId || ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `getCredentialState called for user: ${userIdentifier}`, actualTraceId, ); if (!ZSM) throw new Error("ZSM native module is not available"); await this.zsmAPI.initializeZsm(userIdentifier); const result = await ZSM.getCredentialState( userIdentifier, actualTraceId, ); ZSMLogger.debug( `getCredentialState result: ${result?.stateString} (${result?.state})`, actualTraceId, ); return result || { state: 0, stateString: "none" }; } catch (e) { ZSMLogger.error(`getCredentialState failed: ${e.message}`, actualTraceId); return { state: 0, stateString: "none" }; } }; // ============================================================================ // Passkeys+ Support Methods - Delegate to WebAuthnClient (zsmAPI) // ============================================================================ /** * Checks if passkeys are supported on this device. * @returns {Promise<boolean>} True if passkeys are supported */ isPasskeySupported = async () => { const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug(`isPasskeySupported called`, traceId); // Ensure native module is initialized await this.zsmAPI.initializeZsm(this.userIdentifier || "default"); // Call native module directly if (!ZSM) throw new Error("ZSM native module is not available"); const supported = await ZSM.isPasskeySupported(); ZSMLogger.debug(`isPasskeySupported result: ${supported}`, traceId); return supported; } catch (e) { ZSMLogger.error(`isPasskeySupported failed: ${e.message}`, traceId); return false; } }; /** * Gets the reason why passkeys are not supported (if applicable). * @returns {Promise<string|null>} Reason string or null if passkeys are supported */ passkeyNotSupportedReason = async () => { const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug(`passkeyNotSupportedReason called`, traceId); await this.zsmAPI.initializeZsm(this.userIdentifier || "default"); if (!ZSM) throw new Error("ZSM native module is not available"); const reason = await ZSM.passkeyNotSupportedReason(); ZSMLogger.debug( `passkeyNotSupportedReason result: ${reason || "(none)"}`, traceId, ); return reason; } catch (e) { ZSMLogger.error( `passkeyNotSupportedReason failed: ${e.message}`, traceId, ); return "Unknown (error checking passkey support)"; } }; /** * Gets the server-side credential state for a user. * @param {string} userId - The user identifier to check * @returns {Promise<Object>} Object with state string and numeric value */ getServerCredentialState = async (userId) => { userId = this.transformUserIdentifier(userId); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `getServerCredentialState called for user: ${userId}`, traceId, ); await this.zsmAPI.initializeZsm(userId); if (!ZSM) throw new Error("ZSM native module is not available"); const result = await ZSM.getServerCredentialState(userId, traceId); ZSMLogger.debug( `getServerCredentialState result: ${JSON.stringify(result)}`, traceId, ); return result || { state: "not-enrolled", value: 0 }; } catch (e) { ZSMLogger.error(`getServerCredentialState failed: ${e.message}`, traceId); // Fallback to local state check const localState = await this.getCredentialState(userId); return { state: localState.stateString === "mpc-only" ? "zsm-only" : localState.stateString, value: localState.state, }; } }; /** * Adds a passkey to an existing ZSM identity (migration from ZSM to Passkeys+). * @param {string} userId - The user identifier * @param {string} [userVerification="preferred"] - User verification requirement * @returns {Promise<Object>} Result with success status and metadata */ addPasskeyToExistingIdentity = async ( userId, userVerification = "preferred", ) => { userId = this.transformUserIdentifier(userId); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `addPasskeyToExistingIdentity called for user: ${userId}`, traceId, ); await this.zsmAPI.initializeZsm(userId); if (!ZSM) throw new Error("ZSM native module is not available"); const result = await ZSM.addPasskeyToExistingIdentity( userId, userVerification, traceId, ); ZSMLogger.debug( `addPasskeyToExistingIdentity result: ${JSON.stringify(result)}`, traceId, ); return result; } catch (e) { ZSMLogger.error( `addPasskeyToExistingIdentity failed: ${e.message}`, traceId, ); throw e; } }; /** * Adds MPC (ZSM) credential to an existing passkey-only user. * @param {string} userId - The user identifier * @returns {Promise<Object>} Result with success status and metadata */ addZsmToPasskeyUser = async (userId) => { userId = this.transformUserIdentifier(userId); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `addZsmToPasskeyUser called for user: ${userId}`, traceId, ); await this.zsmAPI.initializeZsm(userId); if (!ZSM) throw new Error("ZSM native module is not available"); const result = await ZSM.addMpcToPasskeyUser(userId, traceId); ZSMLogger.debug( `addZsmToPasskeyUser result: ${JSON.stringify(result)}`, traceId, ); return result; } catch (e) { ZSMLogger.error(`addZsmToPasskeyUser failed: ${e.message}`, traceId); throw e; } }; /** * Bootstrap a new device from a prior Passkeys+ enrollment. * @param {string} userId - The user identifier * @param {Object} [options] - Optional settings * @returns {Promise<Object>} Result with success status, token, and metadata */ bootstrapFromPasskey = async (userId, options = {}) => { userId = this.transformUserIdentifier(userId); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug( `bootstrapFromPasskey called for user: ${userId}`, traceId, ); // Delegate to WebAuthnClient implementation return await this.zsmAPI.bootstrapFromPasskey(userId, options); } catch (e) { ZSMLogger.error(`bootstrapFromPasskey failed: ${e.message}`, traceId); throw e; } }; /** * Checks if a user is enrolled (has credentials on this device). * @param {string} userId - The user identifier to check * @returns {Promise<boolean>} True if user is enrolled */ isUserEnrolled = async (userId) => { userId = this.transformUserIdentifier(userId); const traceId = ZSMLogger.generateTraceId(); try { ZSMLogger.debug(`isUserEnrolled called for user: ${userId}`, traceId); const enrolled = await this.checkEnrollment(userId); ZSMLogger.debug(`isUserEnrolled result: ${enrolled}`, traceId); return enrolled; } catch (e) { ZSMLogger.error(`isUserEnrolled failed: ${e.message}`, traceId); return false; } }; /** * Performs a comprehensive health check on all UMFA services and configuration. * @returns {Promise<Object>} Resolves with detailed health status for UMFA functionality. */ healthCheck = async () => { try { const traceId = ZSMLogger.generateTraceId(); ZSMLogger.info("Starting UMFA health check...", traceId); // Get base health check from WebAuthnClient const baseHealth = await this.zsmAPI.healthCheck(); // Add UMFA-specific information const umfaHealth = { ...baseHealth, umfa_client: { version: this.version, config: this.config, current_user: this.userIdentifier || "NOT_SET", credential_id: this.credentialID || "NOT_SET", enrollment_status: this.credentialID ? "ENROLLED" : "NOT_ENROLLED", }, }; // Test UMFA-specific functionality if user is set if (this.userIdentifier) { ZSMLogger.trace("Testing UMFA enrollment check...", traceId); try { const enrollmentResult = await this.checkEnrollment( this.userIdentifier, ); umfaHealth.umfa_client.enrollment_test = { status: enrollmentResult instanceof Error ? "FAILED" : "OK", result: enrollmentResult instanceof Error ? enrollmentResult.message : "User enrollment verified", }; } catch (error) { umfaHealth.umfa_client.enrollment_test = { status: "ERROR", error: error.message, }; } } ZSMLogger.info( `UMFA health check complete: ${umfaHealth.overall_status}`, traceId, ); ZSMLogger.trace( `Full UMFA health check result: ${JSON.stringify(umfaHealth)}`, traceId, ); return umfaHealth; } catch (error) { const errorResult = { timestamp: new Date().toISOString(), overall_status: "ERROR", umfa_client: { version: this.version, error: error.message, }, }; ZSMLogger.trace(`UMFA health check failed: ${error.message}`); return errorResult; } }; } export default UMFAClient; export { isPasskeyCancellation, extractFallbackMetadata };