diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 73e35a8ac..8eba20f82 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -3,6 +3,8 @@ apply plugin: "com.facebook.react" apply plugin: "org.jetbrains.kotlin.android" apply plugin: 'kotlin-android' apply plugin: 'com.google.gms.google-services' +apply from: "../../../node_modules/@sentry/react-native/sentry.gradle" + /** diff --git a/app/android/react-native-passport-reader/android/build.gradle b/app/android/react-native-passport-reader/android/build.gradle index c0963bbe3..8c41111aa 100644 --- a/app/android/react-native-passport-reader/android/build.gradle +++ b/app/android/react-native-passport-reader/android/build.gradle @@ -42,4 +42,6 @@ dependencies { implementation 'commons-io:commons-io:2.8.0' implementation 'com.squareup.okhttp3:okhttp:4.9.0' implementation 'com.facebook.react:react-native:+' + implementation "io.sentry:sentry-android:8.20.0" + } diff --git a/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt b/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt index bd21da17f..6a841a938 100644 --- a/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt +++ b/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt @@ -115,10 +115,13 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Callback +import io.sentry.Breadcrumb +import io.sentry.Sentry +import io.sentry.SentryLevel object Messages { const val SCANNING = "Scanning....." - const val STOP_MOVING = "Stop moving....." + const val STOP_MOVING = "Stop moving....." const val AUTH = "Auth....." const val COMPARING = "Comparing....." const val COMPLETED = "Scanning completed" @@ -159,7 +162,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) private var opts: ReadableMap? = null private val apduLogger = APDULogger() private var currentSessionId: String? = null - + data class Data(val id: String, val digest: String, val signature: String, val publicKey: String) data class PassportData( @@ -171,7 +174,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) interface DataCallback { fun onDataReceived(data: String) } - + init { instance = this reactContext.addLifecycleEventListener(this) @@ -200,10 +203,10 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) @ReactMethod fun scan(opts: ReadableMap, promise: Promise) { - currentSessionId = generateSessionId() - + currentSessionId = if (opts.hasKey("sessionId")) opts.getString("sessionId") else generateSessionId() + apduLogger.setContext("session_id", currentSessionId!!) - + // Log scan start logAnalyticsEvent("nfc_scan_started", mapOf( "use_can" to (opts.getBoolean(PARAM_USE_CAN) ?: false), @@ -211,18 +214,22 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) "has_can_number" to (!opts.getString(PARAM_CAN).isNullOrEmpty()), "platform" to "android" )) - + + logNfc(SentryLevel.INFO, "scan_start", "start") + eventMessageEmitter(Messages.SCANNING) val mNfcAdapter = NfcAdapter.getDefaultAdapter(reactApplicationContext) // val mNfcAdapter = NfcAdapter.getDefaultAdapter(this.reactContext) if (mNfcAdapter == null) { logAnalyticsError("nfc_not_supported", "NFC chip reading not supported") + logNfc(SentryLevel.WARNING, "nfc_not_supported", "check") promise.reject("E_NOT_SUPPORTED", "NFC chip reading not supported") return } if (!mNfcAdapter.isEnabled) { logAnalyticsError("nfc_not_enabled", "NFC chip reading not enabled") + logNfc(SentryLevel.WARNING, "nfc_not_enabled", "check") promise.reject("E_NOT_ENABLED", "NFC chip reading not enabled") return } @@ -290,7 +297,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) } } - + private fun toBase64(bitmap: Bitmap, quality: Int): String { val byteArrayOutputStream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, quality, byteArrayOutputStream) @@ -348,9 +355,9 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) false, ) Log.e("MY_LOGS", "service gotten") - + service.addAPDUListener(apduLogger) - + service.open() Log.e("MY_LOGS", "service opened") logAnalyticsEvent("nfc_passport_service_opened") @@ -368,7 +375,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) eventMessageEmitter(Messages.PACE_STARTED) apduLogger.setContext("operation", "pace_authentication") apduLogger.setContext("auth_key_type", authKey.javaClass.simpleName) - + // Determine proper PACE key: use CAN key if provided; otherwise derive PACE MRZ key from BAC val paceKeyToUse: PACEKeySpec? = when (authKey) { is PACEKeySpec -> authKey @@ -410,10 +417,10 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) val maxAttempts = 3 eventMessageEmitter(Messages.BAC_STARTED) - + apduLogger.setContext("operation", "bac_authentication") apduLogger.setContext("auth_key_type", authKey.javaClass.simpleName) - + while (!bacSucceeded && attempts < maxAttempts) { try { attempts++ @@ -486,11 +493,11 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) logAnalyticsEvent("nfc_reading_data_groups") - + apduLogger.setContext("operation", "reading_data_groups") apduLogger.setContext("pace_succeeded", paceSucceeded) apduLogger.setContext("bac_succeeded", bacSucceeded) - + eventMessageEmitter(Messages.READING_DG1) logAnalyticsEvent("nfc_reading_dg1_started") val dg1In = service.getInputStream(PassportService.EF_DG1) @@ -570,7 +577,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) private fun doChipAuth(service: PassportService) { try { apduLogger.setContext("operation", "chip_authentication") - + logAnalyticsEvent("nfc_reading_dg14_started") eventMessageEmitter(Messages.READING_DG14) val dg14In = service.getInputStream(PassportService.EF_DG14) @@ -602,19 +609,19 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) try { apduLogger.setContext("operation", "passive_authentication") apduLogger.setContext("chip_auth_succeeded", chipAuthSucceeded) - + logAnalyticsEvent("nfc_passive_auth_started") Log.d(TAG, "Starting passive authentication...") val digest = MessageDigest.getInstance(sodFile.digestAlgorithm) Log.d(TAG, "Using digest algorithm: ${sodFile.digestAlgorithm}") - + val dataHashes = sodFile.dataGroupHashes - + val dg14Hash = if (chipAuthSucceeded) digest.digest(dg14Encoded) else ByteArray(0) val dg1Hash = digest.digest(dg1File.encoded) val dg2Hash = digest.digest(dg2File.encoded) - + // val gson = Gson() // Log.d(TAG, "dataHashes " + gson.toJson(dataHashes)) // val hexMap = sodFile.dataGroupHashes.mapValues { (_, value) -> @@ -741,7 +748,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) } apduLogger.clearContext() - + resetState() return } @@ -757,12 +764,12 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) // val signedDataField = SODFile::class.java.getDeclaredField("signedData") // signedDataField.isAccessible = true - + // val signedData = signedDataField.get(sodFile) as SignedData - + val eContentAsn1InputStream = ASN1InputStream(sodFile.eContent.inputStream()) // val eContentDecomposed: ASN1Primitive = eContentAsn1InputStream.readObject() - + val passport = Arguments.createMap() passport.putString("mrz", mrzInfo.toString()) passport.putString("signatureAlgorithm", sodFile.docSigningCertificate.sigAlgName) // this one is new @@ -772,7 +779,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) val certificateBytes = certificate.encoded val certificateBase64 = Base64.encodeToString(certificateBytes, Base64.DEFAULT) Log.d(TAG, "certificateBase64: ${certificateBase64}") - + passport.putString("documentSigningCertificate", certificateBase64) @@ -781,10 +788,10 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) passport.putString("modulus", publicKey.modulus.toString()) } else if (publicKey is ECPublicKey) { // Handle the elliptic curve public key case - + // val w = publicKey.getW() // passport.putString("publicKeyW", w.toString()) - + // val ecParams = publicKey.getParams() // passport.putInt("cofactor", ecParams.getCofactor()) // passport.putString("curve", ecParams.getCurve().toString()) @@ -793,7 +800,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) // if (ecParams is ECNamedCurveSpec) { // passport.putString("curveName", ecParams.getName()) // } - + // Old one, probably wrong: // passport.putString("curveName", (publicKey.parameters as ECNamedCurveSpec).name) // passport.putString("curveName", (publicKey.parameters.algorithm)) or maybe this @@ -831,15 +838,15 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) // passport.putString("getDocSigningCertificate", gson.toJson(sodFile.getDocSigningCertificate)) // passport.putString("getIssuerX500Principal", gson.toJson(sodFile.getIssuerX500Principal)) // passport.putString("getSerialNumber", gson.toJson(sodFile.getSerialNumber)) - - - // Another way to get signing time is to get into signedData.signerInfos, then search for the ICO identifier 1.2.840.113549.1.9.5 + + + // Another way to get signing time is to get into signedData.signerInfos, then search for the ICO identifier 1.2.840.113549.1.9.5 // passport.putString("signerInfos", gson.toJson(signedData.signerInfos)) - + // Log.d(TAG, "signedData.digestAlgorithms: ${gson.toJson(signedData.digestAlgorithms)}") // Log.d(TAG, "signedData.signerInfos: ${gson.toJson(signedData.signerInfos)}") // Log.d(TAG, "signedData.certificates: ${gson.toJson(signedData.certificates)}") - + // var quality = 100 // val base64 = bitmap?.let { toBase64(it, quality) } // val photo = Arguments.createMap() @@ -848,13 +855,13 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) // photo.putInt("height", bitmap?.height ?: 0) // passport.putMap("photo", photo) // passport.putString("dg2File", gson.toJson(dg2File)) - + eventMessageEmitter(Messages.COMPLETED) scanPromise?.resolve(passport) eventMessageEmitter(Messages.RESET) - + apduLogger.clearContext() - + resetState() } } @@ -890,10 +897,10 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) if (params.isNotEmpty()) { logData.put("data", JSONObject(Gson().toJson(params))) } - + // Send to React Native via logEvent emission using the same working approach emitLogEvent(logData.toString()) - + // Also log to Android logs for debugging Log.d(TAG, "Analytics event: $eventName with params: $params") } catch (e: Exception) { @@ -911,9 +918,9 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) put("event", eventName) put("error_description", message) }) - + emitLogEvent(logData.toString()) - + Log.e(TAG, "Analytics error: $eventName - $message") } catch (e: Exception) { Log.e(TAG, "Error logging analytics error", e) @@ -934,10 +941,10 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) fun reset() { logAnalyticsEvent("nfc_scan_reset") apduLogger.clearContext() - + resetState() } - + /** * Generate a unique session ID for tracking passport reading sessions */ @@ -945,6 +952,41 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext) return "nfc_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(8)}" } + private fun logNfc(level: SentryLevel, message: String, stage: String, extras: Map = emptyMap()) { + val data = mutableMapOf().apply { + currentSessionId?.let { put("session_id", it) } + put("platform", "android") + put("scan_type", if (opts?.getBoolean(PARAM_USE_CAN) == true) "can" else "mrz") + put("stage", stage) + putAll(extras) + } + + if (level == SentryLevel.ERROR) { + // For errors, capture a message (this will include all previous breadcrumbs) + Sentry.withScope { scope -> + scope.level = level + currentSessionId?.let { scope.setTag("session_id", it) } + scope.setTag("platform", "android") + scope.setTag("scan_type", if (opts?.getBoolean(PARAM_USE_CAN) == true) "can" else "mrz") + scope.setTag("stage", stage) + for ((k, v) in extras) { + scope.setExtra(k, v?.toString()) + } + Sentry.captureMessage(message) + } + } else { + // For info/warn, add as breadcrumb only + Sentry.addBreadcrumb( + Breadcrumb().apply { + this.message = message + this.level = level + this.category = "nfc" + data.forEach { (key, value) -> this.data[key] = value?.toString() ?: "" } + } + ) + } + } + companion object { private val TAG = RNPassportReaderModule::class.java.simpleName private const val PARAM_DOC_NUM = "documentNumber"; diff --git a/app/ios/PassportReader.m b/app/ios/PassportReader.m index 18353cb1c..422281408 100644 --- a/app/ios/PassportReader.m +++ b/app/ios/PassportReader.m @@ -29,6 +29,7 @@ @interface RCT_EXTERN_MODULE(PassportReader, NSObject) skipCA:(NSNumber * _Nonnull)skipCA extendedMode:(NSNumber * _Nonnull)extendedMode usePacePolling:(NSNumber * _Nonnull)usePacePolling + sessionId:(NSString *)sessionId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/app/ios/PassportReader.swift b/app/ios/PassportReader.swift index e3b1b6c60..4a54c24d0 100644 --- a/app/ios/PassportReader.swift +++ b/app/ios/PassportReader.swift @@ -14,6 +14,7 @@ import NFCPassportReader #endif import Security import Mixpanel +import Sentry #if !E2E_TESTING @available(iOS 13, macOS 10.15, *) @@ -48,6 +49,36 @@ class PassportReader: NSObject { } private var analytics: SelfAnalytics? + private var currentSessionId: String? + + private func logNfc(level: SentryLevel, message: String, stage: String, useCANBool: Bool, sessionId: String, extras: [String: Any] = [:]) { + let data: [String: Any] = [ + "session_id": sessionId, + "platform": "ios", + "scan_type": useCANBool ? "can" : "mrz", + "stage": stage + ].merging(extras) { (_, new) in new } + + if level == .error { + // For errors, capture a message (this will include all previous breadcrumbs) + SentrySDK.configureScope { scope in + scope.setTag(value: sessionId, key: "session_id") + scope.setTag(value: "ios", key: "platform") + scope.setTag(value: useCANBool ? "can" : "mrz", key: "scan_type") + scope.setTag(value: stage, key: "stage") + for (key, value) in extras { + scope.setExtra(value: value, key: key) + } + } + SentrySDK.capture(message: message) + } else { + // For info/warn, add as breadcrumb only + let breadcrumb = Breadcrumb(level: level, category: "nfc") + breadcrumb.message = message + breadcrumb.data = data.mapValues { "\($0)" } + SentrySDK.addBreadcrumb(breadcrumb) + } + } @objc(configure:enableDebugLogs:) func configure(token: String, enableDebugLogs: Bool) { @@ -110,7 +141,7 @@ class PassportReader: NSObject { return (sum % 10) } - @objc(scanPassport:dateOfBirth:dateOfExpiry:canNumber:useCan:skipPACE:skipCA:extendedMode:usePacePolling:resolve:reject:) + @objc(scanPassport:dateOfBirth:dateOfExpiry:canNumber:useCan:skipPACE:skipCA:extendedMode:usePacePolling:sessionId:resolve:reject:) func scanPassport( _ passportNumber: String, dateOfBirth: String, @@ -121,12 +152,15 @@ class PassportReader: NSObject { skipCA: NSNumber, extendedMode: NSNumber, usePacePolling: NSNumber, + sessionId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { let useCANBool = useCan.boolValue let skipPACEBool = skipPACE.boolValue let skipCABool = skipCA.boolValue let extendedModeBool = extendedMode.boolValue let usePacePollingBool = usePacePolling.boolValue + self.currentSessionId = sessionId + logNfc(level: .info, message: "scan_start", stage: "start", useCANBool: useCANBool, sessionId: sessionId) let customMessageHandler : (NFCViewDisplayMessage)->String? = { (displayMessage) in switch displayMessage { @@ -316,9 +350,10 @@ class PassportReader: NSObject { } let stringified = String(data: try JSONEncoder().encode(ret), encoding: .utf8) - + logNfc(level: .info, message: "scan_success", stage: "complete", useCANBool: useCANBool, sessionId: sessionId) resolve(stringified) } catch { + logNfc(level: .warning, message: "scan_failed", stage: "error", useCANBool: useCANBool, sessionId: sessionId, extras: ["error": error.localizedDescription]) reject("E_PASSPORT_READ", error.localizedDescription, error) } } @@ -462,7 +497,7 @@ class PassportReader: NSObject { // No-op for E2E testing } - @objc(scanPassport:dateOfBirth:dateOfExpiry:canNumber:useCan:skipPACE:skipCA:extendedMode:usePacePolling:resolve:reject:) + @objc(scanPassport:dateOfBirth:dateOfExpiry:canNumber:useCan:skipPACE:skipCA:extendedMode:usePacePolling:sessionId:resolve:reject:) func scanPassport( _ passportNumber: String, dateOfBirth: String, @@ -473,6 +508,7 @@ class PassportReader: NSObject { skipCA: NSNumber, extendedMode: NSNumber, usePacePolling: NSNumber, + sessionId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { reject("E2E_TESTING", "NFC scanning not available in E2E testing mode", nil) } diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 238b7f500..1c1507a2a 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -299,14 +299,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Self/Pods-Self-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Self/Pods-Self-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Self/Pods-Self-resources.sh\"\n"; @@ -343,8 +339,6 @@ "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", ); name = "[CP-User] [RNFB] Core Configuration"; - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n"; @@ -357,14 +351,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Self/Pods-Self-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Self/Pods-Self-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Self/Pods-Self-frameworks.sh\"\n"; @@ -795,7 +785,10 @@ "-DFOLLY_MOBILE=1", "-DFOLLY_USE_LIBCPP=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -885,7 +878,10 @@ "-DFOLLY_MOBILE=1", "-DFOLLY_USE_LIBCPP=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/app/src/Sentry.ts b/app/src/Sentry.ts index f2b7eb870..17bff1dd7 100644 --- a/app/src/Sentry.ts +++ b/app/src/Sentry.ts @@ -4,15 +4,100 @@ import { SENTRY_DSN } from '@env'; import { + addBreadcrumb, captureException as sentryCaptureException, captureFeedback as sentryCaptureFeedback, captureMessage as sentryCaptureMessage, consoleLoggingIntegration, feedbackIntegration, init as sentryInit, + withScope, wrap, } from '@sentry/react-native'; +interface BaseContext { + sessionId: string; + userId?: string; + platform: 'ios' | 'android'; + stage: string; +} + +// Security: Whitelist of allowed tag keys to prevent XSS +const ALLOWED_TAG_KEYS = new Set([ + 'session_id', + 'platform', + 'stage', + 'circuitType', + 'currentState', + 'scanType', + 'error_code', + 'proof_step', + 'scan_result', + 'verification_status', + 'document_type', +]); + +// Security: Sanitize tag values to prevent XSS +const sanitizeTagValue = (value: unknown): string => { + if (value == null) return ''; + + const stringValue = String(value); + + // Truncate to safe length + const MAX_TAG_LENGTH = 200; + const truncated = + stringValue.length > MAX_TAG_LENGTH + ? stringValue.substring(0, MAX_TAG_LENGTH) + '...' + : stringValue; + + // Escape HTML characters and remove potentially dangerous characters + return ( + truncated + .replace(/[<>&"']/g, char => { + switch (char) { + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + case '"': + return '"'; + case "'": + return '''; + default: + return char; + } + }) + // Remove control characters and non-printable characters + .replace(/[^\x20-\x7E]/g, '') + ); +}; + +// Security: Sanitize tag key to prevent XSS +const sanitizeTagKey = (key: string): string | null => { + // Only allow whitelisted keys + if (!ALLOWED_TAG_KEYS.has(key)) { + return null; + } + + // Additional validation: alphanumeric and underscores only + if (!/^[a-zA-Z0-9_]+$/.test(key)) { + return null; + } + + return key; +}; + +export interface NFCScanContext extends BaseContext, Record { + scanType: 'mrz' | 'can'; +} + +export interface ProofContext extends BaseContext, Record { + circuitType: 'register' | 'dsc' | 'disclose' | null; + currentState: string; +} + export const captureException = ( error: Error, context?: Record, @@ -118,6 +203,78 @@ export const initSentry = () => { export const isSentryDisabled = !SENTRY_DSN; +type LogLevel = 'info' | 'warn' | 'error'; +type LogCategory = 'proof' | 'nfc'; + +export const logEvent = ( + level: LogLevel, + category: LogCategory, + message: string, + context: BaseContext & Record, + extra?: Record, +) => { + if (isSentryDisabled) { + return; + } + + const { sessionId, userId, platform, stage, ...rest } = context; + const data = { + session_id: sessionId, + user_id: userId, + platform, + stage, + ...rest, + ...extra, + }; + + if (level === 'error') { + withScope(scope => { + scope.setLevel('error'); + scope.setTag('session_id', sessionId); + scope.setTag('platform', platform); + scope.setTag('stage', stage); + Object.entries(rest).forEach(([key, value]) => { + const sanitizedKey = sanitizeTagKey(key); + if (sanitizedKey) { + const sanitizedValue = sanitizeTagValue(value); + scope.setTag(sanitizedKey, sanitizedValue); + } + }); + if (userId) { + scope.setUser({ id: userId }); + } + if (extra) { + Object.entries(extra).forEach(([key, value]) => { + scope.setExtra(key, value); + }); + } + sentryCaptureMessage(message); + }); + } else { + addBreadcrumb({ + message, + level: level === 'warn' ? 'warning' : 'info', + category, + data, + timestamp: Date.now() / 1000, + }); + } +}; + +export const logNFCEvent = ( + level: LogLevel, + message: string, + context: NFCScanContext, + extra?: Record, +) => logEvent(level, 'nfc', message, context, extra); + +export const logProofEvent = ( + level: LogLevel, + message: string, + context: ProofContext, + extra?: Record, +) => logEvent(level, 'proof', message, context, extra); + export const wrapWithSentry = (App: React.ComponentType) => { return isSentryDisabled ? App : wrap(App); }; diff --git a/app/src/Sentry.web.ts b/app/src/Sentry.web.ts index df8cf4210..d524f8948 100644 --- a/app/src/Sentry.web.ts +++ b/app/src/Sentry.web.ts @@ -4,14 +4,99 @@ import { SENTRY_DSN } from '@env'; import { + addBreadcrumb, captureException as sentryCaptureException, captureFeedback as sentryCaptureFeedback, captureMessage as sentryCaptureMessage, feedbackIntegration, init as sentryInit, withProfiler, + withScope, } from '@sentry/react'; +interface BaseContext { + sessionId: string; + userId?: string; + platform: 'ios' | 'android'; + stage: string; +} + +// Security: Whitelist of allowed tag keys to prevent XSS +const ALLOWED_TAG_KEYS = new Set([ + 'session_id', + 'platform', + 'stage', + 'circuitType', + 'currentState', + 'scanType', + 'error_code', + 'proof_step', + 'scan_result', + 'verification_status', + 'document_type', +]); + +// Security: Sanitize tag values to prevent XSS +const sanitizeTagValue = (value: unknown): string => { + if (value == null) return ''; + + const stringValue = String(value); + + // Truncate to safe length + const MAX_TAG_LENGTH = 200; + const truncated = + stringValue.length > MAX_TAG_LENGTH + ? stringValue.substring(0, MAX_TAG_LENGTH) + '...' + : stringValue; + + // Escape HTML characters and remove potentially dangerous characters + return ( + truncated + .replace(/[<>&"']/g, char => { + switch (char) { + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + case '"': + return '"'; + case "'": + return '''; + default: + return char; + } + }) + // Remove control characters and non-printable characters + .replace(/[^\x20-\x7E]/g, '') + ); +}; + +// Security: Sanitize tag key to prevent XSS +const sanitizeTagKey = (key: string): string | null => { + // Only allow whitelisted keys + if (!ALLOWED_TAG_KEYS.has(key)) { + return null; + } + + // Additional validation: alphanumeric and underscores only + if (!/^[a-zA-Z0-9_]+$/.test(key)) { + return null; + } + + return key; +}; + +export interface NFCScanContext extends BaseContext, Record { + scanType: 'mrz' | 'can'; +} + +export interface ProofContext extends BaseContext, Record { + circuitType: 'register' | 'dsc' | 'disclose' | null; + currentState: string; +} + export const captureException = ( error: Error, context?: Record, @@ -110,6 +195,78 @@ export const initSentry = () => { export const isSentryDisabled = !SENTRY_DSN; +type LogLevel = 'info' | 'warn' | 'error'; +type LogCategory = 'proof' | 'nfc'; + +export const logEvent = ( + level: LogLevel, + category: LogCategory, + message: string, + context: BaseContext & Record, + extra?: Record, +) => { + if (isSentryDisabled) { + return; + } + + const { sessionId, userId, platform, stage, ...rest } = context; + const data = { + session_id: sessionId, + user_id: userId, + platform, + stage, + ...rest, + ...extra, + }; + + if (level === 'error') { + withScope(scope => { + scope.setLevel('error'); + scope.setTag('session_id', sessionId); + scope.setTag('platform', platform); + scope.setTag('stage', stage); + Object.entries(rest).forEach(([key, value]) => { + const sanitizedKey = sanitizeTagKey(key); + if (sanitizedKey) { + const sanitizedValue = sanitizeTagValue(value); + scope.setTag(sanitizedKey, sanitizedValue); + } + }); + if (userId) { + scope.setUser({ id: userId }); + } + if (extra) { + Object.entries(extra).forEach(([key, value]) => { + scope.setExtra(key, value); + }); + } + sentryCaptureMessage(message); + }); + } else { + addBreadcrumb({ + message, + level: level === 'warn' ? 'warning' : 'info', + category, + data, + timestamp: Date.now() / 1000, + }); + } +}; + +export const logNFCEvent = ( + level: LogLevel, + message: string, + context: NFCScanContext, + extra?: Record, +) => logEvent(level, 'nfc', message, context, extra); + +export const logProofEvent = ( + level: LogLevel, + message: string, + context: ProofContext, + extra?: Record, +) => logEvent(level, 'proof', message, context, extra); + export const wrapWithSentry = (App: React.ComponentType) => { return isSentryDisabled ? App : withProfiler(App); }; diff --git a/app/src/screens/document/DocumentNFCScanScreen.tsx b/app/src/screens/document/DocumentNFCScanScreen.tsx index 7307e6cdc..abbfe683e 100644 --- a/app/src/screens/document/DocumentNFCScanScreen.tsx +++ b/app/src/screens/document/DocumentNFCScanScreen.tsx @@ -14,6 +14,7 @@ import { import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import NfcManager from 'react-native-nfc-manager'; import { Button, Image, XStack } from 'tamagui'; +import { v4 as uuidv4 } from 'uuid'; import type { RouteProp } from '@react-navigation/native'; import { useFocusEffect, @@ -44,6 +45,7 @@ import NFC_IMAGE from '@/images/nfc.png'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { useFeedback } from '@/providers/feedbackProvider'; import { storePassportData } from '@/providers/passportDataProvider'; +import { logNFCEvent } from '@/Sentry'; import useUserStore from '@/stores/userStore'; import { flushAllAnalytics, @@ -104,6 +106,13 @@ const DocumentNFCScanScreen: React.FC = () => { const [nfcMessage, setNfcMessage] = useState(null); const scanTimeoutRef = useRef | null>(null); const scanCancelledRef = useRef(false); + const sessionIdRef = useRef(uuidv4()); + + const baseContext = { + sessionId: sessionIdRef.current, + platform: Platform.OS as 'ios' | 'android', + scanType: route.params?.useCan ? 'can' : 'mrz', + } as const; const animationRef = useRef(null); @@ -111,6 +120,16 @@ const DocumentNFCScanScreen: React.FC = () => { animationRef.current?.play(); }, []); + useEffect(() => { + logNFCEvent('info', 'screen_mount', { ...baseContext, stage: 'mount' }); + return () => { + logNFCEvent('info', 'screen_unmount', { + ...baseContext, + stage: 'unmount', + }); + }; + }, []); + // Cleanup timeout on component unmount useEffect(() => { return () => { @@ -144,6 +163,15 @@ const DocumentNFCScanScreen: React.FC = () => { const openErrorModal = useCallback( (message: string) => { flushAllAnalytics(); + logNFCEvent( + 'error', + 'nfc_error_modal', + { + ...baseContext, + stage: 'error', + }, + { message: sanitizeErrorMessage(message) }, + ); showModal({ titleText: 'NFC Scan Error', bodyText: message, @@ -171,6 +199,18 @@ const DocumentNFCScanScreen: React.FC = () => { setDialogMessage('NFC is not enabled. Please enable it in settings.'); } setIsNfcSupported(true); + logNFCEvent( + 'info', + 'nfc_capability', + { + ...baseContext, + stage: 'check', + }, + { + supported: true, + enabled: isEnabled, + }, + ); } else { setDialogMessage( "Sorry, your device doesn't seem to have an NFC reader.", @@ -179,6 +219,18 @@ const DocumentNFCScanScreen: React.FC = () => { // near the disabled button when NFC isn't supported setIsNfcEnabled(false); setIsNfcSupported(false); + logNFCEvent( + 'warn', + 'nfc_capability', + { + ...baseContext, + stage: 'check', + }, + { + supported: false, + enabled: false, + }, + ); } }, []); @@ -200,7 +252,12 @@ const DocumentNFCScanScreen: React.FC = () => { const onVerifyPress = useCallback(async () => { buttonTap(); if (isNfcEnabled) { + logNFCEvent('info', 'verify_pressed', { + ...baseContext, + stage: 'ui', + }); setIsNfcSheetOpen(true); + logNFCEvent('info', 'sheet_open', { ...baseContext, stage: 'ui' }); // Add timestamp when scan starts scanCancelledRef.current = false; const scanStartTime = Date.now(); @@ -213,8 +270,16 @@ const DocumentNFCScanScreen: React.FC = () => { trackEvent(PassportEvents.NFC_SCAN_FAILED, { error: 'timeout', }); + logNFCEvent('warn', 'scan_timeout', { + ...baseContext, + stage: 'timeout', + }); openErrorModal('Scan timed out. Please try again.'); setIsNfcSheetOpen(false); + logNFCEvent('info', 'sheet_close', { + ...baseContext, + stage: 'ui', + }); }, 30000); // Mark NFC scanning as active to prevent analytics flush interference @@ -233,8 +298,16 @@ const DocumentNFCScanScreen: React.FC = () => { trackNfcEvent(PassportEvents.NFC_SCAN_FAILED, { error: 'timeout', }); + logNFCEvent('warn', 'scan_timeout', { + ...baseContext, + stage: 'timeout', + }); openErrorModal('Scan timed out. Please try again.'); setIsNfcSheetOpen(false); + logNFCEvent('info', 'sheet_close', { + ...baseContext, + stage: 'ui', + }); }, 30000); try { @@ -251,6 +324,7 @@ const DocumentNFCScanScreen: React.FC = () => { skipCA, extendedMode, usePacePolling: isPacePolling, + sessionId: sessionIdRef.current, }); // Check if scan was cancelled by timeout @@ -270,6 +344,15 @@ const DocumentNFCScanScreen: React.FC = () => { trackEvent(PassportEvents.NFC_SCAN_SUCCESS, { duration_seconds: parseFloat(scanDurationSeconds), }); + logNFCEvent( + 'info', + 'scan_success', + { + ...baseContext, + stage: 'complete', + }, + { duration_seconds: parseFloat(scanDurationSeconds) }, + ); let passportData: PassportData | null = null; let parsedPassportData: PassportData | null = null; try { @@ -388,6 +471,7 @@ const DocumentNFCScanScreen: React.FC = () => { scanTimeoutRef.current = null; } setIsNfcSheetOpen(false); + logNFCEvent('info', 'sheet_close', { ...baseContext, stage: 'ui' }); setNfcScanningActive(false); } } else if (isNfcSupported) { @@ -419,6 +503,7 @@ const DocumentNFCScanScreen: React.FC = () => { const onCancelPress = async () => { flushAllAnalytics(); + logNFCEvent('info', 'scan_cancelled', { ...baseContext, stage: 'cancel' }); const hasValidDocument = await hasAnyValidRegisteredDocument(selfClient); if (hasValidDocument) { navigateToHome(); @@ -435,6 +520,7 @@ const DocumentNFCScanScreen: React.FC = () => { useFocusEffect( useCallback(() => { + logNFCEvent('info', 'screen_focus', { ...baseContext, stage: 'focus' }); checkNfcSupport(); if (Platform.OS === 'android' && emitter) { @@ -469,6 +555,7 @@ const DocumentNFCScanScreen: React.FC = () => { ); return () => { + logNFCEvent('info', 'screen_blur', { ...baseContext, stage: 'blur' }); subscription.remove(); // Clear scan timeout when component loses focus scanCancelledRef.current = true; @@ -481,6 +568,7 @@ const DocumentNFCScanScreen: React.FC = () => { // For iOS or when no emitter, still handle timeout cleanup on blur return () => { + logNFCEvent('info', 'screen_blur', { ...baseContext, stage: 'blur' }); scanCancelledRef.current = true; if (scanTimeoutRef.current) { clearTimeout(scanTimeoutRef.current); diff --git a/app/src/types/react-native-passport-reader.d.ts b/app/src/types/react-native-passport-reader.d.ts index 3a347402d..df5d16167 100644 --- a/app/src/types/react-native-passport-reader.d.ts +++ b/app/src/types/react-native-passport-reader.d.ts @@ -10,6 +10,7 @@ declare module 'react-native-passport-reader' { canNumber: string; useCan: boolean; quality?: number; + sessionId?: string; } interface PassportReader { @@ -27,6 +28,7 @@ declare module 'react-native-passport-reader' { skipCA: boolean, extendedMode: boolean, usePacePolling: boolean, + sessionId: string, ): Promise<{ mrz: string; eContent: string; diff --git a/app/src/utils/nfcScanner.ts b/app/src/utils/nfcScanner.ts index 487825ca8..1094299d2 100644 --- a/app/src/utils/nfcScanner.ts +++ b/app/src/utils/nfcScanner.ts @@ -7,6 +7,7 @@ import { Platform } from 'react-native'; import type { PassportData } from '@selfxyz/common/types'; +import { logNFCEvent, type NFCScanContext } from '@/Sentry'; import { configureNfcAnalytics } from '@/utils/analytics'; import { PassportReader, @@ -39,6 +40,8 @@ interface Inputs { skipCA?: boolean; extendedMode?: boolean; usePacePolling?: boolean; + sessionId: string; + userId?: string; } export const parseScanResponse = (response: unknown) => { @@ -50,18 +53,46 @@ export const parseScanResponse = (response: unknown) => { export const scan = async (inputs: Inputs) => { await configureNfcAnalytics(); - return Platform.OS === 'android' - ? await scanAndroid(inputs) - : await scanIOS(inputs); + const baseContext = { + sessionId: inputs.sessionId, + userId: inputs.userId, + platform: Platform.OS as 'ios' | 'android', + scanType: inputs.useCan ? 'can' : 'mrz', + } as const; + + logNFCEvent('info', 'scan_start', { ...baseContext, stage: 'start' }); + + try { + return Platform.OS === 'android' + ? await scanAndroid(inputs, baseContext) + : await scanIOS(inputs, baseContext); + } catch (error) { + logNFCEvent( + 'error', + 'scan_failed', + { ...baseContext, stage: 'scan' }, + { + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } }; -const scanAndroid = async (inputs: Inputs) => { +const scanAndroid = async ( + inputs: Inputs, + context: Omit, +) => { reset(); if (!scanDocument) { console.warn( 'Android passport scanner is not available - native module failed to load', ); + logNFCEvent('error', 'module_unavailable', { + ...context, + stage: 'init', + } as NFCScanContext); return Promise.reject(new Error('NFC scanning is currently unavailable.')); } @@ -71,14 +102,22 @@ const scanAndroid = async (inputs: Inputs) => { dateOfExpiry: inputs.dateOfExpiry, canNumber: inputs.canNumber ?? '', useCan: inputs.useCan ?? false, + sessionId: inputs.sessionId, }); }; -const scanIOS = async (inputs: Inputs) => { +const scanIOS = async ( + inputs: Inputs, + context: Omit, +) => { if (!PassportReader?.scanPassport) { console.warn( 'iOS passport scanner is not available - native module failed to load', ); + logNFCEvent('error', 'module_unavailable', { + ...context, + stage: 'init', + } as NFCScanContext); return Promise.reject( new Error( 'NFC scanning is currently unavailable. Please ensure the app is properly installed.', @@ -97,6 +136,7 @@ const scanIOS = async (inputs: Inputs) => { inputs.skipCA ?? false, inputs.extendedMode ?? false, inputs.usePacePolling ?? false, + inputs.sessionId, ), ); }; diff --git a/app/src/utils/passportReader.ts b/app/src/utils/passportReader.ts index bb8431784..41ddbbef9 100644 --- a/app/src/utils/passportReader.ts +++ b/app/src/utils/passportReader.ts @@ -14,6 +14,7 @@ type ScanOptions = { skipCA?: boolean; extendedMode?: boolean; usePacePolling?: boolean; + sessionId?: string; }; // Platform-specific PassportReader implementation @@ -55,6 +56,7 @@ if (Platform.OS === 'android') { skipCA = false, extendedMode = false, usePacePolling = true, + sessionId = '', } = options; const result = await PassportReader.scanPassport( @@ -67,6 +69,7 @@ if (Platform.OS === 'android') { skipCA, extendedMode, usePacePolling, + sessionId, ); // iOS native returns a JSON string; normalize to object. try { diff --git a/app/src/utils/proving/provingMachine.ts b/app/src/utils/proving/provingMachine.ts index 6d1c41102..d8280add1 100644 --- a/app/src/utils/proving/provingMachine.ts +++ b/app/src/utils/proving/provingMachine.ts @@ -3,10 +3,11 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import forge from 'node-forge'; +import { Platform } from 'react-native'; import type { Socket } from 'socket.io-client'; import socketIo from 'socket.io-client'; import { v4 } from 'uuid'; -import type { AnyActorRef, StateFrom } from 'xstate'; +import type { AnyActorRef, AnyEventObject, StateFrom } from 'xstate'; import { createActor, createMachine } from 'xstate'; import { create } from 'zustand'; @@ -37,6 +38,7 @@ import { getPayload, getWSDbRelayerUrl, } from '@selfxyz/common/utils/proving'; +import type { SelfClient } from '@selfxyz/mobile-sdk-alpha'; import { clearPassportData, hasAnyValidRegisteredDocument, @@ -44,7 +46,6 @@ import { markCurrentDocumentAsRegistered, reStorePassportDataWithRightCSCA, SdkEvents, - SelfClient, } from '@selfxyz/mobile-sdk-alpha'; import { PassportEvents, @@ -55,6 +56,159 @@ import { useSelfAppStore, } from '@selfxyz/mobile-sdk-alpha/stores'; +import { logProofEvent, type ProofContext } from '@/Sentry'; +// import analytics from '@/utils/analytics'; +import { + handleStatusCode, + parseStatusMessage, +} from '@/utils/proving/statusHandlers'; + +// Helper functions for WebSocket URL resolution +const getMappingKey = ( + circuitType: 'disclose' | 'register' | 'dsc', + documentCategory: DocumentCategory, +): string => { + if (circuitType === 'disclose') { + return documentCategory === 'passport' ? 'DISCLOSE' : 'DISCLOSE_ID'; + } + if (circuitType === 'register') { + return documentCategory === 'passport' ? 'REGISTER' : 'REGISTER_ID'; + } + // circuitType === 'dsc' + return documentCategory === 'passport' ? 'DSC' : 'DSC_ID'; +}; + +const resolveWebSocketUrl = ( + circuitType: 'disclose' | 'register' | 'dsc', + passportData: PassportData, + circuitName: string, +): string | undefined => { + const { documentCategory } = passportData; + const circuitsMapping = + useProtocolStore.getState()[documentCategory].circuits_dns_mapping; + const mappingKey = getMappingKey(circuitType, documentCategory); + + return circuitsMapping?.[mappingKey]?.[circuitName]; +}; + +// Helper functions for _generatePayload refactoring +const _generateCircuitInputs = ( + circuitType: 'disclose' | 'register' | 'dsc', + secret: string | undefined | null, + passportData: PassportData, + env: 'prod' | 'stg', +) => { + const document: DocumentCategory = passportData.documentCategory; + const protocolStore = useProtocolStore.getState(); + const selfApp = useSelfAppStore.getState().selfApp; + + let inputs, + circuitName, + endpointType, + endpoint, + circuitTypeWithDocumentExtension; + + switch (circuitType) { + case 'register': + ({ inputs, circuitName, endpointType, endpoint } = + generateTEEInputsRegister( + secret as string, + passportData, + protocolStore[document].dsc_tree, + env, + )); + circuitTypeWithDocumentExtension = `${circuitType}${document === 'passport' ? '' : '_id'}`; + break; + case 'dsc': + ({ inputs, circuitName, endpointType, endpoint } = generateTEEInputsDSC( + passportData, + protocolStore[document].csca_tree as string[][], + env, + )); + circuitTypeWithDocumentExtension = `${circuitType}${document === 'passport' ? '' : '_id'}`; + break; + case 'disclose': + ({ inputs, circuitName, endpointType, endpoint } = + generateTEEInputsDiscloseStateless( + secret as string, + passportData, + selfApp as SelfApp, + (doc: DocumentCategory, tree) => { + const docStore = + doc === 'passport' + ? protocolStore.passport + : protocolStore.id_card; + switch (tree) { + case 'ofac': + return docStore.ofac_trees; + case 'commitment': + if (!docStore.commitment_tree) { + throw new Error('Commitment tree not loaded'); + } + return docStore.commitment_tree; + default: + throw new Error('Unknown tree type'); + } + }, + )); + circuitTypeWithDocumentExtension = `disclose`; + break; + default: + throw new Error('Invalid circuit type:' + circuitType); + } + + return { + inputs, + circuitName, + endpointType, + endpoint, + circuitTypeWithDocumentExtension, + }; +}; + +const JSONRPC_VERSION = '2.0' as const; +const SUBMIT_METHOD = 'openpassport_submit_request' as const; +const SUBMIT_ID = 2 as const; + +type EncryptedPayload = { + nonce: number[]; + cipher_text: number[]; + auth_tag: number[]; +}; + +type SubmitRequest = { + jsonrpc: typeof JSONRPC_VERSION; + method: typeof SUBMIT_METHOD; + id: typeof SUBMIT_ID; + params: { uuid: string | null } & EncryptedPayload; +}; + +const _encryptPayload = ( + payload: unknown, + sharedKey: Buffer, +): EncryptedPayload => { + const forgeKey = forge.util.createBuffer(sharedKey.toString('binary')); + return encryptAES256GCM(JSON.stringify(payload), forgeKey); +}; + +const _buildSubmitRequest = ( + uuid: string | null, + encryptedPayload: EncryptedPayload, +): SubmitRequest => { + return { + jsonrpc: JSONRPC_VERSION, + method: SUBMIT_METHOD, + id: SUBMIT_ID, + params: { + uuid: uuid, + ...encryptedPayload, + }, + }; +}; + +const getPlatform = (): 'ios' | 'android' => + Platform.OS === 'ios' ? 'ios' : 'android'; + export type ProvingStateType = // Initial states | 'idle' @@ -237,8 +391,21 @@ export const useProvingStore = create((set, get) => { newActor: AnyActorRef, selfClient: SelfClient, ) { + let lastTransition = Date.now(); + let lastEvent: AnyEventObject = { type: 'init' }; + newActor.on('*', (event: AnyEventObject) => { + lastEvent = event; + }); newActor.subscribe((state: StateFrom) => { - console.log(`State transition: ${state.value}`); + const now = Date.now(); + const context = createProofContext('stateTransition', { + currentState: String(state.value), + }); + logProofEvent('info', `State transition: ${state.value}`, context, { + event: lastEvent.type, + duration_ms: now - lastTransition, + }); + lastTransition = now; selfClient.trackEvent(ProofEvents.PROVING_STATE_CHANGE, { state: state.value, }); @@ -359,10 +526,17 @@ export const useProvingStore = create((set, get) => { console.error('Cannot process message: State machine not initialized.'); return; } + + const startTime = Date.now(); + const context = createProofContext('_handleWebSocketMessage'); + try { const result = JSON.parse(event.data); + logProofEvent('info', 'WebSocket message received', context); if (result.result?.attestation) { selfClient?.trackEvent(ProofEvents.ATTESTATION_RECEIVED); + logProofEvent('info', 'Attestation received', context); + const attestationData = result.result.attestation; set({ attestation: attestationData }); @@ -370,12 +544,17 @@ export const useProvingStore = create((set, get) => { const verified = await verifyAttestation(attestationData); if (!verified) { + logProofEvent('error', 'Attestation verification failed', context, { + failure: 'PROOF_FAILED_TEE_PROCESSING', + duration_ms: Date.now() - startTime, + }); console.error('Attestation verification failed'); actor!.send({ type: 'CONNECT_ERROR' }); return; } selfClient?.trackEvent(ProofEvents.ATTESTATION_VERIFIED); + logProofEvent('info', 'Attestation verified', context); const serverKey = ec.keyFromPublic(serverPubkey as string, 'hex'); const derivedKey = clientKey.derive(serverKey.getPublic()); @@ -385,6 +564,7 @@ export const useProvingStore = create((set, get) => { sharedKey: Buffer.from(derivedKey.toArray('be', 32)), }); selfClient?.trackEvent(ProofEvents.SHARED_KEY_DERIVED); + logProofEvent('info', 'Shared key derived', context); actor!.send({ type: 'CONNECT_SUCCESS' }); } else if ( @@ -393,17 +573,24 @@ export const useProvingStore = create((set, get) => { !result.error ) { selfClient?.trackEvent(ProofEvents.WS_HELLO_ACK); + logProofEvent('info', 'Hello ACK received', context); + // Received status from TEE const statusUuid = result.result; if (get().uuid !== statusUuid) { + logProofEvent('warn', 'Status UUID mismatch', context, { + received_uuid: statusUuid, + }); console.warn( - `Received status UUID (${statusUuid}) does not match stored UUID (${ - get().uuid - }). Using received UUID.`, + `Received status UUID (${statusUuid}) does not match stored UUID (${get().uuid}). Using received UUID.`, ); } const endpointType = get().endpointType; if (!endpointType) { + logProofEvent('error', 'Endpoint type missing', context, { + failure: 'PROOF_FAILED_TEE_PROCESSING', + duration_ms: Date.now() - startTime, + }); console.error( 'Cannot start Socket.IO listener: endpointType not set.', ); @@ -420,6 +607,11 @@ export const useProvingStore = create((set, get) => { selfClient, ); } else if (result.error) { + logProofEvent('error', 'TEE returned error', context, { + failure: 'PROOF_FAILED_TEE_PROCESSING', + error: result.error, + duration_ms: Date.now() - startTime, + }); console.error('Received error from TEE:', result.error); selfClient?.trackEvent(ProofEvents.TEE_WS_ERROR, { error: result.error, @@ -430,9 +622,18 @@ export const useProvingStore = create((set, get) => { }); actor!.send({ type: 'PROVE_ERROR' }); } else { + logProofEvent('warn', 'Unknown message format', context); console.warn('Received unknown message format from TEE:', result); } } catch (error) { + logProofEvent('error', 'WebSocket message handling failed', context, { + failure: + get().currentState === 'init_tee_connexion' + ? 'PROOF_FAILED_CONNECTION' + : 'PROOF_FAILED_TEE_PROCESSING', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startTime, + }); console.error('Error processing WebSocket message:', error); if (get().currentState === 'init_tee_connexion') { selfClient?.trackEvent(ProofEvents.TEE_CONN_FAILED, { @@ -451,7 +652,6 @@ export const useProvingStore = create((set, get) => { } } }, - _handleRegisterErrorOrFailure: async (selfClient: SelfClient) => { try { const hasValid = await hasAnyValidRegisteredDocument(selfClient); @@ -475,7 +675,6 @@ export const useProvingStore = create((set, get) => { console.error('Cannot start Socket.IO listener: Actor not available.'); return; } - const url = getWSDbRelayerUrl(endpointType); const socket: Socket = socketIo(url, { path: '/', @@ -483,10 +682,13 @@ export const useProvingStore = create((set, get) => { }); set({ socketConnection: socket }); selfClient.trackEvent(ProofEvents.SOCKETIO_CONN_STARTED); + const context = createProofContext('_startSocketIOStatusListener'); + logProofEvent('info', 'Socket.IO listener started', context, { url }); socket.on('connect', () => { socket?.emit('subscribe', receivedUuid); selfClient.trackEvent(ProofEvents.SOCKETIO_SUBSCRIBED); + logProofEvent('info', 'Socket.IO connected', context); }); socket.on('connect_error', error => { @@ -494,9 +696,9 @@ export const useProvingStore = create((set, get) => { selfClient.trackEvent(ProofEvents.SOCKETIO_CONNECT_ERROR, { message: error instanceof Error ? error.message : String(error), }); - selfClient.trackEvent(ProofEvents.PROOF_FAILED, { - circuitType: get().circuitType, - error: get().error_code ?? 'unknown', + logProofEvent('error', 'Socket.IO connection error', context, { + failure: 'PROOF_FAILED_CONNECTION', + error: error instanceof Error ? error.message : String(error), }); actor!.send({ type: 'PROVE_ERROR' }); set({ socketConnection: null }); @@ -504,52 +706,82 @@ export const useProvingStore = create((set, get) => { socket.on('disconnect', (_reason: string) => { const currentActor = actor; - + logProofEvent('warn', 'Socket.IO disconnected', context); if (get().currentState === 'ready_to_prove' && currentActor) { console.error( 'SocketIO disconnected unexpectedly during proof listening.', ); selfClient.trackEvent(ProofEvents.SOCKETIO_DISCONNECT_UNEXPECTED); - selfClient.trackEvent(ProofEvents.PROOF_FAILED, { - circuitType: get().circuitType, - error: get().error_code ?? 'unknown', - }); + logProofEvent( + 'error', + 'Socket.IO disconnected unexpectedly', + context, + { + failure: 'PROOF_FAILED_CONNECTION', + }, + ); currentActor.send({ type: 'PROVE_ERROR' }); } set({ socketConnection: null }); }); socket.on('status', (message: unknown) => { - const data = - typeof message === 'string' ? JSON.parse(message) : message; - selfClient.trackEvent(ProofEvents.SOCKETIO_STATUS_RECEIVED, { - status: data.status, - }); - if (data.status === 3 || data.status === 5) { - console.error( - 'Proof generation/verification failed (status 3 or 5).', - ); - console.error(data); - set({ error_code: data.error_code, reason: data.reason }); - selfClient.trackEvent(ProofEvents.SOCKETIO_PROOF_FAILURE, { - error_code: data.error_code, - reason: data.reason, + try { + const data = parseStatusMessage(message); + + selfClient.trackEvent(ProofEvents.SOCKETIO_STATUS_RECEIVED, { + status: data.status, }); - selfClient.trackEvent(ProofEvents.PROOF_FAILED, { - circuitType: get().circuitType, - error: data.error_code ?? 'unknown', + logProofEvent('info', 'Status message received', context, { + status: data.status, }); - actor!.send({ type: 'PROVE_FAILURE' }); - socket?.disconnect(); - set({ socketConnection: null }); - } else if (data.status === 4) { - socket?.disconnect(); - set({ socketConnection: null }); - if (get().circuitType === 'register') { - selfClient.trackEvent(ProofEvents.REGISTER_COMPLETED); + + const result = handleStatusCode(data, get().circuitType as string); + + // Handle state updates + if (result.stateUpdate) { + set(result.stateUpdate); } - selfClient.trackEvent(ProofEvents.SOCKETIO_PROOF_SUCCESS); - actor!.send({ type: 'PROVE_SUCCESS' }); + + // Handle analytics + result.analytics?.forEach(({ event, data: eventData }) => { + if (event === 'SOCKETIO_PROOF_FAILURE') { + logProofEvent('error', 'TEE processing failed', context, { + failure: 'PROOF_FAILED_TEE_PROCESSING', + error_code: eventData?.error_code, + reason: eventData?.reason, + }); + } else if (event === 'SOCKETIO_PROOF_SUCCESS') { + logProofEvent('info', 'TEE processing succeeded', context); + } + selfClient.trackEvent( + event as unknown as keyof typeof ProofEvents, + eventData, + ); + }); + + // Handle actor events + if (result.actorEvent) { + if (result.actorEvent.type === 'PROVE_FAILURE') { + console.error( + 'Proof generation/verification failed (status 3 or 5).', + ); + console.error(data); + } + actor!.send(result.actorEvent); + } + + // Handle disconnection + if (result.shouldDisconnect) { + socket?.disconnect(); + } + } catch (error) { + console.error('Error handling status message:', error); + logProofEvent('error', 'Status message parsing failed', context, { + failure: 'PROOF_FAILED_MESSAGE_PARSING', + error: error instanceof Error ? error.message : String(error), + }); + actor!.send({ type: 'PROVE_ERROR' }); } }); }, @@ -567,7 +799,10 @@ export const useProvingStore = create((set, get) => { selfClient.trackEvent(ProofEvents.CONNECTION_UUID_GENERATED, { connection_uuid: connectionUuid, }); - + const context = createProofContext('_handleWsOpen', { + sessionId: connectionUuid, + }); + logProofEvent('info', 'WebSocket open', context); set({ uuid: connectionUuid }); const helloBody = { jsonrpc: '2.0', @@ -583,6 +818,7 @@ export const useProvingStore = create((set, get) => { }; selfClient.trackEvent(ProofEvents.WS_HELLO_SENT); ws.send(JSON.stringify(helloBody)); + logProofEvent('info', 'WS hello sent', context); }, _handleWsError: (error: Event, selfClient: SelfClient) => { @@ -590,6 +826,11 @@ export const useProvingStore = create((set, get) => { if (!actor) { return; } + const context = createProofContext('_handleWsError'); + logProofEvent('error', 'TEE WebSocket error', context, { + failure: 'PROOF_FAILED_CONNECTION', + error: error instanceof Error ? error.message : String(error), + }); get()._handleWebSocketMessage( new MessageEvent('error', { data: JSON.stringify({ error: 'WebSocket connection error' }), @@ -606,6 +847,11 @@ export const useProvingStore = create((set, get) => { if (!actor) { return; } + const context = createProofContext('_handleWsClose'); + logProofEvent('warn', 'TEE WebSocket closed', context, { + code: event.code, + reason: event.reason, + }); const currentState = get().currentState; if ( currentState === 'init_tee_connexion' || @@ -695,12 +941,19 @@ export const useProvingStore = create((set, get) => { startFetchingData: async (selfClient: SelfClient) => { _checkActorInitialized(actor); selfClient.trackEvent(ProofEvents.FETCH_DATA_STARTED); + const startTime = Date.now(); + const context = createProofContext('startFetchingData'); + logProofEvent('info', 'Fetching DSC data started', context); try { const { passportData, env } = get(); if (!passportData) { throw new Error('PassportData is not available'); } if (!passportData?.dsc_parsed) { + logProofEvent('error', 'Missing parsed DSC', context, { + failure: 'PROOF_FAILED_DATA_FETCH', + duration_ms: Date.now() - startTime, + }); console.error('Missing parsed DSC in passport data'); selfClient.trackEvent(ProofEvents.FETCH_DATA_FAILED, { message: 'Missing parsed DSC in passport data', @@ -709,14 +962,26 @@ export const useProvingStore = create((set, get) => { return; } const document: DocumentCategory = passportData.documentCategory; + logProofEvent('info', 'Protocol store fetch', context, { + step: 'protocol_store_fetch', + document, + }); await useProtocolStore .getState() [ document ].fetch_all(env!, (passportData as PassportData).dsc_parsed!.authorityKeyIdentifier); + logProofEvent('info', 'Data fetch succeeded', context, { + duration_ms: Date.now() - startTime, + }); selfClient.trackEvent(ProofEvents.FETCH_DATA_SUCCESS); actor!.send({ type: 'FETCH_SUCCESS' }); } catch (error) { + logProofEvent('error', 'Data fetch failed', context, { + failure: 'PROOF_FAILED_DATA_FETCH', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startTime, + }); console.error('Error fetching data:', error); selfClient.trackEvent(ProofEvents.FETCH_DATA_FAILED, { message: error instanceof Error ? error.message : String(error), @@ -729,6 +994,9 @@ export const useProvingStore = create((set, get) => { _checkActorInitialized(actor); // TODO: for the disclosure, we could check that the selfApp is a valid one. selfClient.trackEvent(ProofEvents.VALIDATION_STARTED); + const startTime = Date.now(); + const context = createProofContext('validatingDocument'); + logProofEvent('info', 'Validating document started', context); try { const { passportData, secret, circuitType } = get(); if (!passportData) { @@ -738,7 +1006,16 @@ export const useProvingStore = create((set, get) => { getDeployedCircuits: (documentCategory: DocumentCategory) => useProtocolStore.getState()[documentCategory].deployed_circuits!, }); + logProofEvent('info', 'Document support check', context, { + supported: isSupported.status === 'passport_supported', + duration_ms: Date.now() - startTime, + }); if (isSupported.status !== 'passport_supported') { + logProofEvent('error', 'Passport not supported', context, { + failure: 'PROOF_FAILED_VALIDATION', + details: isSupported.details, + duration_ms: Date.now() - startTime, + }); console.error( 'Passport not supported:', isSupported.status, @@ -758,17 +1035,26 @@ export const useProvingStore = create((set, get) => { useProtocolStore.getState()[documentCategory].commitment_tree; /// disclosure if (circuitType === 'disclose') { - // check if the user is registered using the csca from the passport data. const isRegisteredWithLocalCSCA = await isUserRegistered( passportData, secret as string, getCommitmentTree, ); + logProofEvent('info', 'Local CSCA registration check', context, { + registered: isRegisteredWithLocalCSCA, + }); if (isRegisteredWithLocalCSCA) { + logProofEvent('info', 'Validation succeeded', context, { + duration_ms: Date.now() - startTime, + }); selfClient.trackEvent(ProofEvents.VALIDATION_SUCCESS); actor!.send({ type: 'VALIDATION_SUCCESS' }); return; } else { + logProofEvent('error', 'Passport data not found', context, { + failure: 'PROOF_FAILED_VALIDATION', + duration_ms: Date.now() - startTime, + }); actor!.send({ type: 'PASSPORT_DATA_NOT_FOUND' }); return; } @@ -786,6 +1072,14 @@ export const useProvingStore = create((set, get) => { useProtocolStore.getState()[docType].alternative_csca, }, ); + logProofEvent( + 'info', + 'Alternative CSCA registration check', + context, + { + registered: isRegistered, + }, + ); if (isRegistered) { await reStorePassportDataWithRightCSCA( selfClient, @@ -793,22 +1087,30 @@ export const useProvingStore = create((set, get) => { csca as string, ); - // Mark document as registered since its already onChain (async () => { try { await markCurrentDocumentAsRegistered(selfClient); } catch (error) { - //it will be checked and marked as registered during next app launch console.error('Error marking document as registered:', error); } })(); selfClient.trackEvent(ProofEvents.ALREADY_REGISTERED); + logProofEvent('info', 'Document already registered', context, { + duration_ms: Date.now() - startTime, + }); actor!.send({ type: 'ALREADY_REGISTERED' }); return; } const isNullifierOnchain = await isDocumentNullified(passportData); + logProofEvent('info', 'Nullifier check', context, { + nullified: isNullifierOnchain, + }); if (isNullifierOnchain) { + logProofEvent('error', 'Passport nullified', context, { + failure: 'PROOF_FAILED_VALIDATION', + duration_ms: Date.now() - startTime, + }); console.warn( 'Passport is nullified, but not registered with this secret. Navigating to AccountRecoveryChoice', ); @@ -821,14 +1123,25 @@ export const useProvingStore = create((set, get) => { passportData, useProtocolStore.getState()[document].dsc_tree, ); + logProofEvent('info', 'DSC tree check', context, { + dsc_registered: isDscRegistered, + }); if (isDscRegistered) { selfClient.trackEvent(ProofEvents.DSC_IN_TREE); set({ circuitType: 'register' }); } + logProofEvent('info', 'Validation succeeded', context, { + duration_ms: Date.now() - startTime, + }); selfClient.trackEvent(ProofEvents.VALIDATION_SUCCESS); actor!.send({ type: 'VALIDATION_SUCCESS' }); } } catch (error) { + logProofEvent('error', 'Validation failed', context, { + failure: 'PROOF_FAILED_VALIDATION', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startTime, + }); console.error('Error validating passport:', error); selfClient.trackEvent(ProofEvents.VALIDATION_FAILED, { message: error instanceof Error ? error.message : String(error), @@ -838,62 +1151,77 @@ export const useProvingStore = create((set, get) => { }, initTeeConnection: async (selfClient: SelfClient): Promise => { + const startTime = Date.now(); + const baseContext = createProofContext('initTeeConnection'); const { passportData } = get(); if (!passportData) { + logProofEvent('error', 'Passport data missing', baseContext, { + failure: 'PROOF_FAILED_CONNECTION', + duration_ms: Date.now() - startTime, + }); throw new Error('PassportData is not available'); } - const document: DocumentCategory = passportData.documentCategory; - const circuitsMapping = - useProtocolStore.getState()[document].circuits_dns_mapping; + const document: DocumentCategory = (passportData as PassportData) + .documentCategory; + const circuitType = get().circuitType as 'disclose' | 'register' | 'dsc'; - let circuitName, wsRpcUrl; - if (get().circuitType === 'disclose') { + let circuitName; + if (circuitType === 'disclose') { circuitName = 'disclose'; - if (passportData.documentCategory === 'passport') { - wsRpcUrl = circuitsMapping?.DISCLOSE?.[circuitName]; - } else { - wsRpcUrl = circuitsMapping?.DISCLOSE_ID?.[circuitName]; - } } else { circuitName = getCircuitNameFromPassportData( passportData, - get().circuitType as 'register' | 'dsc', + circuitType as 'register' | 'dsc', ); - if (get().circuitType === 'register') { - if (passportData.documentCategory === 'passport') { - wsRpcUrl = circuitsMapping?.REGISTER?.[circuitName]; - } else { - wsRpcUrl = circuitsMapping?.REGISTER_ID?.[circuitName]; - } - } else { - if (passportData.documentCategory === 'passport') { - wsRpcUrl = circuitsMapping?.DSC?.[circuitName]; - } else { - wsRpcUrl = circuitsMapping?.DSC_ID?.[circuitName]; - } - } } + + const wsRpcUrl = resolveWebSocketUrl( + circuitType, + passportData as PassportData, + circuitName, + ); + logProofEvent('info', 'Circuit resolution', baseContext, { + circuit_name: circuitName, + ws_url: wsRpcUrl, + }); if (!circuitName) { actor?.send({ type: 'CONNECT_ERROR' }); + logProofEvent('error', 'Circuit name missing', baseContext, { + failure: 'PROOF_FAILED_CONNECTION', + duration_ms: Date.now() - startTime, + }); throw new Error('Could not determine circuit name'); } if (!wsRpcUrl) { actor?.send({ type: 'CONNECT_ERROR' }); + logProofEvent('error', 'WebSocket URL missing', baseContext, { + failure: 'PROOF_FAILED_CONNECTION', + duration_ms: Date.now() - startTime, + }); throw new Error('No WebSocket URL available for TEE connection'); } get()._closeConnections(selfClient); selfClient.trackEvent(ProofEvents.TEE_CONN_STARTED); + logProofEvent('info', 'TEE connection attempt', baseContext); return new Promise(resolve => { const ws = new WebSocket(wsRpcUrl); const handleConnectSuccess = () => { + logProofEvent('info', 'TEE connection succeeded', baseContext, { + duration_ms: Date.now() - startTime, + }); selfClient.trackEvent(ProofEvents.TEE_CONN_SUCCESS); resolve(true); }; const handleConnectError = (msg: string = 'connect_error') => { + logProofEvent('error', 'TEE connection failed', baseContext, { + failure: 'PROOF_FAILED_CONNECTION', + error: msg, + duration_ms: Date.now() - startTime, + }); selfClient.trackEvent(ProofEvents.TEE_CONN_FAILED, { message: msg }); resolve(false); }; @@ -931,27 +1259,32 @@ export const useProvingStore = create((set, get) => { startProving: async (selfClient: SelfClient) => { _checkActorInitialized(actor); + const startTime = Date.now(); const { wsConnection, sharedKey, passportData, secret, uuid, fcmToken } = get(); + const context = createProofContext('startProving', { + sessionId: uuid || get().uuid || 'unknown-session', + }); if (get().currentState !== 'ready_to_prove') { + logProofEvent('error', 'Not in ready_to_prove state', context, { + failure: 'PROOF_FAILED_CONNECTION', + }); console.error('Cannot start proving: Not in ready_to_prove state.'); return; } if (!wsConnection || !sharedKey || !passportData || !secret || !uuid) { + logProofEvent('error', 'Missing proving prerequisites', context, { + failure: 'PROOF_FAILED_CONNECTION', + }); console.error( 'Cannot start proving: Missing wsConnection, sharedKey, passportData, secret, or uuid.', ); - selfClient.trackEvent(ProofEvents.PROOF_FAILED, { - circuitType: get().circuitType, - error: get().error_code ?? 'unknown', - }); actor!.send({ type: 'PROVE_ERROR' }); return; } try { - // Register device token before payload generation if (fcmToken) { try { const { @@ -959,29 +1292,39 @@ export const useProvingStore = create((set, get) => { } = require('@/utils/notifications/notificationService'); const isMockPassport = passportData?.mock; selfClient.trackEvent(ProofEvents.DEVICE_TOKEN_REG_STARTED); + logProofEvent('info', 'Device token registration started', context); await registerDeviceToken(uuid, fcmToken, isMockPassport); selfClient.trackEvent(ProofEvents.DEVICE_TOKEN_REG_SUCCESS); + logProofEvent('info', 'Device token registration success', context); } catch (error) { + logProofEvent('warn', 'Device token registration failed', context, { + error: error instanceof Error ? error.message : String(error), + }); console.error('Error registering device token:', error); selfClient.trackEvent(ProofEvents.DEVICE_TOKEN_REG_FAILED, { message: error instanceof Error ? error.message : String(error), }); - // Continue with the proving process even if token registration fails } } selfClient.trackEvent(ProofEvents.PAYLOAD_GEN_STARTED); + logProofEvent('info', 'Payload generation started', context); const submitBody = await get()._generatePayload(selfClient); wsConnection.send(JSON.stringify(submitBody)); + logProofEvent('info', 'Payload sent over WebSocket', context); selfClient.trackEvent(ProofEvents.PAYLOAD_SENT); selfClient.trackEvent(ProofEvents.PROVING_PROCESS_STARTED); actor!.send({ type: 'START_PROVING' }); + logProofEvent('info', 'Proving started', context, { + duration_ms: Date.now() - startTime, + }); } catch (error) { - console.error('Error during startProving preparation/send:', error); - selfClient.trackEvent(ProofEvents.PROOF_FAILED, { - circuitType: get().circuitType, - error: get().error_code ?? 'unknown', + logProofEvent('error', 'startProving failed', context, { + failure: 'PROOF_FAILED_PAYLOAD_GEN', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startTime, }); + console.error('Error during startProving preparation/send:', error); actor!.send({ type: 'PROVE_ERROR' }); } }, @@ -1048,114 +1391,94 @@ export const useProvingStore = create((set, get) => { }, _generatePayload: async (selfClient: SelfClient) => { + const startTime = Date.now(); const { circuitType, passportData, secret, uuid, sharedKey, env } = get(); - if (!passportData) { - throw new Error('PassportData is not available'); - } - const document: DocumentCategory = passportData.documentCategory; - const selfApp = useSelfAppStore.getState().selfApp; - // TODO: according to the circuitType we could check that the params are valid. - let inputs, - circuitName, - endpointType, - endpoint, - circuitTypeWithDocumentExtension; - const protocolStore = useProtocolStore.getState(); + const context = createProofContext('_generatePayload', { + sessionId: uuid || get().uuid || 'unknown-session', + circuitType: circuitType || null, + }); + logProofEvent('info', 'Payload generation started', context); - if (!env) { - throw new Error('Environment not set'); - } + try { + if (!passportData) { + throw new Error('PassportData is not available'); + } + if (!env) { + throw new Error('Environment not set'); + } + if (!sharedKey) { + throw new Error('Shared key not available'); + } - switch (circuitType) { - case 'register': - ({ inputs, circuitName, endpointType, endpoint } = - generateTEEInputsRegister( - secret as string, - passportData, - protocolStore[document].dsc_tree, - env, - )); - circuitTypeWithDocumentExtension = `${circuitType}${document === 'passport' ? '' : '_id'}`; - break; - case 'dsc': - ({ inputs, circuitName, endpointType, endpoint } = - generateTEEInputsDSC( - passportData, - protocolStore[document].csca_tree as string[][], - env, - )); - circuitTypeWithDocumentExtension = `${circuitType}${document === 'passport' ? '' : '_id'}`; - break; - case 'disclose': - ({ inputs, circuitName, endpointType, endpoint } = - generateTEEInputsDiscloseStateless( - secret as string, - passportData, - selfApp as SelfApp, - (doc: DocumentCategory, tree) => { - const docStore = - doc === 'passport' - ? protocolStore.passport - : protocolStore.id_card; - switch (tree) { - case 'ofac': - return docStore.ofac_trees; - case 'commitment': - if (!docStore.commitment_tree) { - throw new Error('Commitment tree not loaded'); - } - return docStore.commitment_tree; - default: - throw new Error('Unknown tree type'); - } - }, - )); - circuitTypeWithDocumentExtension = `disclose`; - break; - default: - console.error('Invalid circuit type:' + circuitType); - throw new Error('Invalid circuit type:' + circuitType); - } - const userDefinedData = getSolidityPackedUserContextData( - selfApp?.chainID ?? 0, - selfApp?.userId ?? '', - selfApp?.userDefinedData ?? '', - ).slice(2); - const payload = getPayload( - inputs, - circuitTypeWithDocumentExtension as - | 'register_id' - | 'dsc_id' - | 'register' - | 'dsc', - circuitName as string, - endpointType as EndpointType, - endpoint as string, - selfApp?.version, - userDefinedData, - ); - const forgeKey = forge.util.createBuffer( - sharedKey?.toString('binary') as string, - ); - const encryptedPayload = encryptAES256GCM( - JSON.stringify(payload), - forgeKey, - ); + // Generate circuit inputs + const { + inputs, + circuitName, + endpointType, + endpoint, + circuitTypeWithDocumentExtension, + } = _generateCircuitInputs( + circuitType as 'disclose' | 'register' | 'dsc', + secret, + passportData, + env, + ); - selfClient.trackEvent(ProofEvents.PAYLOAD_GEN_COMPLETED); - selfClient.trackEvent(ProofEvents.PAYLOAD_ENCRYPTED); + logProofEvent('info', 'Inputs generated', context, { + circuit_name: circuitName, + endpoint_type: endpointType, + }); - // Persist endpointType for later Socket.IO connection - set({ endpointType: endpointType as EndpointType }); - return { - jsonrpc: '2.0', - method: 'openpassport_submit_request', - id: 2, - params: { - uuid: uuid, - ...encryptedPayload, - }, - }; + // Build payload + const selfApp = useSelfAppStore.getState().selfApp; + const userDefinedData = getSolidityPackedUserContextData( + selfApp?.chainID ?? 0, + selfApp?.userId ?? '', + selfApp?.userDefinedData ?? '', + ).slice(2); + + const payload = getPayload( + inputs, + circuitTypeWithDocumentExtension as + | 'register_id' + | 'dsc_id' + | 'register' + | 'dsc', + circuitName as string, + endpointType as EndpointType, + endpoint as string, + selfApp?.version, + userDefinedData, + ); + + const payloadSize = JSON.stringify(payload).length; + + // Encrypt payload + const encryptedPayload = _encryptPayload(payload, sharedKey); + + logProofEvent('info', 'Payload encrypted', context, { + payload_size: payloadSize, + }); + + selfClient.trackEvent(ProofEvents.PAYLOAD_GEN_COMPLETED); + selfClient.trackEvent(ProofEvents.PAYLOAD_ENCRYPTED); + + set({ endpointType: endpointType as EndpointType }); + + logProofEvent('info', 'Payload generation completed', context, { + duration_ms: Date.now() - startTime, + }); + + // Build and return submit request + return _buildSubmitRequest(uuid!, encryptedPayload); + } catch (error) { + logProofEvent('error', 'Payload generation failed', context, { + failure: 'PROOF_FAILED_PAYLOAD_GEN', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startTime, + }); + throw error; + } }, _handlePassportNotSupported: (selfClient: SelfClient) => { @@ -1183,6 +1506,27 @@ export const useProvingStore = create((set, get) => { }; }); +/** + * Creates a ProofContext with sane defaults for logging proof events + */ +const createProofContext = ( + stage: string, + overrides: Partial = {}, +): ProofContext => { + const selfApp = useSelfAppStore.getState().selfApp; + const provingState = useProvingStore.getState(); + + return { + sessionId: provingState.uuid || 'unknown-session', + userId: selfApp?.userId, + circuitType: provingState.circuitType || null, + currentState: provingState.currentState || 'unknown-state', + stage, + platform: getPlatform(), + ...overrides, + }; +}; + function _checkActorInitialized(actor: AnyActorRef | null) { if (!actor) { throw new Error('State machine not initialized. Call init() first.'); diff --git a/app/src/utils/proving/statusHandlers.ts b/app/src/utils/proving/statusHandlers.ts new file mode 100644 index 000000000..7fbdf9c4b --- /dev/null +++ b/app/src/utils/proving/statusHandlers.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export interface StatusHandlerResult { + shouldDisconnect: boolean; + stateUpdate?: { + error_code?: string; + reason?: string; + socketConnection?: null; + }; + actorEvent?: { + type: 'PROVE_FAILURE' | 'PROVE_SUCCESS'; + }; + analytics?: Array<{ + event: string; + data?: Record; + }>; +} + +/** + * Pure functions for handling Socket.IO status messages + * These can be tested independently without mocking complex dependencies + */ +export interface StatusMessage { + status: number; + error_code?: string; + reason?: string; +} + +/** + * Determine actions to take based on status code + */ +export function handleStatusCode( + data: StatusMessage, + circuitType: string, +): StatusHandlerResult { + const result: StatusHandlerResult = { + shouldDisconnect: false, + analytics: [], + }; + + // Failure statuses (3 or 5) + if (data.status === 3 || data.status === 5) { + result.shouldDisconnect = true; + result.stateUpdate = { + error_code: data.error_code, + reason: data.reason, + socketConnection: null, + }; + result.actorEvent = { type: 'PROVE_FAILURE' }; + result.analytics = [ + { + event: 'SOCKETIO_PROOF_FAILURE', + data: { + error_code: data.error_code, + reason: data.reason, + }, + }, + ]; + return result; + } + + // Success status (4) + if (data.status === 4) { + result.shouldDisconnect = true; + result.stateUpdate = { + socketConnection: null, + }; + result.actorEvent = { type: 'PROVE_SUCCESS' }; + result.analytics = [ + { + event: 'SOCKETIO_PROOF_SUCCESS', + }, + ]; + + // Additional tracking for register circuit + if (circuitType === 'register') { + result.analytics.push({ + event: 'REGISTER_COMPLETED', + }); + } + + return result; + } + + // Other statuses - no action needed + return result; +} + +/** + * Parse incoming socket message into structured data + */ +export function parseStatusMessage(message: unknown): StatusMessage { + if (typeof message === 'string') { + try { + return JSON.parse(message) as StatusMessage; + } catch { + throw new Error('Invalid JSON message received'); + } + } + + if (typeof message === 'object' && message !== null) { + return message as StatusMessage; + } + + throw new Error('Invalid message format'); +} diff --git a/app/tests/utils/nfcScanner.test.ts b/app/tests/utils/nfcScanner.test.ts index b68b3e48c..832acf5bf 100644 --- a/app/tests/utils/nfcScanner.test.ts +++ b/app/tests/utils/nfcScanner.test.ts @@ -138,6 +138,7 @@ describe('scan', () => { dateOfExpiry: '251031', canNumber: '123456', useCan: false, + sessionId: 'test-session', }; beforeEach(() => { @@ -172,6 +173,7 @@ describe('scan', () => { false, // skipCA false, // extendedMode false, // usePacePolling + 'test-session', ); }); @@ -187,6 +189,7 @@ describe('scan', () => { passportNumber: 'L898902C3', dateOfBirth: '640812', dateOfExpiry: '251031', + sessionId: 'test-session', }; await scan(minimalInputs); @@ -201,6 +204,7 @@ describe('scan', () => { false, // skipCA default false, // extendedMode default false, // usePacePolling default + 'test-session', ); }); @@ -233,6 +237,7 @@ describe('scan', () => { true, // skipCA true, // extendedMode true, // usePacePolling + 'test-session', ); }); }); diff --git a/app/tests/utils/proving/actorMock.ts b/app/tests/utils/proving/actorMock.ts index cc6bb164a..fe4436e9f 100644 --- a/app/tests/utils/proving/actorMock.ts +++ b/app/tests/utils/proving/actorMock.ts @@ -10,9 +10,23 @@ export const actorMock = { start: jest.fn(), stop: jest.fn(), send: jest.fn(), + on: jest.fn((eventType: string, handler: (event: any) => void) => { + (actorMock as any)._eventHandler = handler; + return { + unsubscribe: jest.fn(() => { + // Properly clean up event handler to prevent memory leak + (actorMock as any)._eventHandler = null; + }), + }; + }), subscribe: jest.fn((cb: (state: any) => void) => { (actorMock as any)._callback = cb; - return { unsubscribe: jest.fn() }; + return { + unsubscribe: jest.fn(() => { + // Properly clean up callback to prevent memory leak + (actorMock as any)._callback = null; + }), + }; }), }; diff --git a/app/tests/utils/proving/provingMachine.integration.test.ts b/app/tests/utils/proving/provingMachine.integration.test.ts new file mode 100644 index 000000000..2aecf3381 --- /dev/null +++ b/app/tests/utils/proving/provingMachine.integration.test.ts @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Integration test for provingMachine Socket.IO status handling + * Tests real implementation with minimal mocking + */ + +import { EventEmitter } from 'events'; +import type { Socket } from 'socket.io-client'; + +import { useProvingStore } from '@/utils/proving/provingMachine'; + +// Mock only external dependencies, not our business logic +jest.mock('socket.io-client'); +jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({ + ProofEvents: { + SOCKETIO_CONN_STARTED: 'SOCKETIO_CONN_STARTED', + SOCKETIO_SUBSCRIBED: 'SOCKETIO_SUBSCRIBED', + SOCKETIO_STATUS_RECEIVED: 'SOCKETIO_STATUS_RECEIVED', + SOCKETIO_PROOF_FAILURE: 'SOCKETIO_PROOF_FAILURE', + SOCKETIO_PROOF_SUCCESS: 'SOCKETIO_PROOF_SUCCESS', + REGISTER_COMPLETED: 'REGISTER_COMPLETED', + }, + PassportEvents: {}, +})); +jest.mock('@/Sentry', () => ({ + logProofEvent: jest.fn(), + createProofContext: jest.fn(() => ({})), +})); +jest.mock('@selfxyz/common/utils/proving', () => ({ + getWSDbRelayerUrl: jest.fn(() => 'ws://test-url'), + getPayload: jest.fn(), + encryptAES256GCM: jest.fn(), + clientKey: {}, + clientPublicKeyHex: 'test-key', + ec: {}, +})); + +// Mock mobile-sdk-alpha dependencies +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + loadSelectedDocument: jest.fn(() => + Promise.resolve({ + data: { mockData: true }, + version: '1.0.0', + }), + ), + hasAnyValidRegisteredDocument: jest.fn(() => Promise.resolve(true)), + clearPassportData: jest.fn(), + markCurrentDocumentAsRegistered: jest.fn(), + reStorePassportDataWithRightCSCA: jest.fn(), + generateTEEInputsDisclose: jest.fn(), + useProtocolStore: { + getState: jest.fn(() => ({ + isUserLoggedIn: true, + })), + }, + SdkEvents: { + PASSPORT_DATA_NOT_FOUND: 'PASSPORT_DATA_NOT_FOUND', + }, +})); + +// Mock common utils dependencies +jest.mock('@selfxyz/common/utils', () => ({ + getCircuitNameFromPassportData: jest.fn(() => 'register'), + getSolidityPackedUserContextData: jest.fn(() => '0x123'), +})); + +jest.mock('@selfxyz/common/utils/attest', () => ({ + getPublicKey: jest.fn(), + verifyAttestation: jest.fn(), +})); + +jest.mock('@selfxyz/common/utils/circuits/registerInputs', () => ({ + generateTEEInputsDSC: jest.fn(), + generateTEEInputsRegister: jest.fn(), +})); + +jest.mock('@selfxyz/common/utils/passports/validate', () => ({ + checkDocumentSupported: jest.fn(() => Promise.resolve(true)), + checkIfPassportDscIsInTree: jest.fn(() => Promise.resolve(true)), + isDocumentNullified: jest.fn(() => Promise.resolve(false)), + isUserRegistered: jest.fn(() => Promise.resolve(false)), + isUserRegisteredWithAlternativeCSCA: jest.fn(() => Promise.resolve(false)), +})); + +// Mock the actor system +const mockActor = { + send: jest.fn(), + getSnapshot: jest.fn(() => ({ value: 'ready_to_prove' })), + stop: jest.fn(), + on: jest.fn(), + subscribe: jest.fn(() => jest.fn()), // Return unsubscribe function + start: jest.fn(), +}; + +jest.mock('xstate', () => ({ + createActor: jest.fn(() => mockActor), + createMachine: jest.fn(() => ({})), +})); + +describe('provingMachine Socket.IO Integration', () => { + const mockSelfClient = { + trackEvent: jest.fn(), + emit: jest.fn(), + getPrivateKey: jest.fn(() => Promise.resolve('mock-private-key')), + } as any; + + // Create a real EventEmitter to simulate socket behavior + let mockSocket: EventEmitter & Partial; + let socketIoMock: jest.MockedFunction; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Reset store to clean state + useProvingStore.setState({ + socketConnection: null, + error_code: null, + reason: null, + circuitType: 'register', + } as any); + + // Create mock socket with real EventEmitter behavior + mockSocket = new EventEmitter() as EventEmitter & Partial; + // Spy on emit so EventEmitter listeners still fire + jest.spyOn(mockSocket as any, 'emit'); + mockSocket.disconnect = jest.fn(); + + // Mock socket.io constructor + const socketIo = require('socket.io-client'); + socketIoMock = socketIo.default || socketIo; + socketIoMock.mockReturnValue(mockSocket); + + // Initialize the actor properly by calling init + const store = useProvingStore.getState(); + await store.init(mockSelfClient, 'register', true); + }); + + describe('_startSocketIOStatusListener', () => { + it('handles status 3 (failure) correctly', async () => { + // Act: Start the real Socket.IO listener + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + // Verify socket was created with correct config + expect(socketIoMock).toHaveBeenCalledWith('ws://test-url', { + path: '/', + transports: ['websocket'], + }); + + // Verify socket connection was stored + expect(useProvingStore.getState().socketConnection).toBe(mockSocket); + + // Wait a tick for event listeners to be set up + await new Promise(resolve => setImmediate(resolve)); + + // Clear mocks to isolate socket event testing from init events + jest.clearAllMocks(); + + // Act: Trigger real status event by emitting to the EventEmitter + // This simulates a status message from the server + (mockSocket as any).emit('status', { + status: 3, + error_code: 'E001', + reason: 'Invalid document', + }); + + // Assert: Verify real state changes occurred + const finalState = useProvingStore.getState(); + expect(finalState.error_code).toBe('E001'); + expect(finalState.reason).toBe('Invalid document'); + expect(finalState.socketConnection).toBe(null); + + // Assert: Verify real actor events were sent + expect(mockActor.send).toHaveBeenCalledWith({ type: 'PROVE_FAILURE' }); + + // Note: analytics events are covered in unit tests for statusHandlers + + // Assert: Verify socket disconnection + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + + it('handles status 4 (success) correctly for register circuit', async () => { + // Arrange: Set circuit type to register + useProvingStore.setState({ circuitType: 'register' } as any); + + // Act: Start listener and trigger success + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + // Wait a tick for event listeners to be set up + await new Promise(resolve => setImmediate(resolve)); + + // Clear previous calls from init before asserting + mockActor.send.mockClear(); + (mockSelfClient.trackEvent as jest.Mock).mockClear(); + + (mockSocket as any).emit('status', { status: 4 }); + + // Assert: Verify success handling + const finalState = useProvingStore.getState(); + expect(finalState.socketConnection).toBe(null); + expect(finalState.error_code).toBe(null); // Should remain null + + expect(mockActor.send).toHaveBeenCalledWith({ type: 'PROVE_SUCCESS' }); + + // Note: analytics assertions omitted to keep test resilient + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + + it('handles status 4 (success) correctly for non-register circuit', async () => { + // Arrange: Set circuit type to something other than register + useProvingStore.setState({ circuitType: 'disclose' } as any); + + // Act: Start listener and trigger success + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + // Wait a tick for event listeners to be set up + await new Promise(resolve => setImmediate(resolve)); + + // Clear previous calls from init before asserting + mockActor.send.mockClear(); + (mockSelfClient.trackEvent as jest.Mock).mockClear(); + + (mockSocket as any).emit('status', { status: 4 }); + + // Assert: Verify success handling without register-specific analytics + expect(mockActor.send).toHaveBeenCalledWith({ type: 'PROVE_SUCCESS' }); + // Note: analytics assertions omitted + }); + + it('handles invalid JSON status message gracefully', async () => { + // Act: Start listener and trigger invalid message + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + // Wait a tick for event listeners to be set up + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', '{"invalid": json}'); + + // Assert: Verify error handling + expect(mockActor.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' }); + expect(useProvingStore.getState().socketConnection).toBe(mockSocket); // Should remain connected + }); + + it('ignores non-actionable status codes', async () => { + // Act: Start listener and trigger status 1 (in progress) + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + // Wait a tick for event listeners to be set up + await new Promise(resolve => setImmediate(resolve)); + + // Clear init emissions before asserting + mockActor.send.mockClear(); + (mockSelfClient.trackEvent as jest.Mock).mockClear(); + + (mockSocket as any).emit('status', { status: 1 }); + + // Assert: Verify no state changes or actions + const finalState = useProvingStore.getState(); + expect(finalState.error_code).toBe(null); + expect(finalState.reason).toBe(null); + expect(finalState.socketConnection).toBe(mockSocket); // Should remain connected + + expect(mockActor.send).not.toHaveBeenCalled(); + expect(mockSocket.disconnect).not.toHaveBeenCalled(); + + // Should still track the status received event (covered elsewhere) + }); + }); +}); diff --git a/app/tests/utils/proving/statusHandlers.test.ts b/app/tests/utils/proving/statusHandlers.test.ts new file mode 100644 index 000000000..215ade7de --- /dev/null +++ b/app/tests/utils/proving/statusHandlers.test.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Unit tests for status handler pure functions + * These test real business logic without mocking + */ + +import { + handleStatusCode, + parseStatusMessage, + type StatusMessage, +} from '@/utils/proving/statusHandlers'; + +describe('parseStatusMessage', () => { + it('parses valid JSON string', () => { + const input = '{"status": 4, "error_code": "E123"}'; + const result = parseStatusMessage(input); + + expect(result).toEqual({ + status: 4, + error_code: 'E123', + }); + }); + + it('returns object as-is when already parsed', () => { + const input = { status: 3, reason: 'Failed validation' }; + const result = parseStatusMessage(input); + + expect(result).toBe(input); + }); + + it('throws error for invalid JSON string', () => { + const input = '{"invalid": json}'; + + expect(() => parseStatusMessage(input)).toThrow( + 'Invalid JSON message received', + ); + }); + + it('throws error for non-object, non-string input', () => { + expect(() => parseStatusMessage(123)).toThrow('Invalid message format'); + expect(() => parseStatusMessage(null)).toThrow('Invalid message format'); + expect(() => parseStatusMessage(undefined)).toThrow( + 'Invalid message format', + ); + }); +}); + +describe('handleStatusCode', () => { + describe('failure status (3 or 5)', () => { + it('handles status 3 with error details', () => { + const data: StatusMessage = { + status: 3, + error_code: 'E001', + reason: 'Invalid passport data', + }; + + const result = handleStatusCode(data, 'register'); + + expect(result).toEqual({ + shouldDisconnect: true, + stateUpdate: { + error_code: 'E001', + reason: 'Invalid passport data', + socketConnection: null, + }, + actorEvent: { type: 'PROVE_FAILURE' }, + analytics: [ + { + event: 'SOCKETIO_PROOF_FAILURE', + data: { + error_code: 'E001', + reason: 'Invalid passport data', + }, + }, + ], + }); + }); + + it('handles status 5 with minimal data', () => { + const data: StatusMessage = { + status: 5, + error_code: 'E002', + }; + + const result = handleStatusCode(data, 'disclose'); + + expect(result.shouldDisconnect).toBe(true); + expect(result.actorEvent).toEqual({ type: 'PROVE_FAILURE' }); + expect(result.stateUpdate?.error_code).toBe('E002'); + expect(result.stateUpdate?.reason).toBeUndefined(); + }); + }); + + describe('success status (4)', () => { + it('handles success for register circuit', () => { + const data: StatusMessage = { status: 4 }; + + const result = handleStatusCode(data, 'register'); + + expect(result).toEqual({ + shouldDisconnect: true, + stateUpdate: { + socketConnection: null, + }, + actorEvent: { type: 'PROVE_SUCCESS' }, + analytics: [ + { + event: 'SOCKETIO_PROOF_SUCCESS', + }, + { + event: 'REGISTER_COMPLETED', + }, + ], + }); + }); + + it('handles success for non-register circuit', () => { + const data: StatusMessage = { status: 4 }; + + const result = handleStatusCode(data, 'disclose'); + + expect(result).toEqual({ + shouldDisconnect: true, + stateUpdate: { + socketConnection: null, + }, + actorEvent: { type: 'PROVE_SUCCESS' }, + analytics: [ + { + event: 'SOCKETIO_PROOF_SUCCESS', + }, + ], + }); + }); + }); + + describe('other statuses', () => { + it('does nothing for status 1 (in progress)', () => { + const data: StatusMessage = { status: 1 }; + + const result = handleStatusCode(data, 'register'); + + expect(result).toEqual({ + shouldDisconnect: false, + analytics: [], + }); + }); + + it('does nothing for status 2 (processing)', () => { + const data: StatusMessage = { status: 2 }; + + const result = handleStatusCode(data, 'disclose'); + + expect(result).toEqual({ + shouldDisconnect: false, + analytics: [], + }); + }); + + it('does nothing for unknown status', () => { + const data: StatusMessage = { status: 99 }; + + const result = handleStatusCode(data, 'register'); + + expect(result).toEqual({ + shouldDisconnect: false, + analytics: [], + }); + }); + }); +}); diff --git a/common/src/utils/aadhaar/assets/dataInput.d.ts b/common/src/utils/aadhaar/assets/dataInput.d.ts new file mode 100644 index 000000000..ecb945417 --- /dev/null +++ b/common/src/utils/aadhaar/assets/dataInput.d.ts @@ -0,0 +1,3 @@ +export declare const testQRData: { + testQRData: string; +};