@myazahq/kyc-sdk-react-native 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +111 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +67 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +152 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +331 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  198. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  199. package/src/screens/CompanyInfoFields.tsx +83 -0
  200. package/src/screens/ConsentStep.tsx +68 -68
  201. package/src/screens/ContactChannelChoice.tsx +133 -0
  202. package/src/screens/ContactCodeStep.tsx +130 -0
  203. package/src/screens/ContactDestinationField.tsx +70 -0
  204. package/src/screens/ContactFooterNote.tsx +34 -0
  205. package/src/screens/ContactVerificationStep.tsx +199 -0
  206. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  207. package/src/screens/CountrySelectStep.tsx +210 -0
  208. package/src/screens/DocumentCaptureStep.tsx +156 -132
  209. package/src/screens/IdInputStep.tsx +13 -5
  210. package/src/screens/IdTypeStep.tsx +74 -33
  211. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  212. package/src/screens/LivenessStep.tsx +155 -247
  213. package/src/screens/MrzScanView.tsx +173 -0
  214. package/src/screens/NfcStep.tsx +290 -0
  215. package/src/screens/ProofOfAddressStep.tsx +238 -0
  216. package/src/screens/QuestionnaireField.tsx +219 -0
  217. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  218. package/src/screens/QuestionnaireStep.tsx +76 -0
  219. package/src/screens/SubmittedStep.tsx +5 -0
  220. package/src/screens/consent/model.ts +109 -0
  221. package/src/screens/document/CameraPhase.tsx +150 -0
  222. package/src/screens/document/RequiredPill.tsx +60 -0
  223. package/src/screens/liveness/LightingBanner.tsx +50 -0
  224. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  225. package/src/screens/liveness/ProgressDots.tsx +67 -0
  226. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  227. package/src/screens/liveness/constants.ts +31 -0
  228. package/src/screens/liveness/index.ts +8 -0
  229. package/src/screens/liveness/resolveGuidance.ts +27 -0
  230. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  231. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  232. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  233. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  234. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  235. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  236. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  237. package/src/screens/nfc/availability.ts +73 -0
  238. package/src/screens/nfc/readErrorMessage.ts +93 -0
  239. package/src/screens/nfc/useChipPortrait.ts +58 -0
  240. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  241. package/src/screens/useFlashHole.ts +105 -0
  242. package/src/screens/useFlashSequence.ts +96 -0
  243. package/src/screens/usePoaAttach.ts +121 -0
  244. package/src/services/api-types.ts +164 -0
  245. package/src/services/api-verify-types.ts +99 -0
  246. package/src/services/api.ts +72 -102
  247. package/src/services/cardCrop.ts +25 -10
  248. package/src/services/contactErrors.ts +43 -0
  249. package/src/services/deviceMetadata.ts +1 -1
  250. package/src/services/documentPicker.ts +20 -0
  251. package/src/services/fingerprint-sources.ts +129 -0
  252. package/src/services/fingerprint.ts +101 -0
  253. package/src/services/haptics.ts +38 -0
  254. package/src/services/mediaCompress.ts +7 -2
  255. package/src/services/resolveUrl.ts +2 -2
  256. package/src/services/workflowGate.ts +138 -0
  257. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  258. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  259. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  260. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  261. package/src/store/derive.ts +225 -0
  262. package/src/store/kycStore.ts +250 -149
  263. package/src/store/state.ts +253 -0
  264. package/src/store/submit.ts +101 -0
  265. package/src/store/submitApplicant.ts +81 -0
  266. package/src/types/appearance.ts +113 -0
  267. package/src/types/business.ts +131 -0
  268. package/src/types/config.ts +150 -124
  269. package/src/types/id-types.ts +57 -0
  270. package/src/types/verification.ts +5 -0
  271. package/src/types/workflow.ts +141 -0
  272. package/src/utils/amountFormat.ts +73 -0
  273. package/src/utils/tokens.ts +4 -2
  274. package/src/__tests__/cardCrop.test.ts +0 -39
  275. package/src/__tests__/deviceMetadata.test.ts +0 -34
  276. package/src/__tests__/errors.test.ts +0 -34
  277. package/src/__tests__/flow.test.ts +0 -61
  278. package/src/__tests__/gestureDetector.test.ts +0 -37
  279. package/src/__tests__/liveness.test.ts +0 -112
  280. package/src/__tests__/resolveUrl.test.ts +0 -64
  281. package/src/__tests__/validators.test.ts +0 -38
@@ -0,0 +1,224 @@
1
+ import { Platform } from 'react-native';
2
+
3
+ import { nativeModule } from './native';
4
+ import { readChip, type EmrtdReadResult, EmrtdSessionError } from './session';
5
+ import {
6
+ MAX_CHIP_ATTEMPTS,
7
+ chipRetryDelayMs,
8
+ isRetryableChipError,
9
+ isRetryableStartError,
10
+ } from './retry';
11
+ import { nfcSheetMessage, type NfcReadStage } from './stages';
12
+ import type { MrzKeyFields } from './crypto';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // The read loop: open the session, run BAC with the MRZ key, read the files,
16
+ // close the session — and when the chip drops, do the whole thing again.
17
+ //
18
+ // A dropped session is retried by opening a NEW one, because that is the only
19
+ // recovery there is: iOS tears the session down and secure messaging dies with
20
+ // it — the BAC-derived keys and the send-sequence counter cannot outlive the
21
+ // session, so there is nothing to resume into.
22
+ //
23
+ // The session is ALWAYS closed, including on failure — a session left open
24
+ // holds iOS's NFC sheet on screen over a flow that has already moved on, and
25
+ // on Android keeps reader mode attached to a dead activity.
26
+ //
27
+ // Nothing here concludes that a chip is genuine. The files go to the server,
28
+ // which hashes DG1 against the signed security object and checks the document
29
+ // signer's signature. A client-side "authentic" flag would be a claim an
30
+ // attacker controls.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface ChipReadOptions {
34
+ /** Shown in iOS's system NFC sheet. Android has none and ignores it. */
35
+ alertMessage?: string;
36
+ /** Shown when the read finishes, on iOS. */
37
+ successMessage?: string;
38
+ /** Read progress, so the UI can narrate an otherwise invisible operation. */
39
+ onStage?: (stage: NfcReadStage) => void;
40
+ }
41
+
42
+ /**
43
+ * Read generation. Each read claims a fresh value; `cancelChipRead` (and any
44
+ * newer read) bumps it, and a loop that wakes from its between-attempt sleep
45
+ * to find the world has moved on stops INSTEAD of reopening a session over
46
+ * whatever screen the user is on now. Closing the current session alone cannot
47
+ * do that — the loop's whole job is to open another one.
48
+ */
49
+ let generation = 0;
50
+
51
+ /**
52
+ * Abandon a read that is still in progress.
53
+ *
54
+ * Necessary because opening the session BLOCKS until a document is presented —
55
+ * on Android for up to a minute. A user who leaves the step during that wait
56
+ * cannot be rescued by `readPassportChip`'s own cleanup: that runs in the catch
57
+ * around `readChip`, which is never reached while the session is still waiting
58
+ * for a tap. Without this, reader mode stays attached to an activity the flow
59
+ * has already left, and on iOS the system sheet sits over the next screen.
60
+ *
61
+ * Safe to call when nothing is running — closing an already-closed session is a
62
+ * no-op, so callers do not have to track whether a read is live.
63
+ */
64
+ export async function cancelChipRead(): Promise<void> {
65
+ generation += 1;
66
+ const native = nativeModule();
67
+ if (!native) return;
68
+ try {
69
+ await native.stopSession('Cancelled');
70
+ } catch {
71
+ // Nothing to cancel, or the session had already gone. Either way there is
72
+ // nothing left to clean up and nobody to report it to.
73
+ }
74
+ }
75
+
76
+ export async function readPassportChip(
77
+ mrz: MrzKeyFields,
78
+ options: ChipReadOptions = {},
79
+ ): Promise<EmrtdReadResult> {
80
+ const native = nativeModule();
81
+ if (!native) {
82
+ throw new EmrtdSessionError('This build cannot read NFC chips.', 'transport');
83
+ }
84
+
85
+ const run = ++generation;
86
+ const abandoned = (): boolean => generation !== run;
87
+ const settle = (): Promise<void> =>
88
+ new Promise((resolve) => setTimeout(resolve, chipRetryDelayMs(Platform.OS)));
89
+
90
+ // The stage the read died at is the single most diagnostic fact a failure
91
+ // carries — 'waiting' is a session/detection problem, 'authenticating' is
92
+ // SELECT/BAC (first contact), the read stages are a mid-transfer drop — so
93
+ // it is tracked here and logged with the RAW platform error per attempt.
94
+ // The same wrapper narrates the iOS system sheet, which COVERS the app
95
+ // during the read: without it the sheet sits on its opening line while
96
+ // three files transfer, and the silence reads as a hang (Flutter updates
97
+ // its sheet per stage; this is that, for RN).
98
+ let lastStage: NfcReadStage = 'waiting';
99
+ const onStage = (stage: NfcReadStage): void => {
100
+ lastStage = stage;
101
+ if (stage !== 'waiting' && stage !== 'done') {
102
+ try {
103
+ // Older installed builds predate this native method — narration is a
104
+ // courtesy, never a reason to fail a read.
105
+ void native.updateSessionMessage(nfcSheetMessage(stage)).catch(() => undefined);
106
+ } catch {
107
+ // Method absent on this build.
108
+ }
109
+ }
110
+ options.onStage?.(stage);
111
+ };
112
+
113
+ // A read that got DG1 but not the SOD is usable yet WORTH RETRYING: without
114
+ // the security object the server cannot verify the chip against the issuer,
115
+ // and the portrait is never fetched. Bank the partial and spend remaining
116
+ // attempts going for the complete read — settle for the partial only when
117
+ // the budget is exhausted.
118
+ let partial: EmrtdReadResult | null = null;
119
+
120
+ let lastError: unknown;
121
+ for (let attempt = 1; attempt <= MAX_CHIP_ATTEMPTS; attempt += 1) {
122
+ try {
123
+ // A completeness retry re-presents the sheet moments after it closed on
124
+ // what looked like a finished read — without an explanation, the natural
125
+ // move is to take the document away, which forfeits the retry.
126
+ await native.startSession(
127
+ partial
128
+ ? 'Almost done — keep the document against your phone to finish'
129
+ : options.alertMessage ?? 'Hold your phone near the passport',
130
+ );
131
+ } catch (err) {
132
+ // Opening can be refused TRANSIENTLY: iOS reports an immediate
133
+ // `Code=202` for a session begun while the previous sheet is still
134
+ // dismissing — the very failure this loop exists to recover from, and
135
+ // exactly what a reopen straight after a drop runs into. Those get the
136
+ // settle-and-retry treatment like any other drop; a refusal another
137
+ // attempt cannot change (cancelled sheet, no document presented, no
138
+ // radio) is reported as-is.
139
+ lastError = err;
140
+ devLog(attempt, 'session-open', err);
141
+ // startSession can fail AFTER arming the radio (Android enables reader
142
+ // mode before discovery/connect can still throw), so the session must be
143
+ // torn down on this path too — or reader mode outlives the attempt,
144
+ // attached to the activity with a dead callback.
145
+ await native.stopSession('').catch(() => undefined);
146
+ if (attempt >= MAX_CHIP_ATTEMPTS || !isRetryableStartError(err)) throw err;
147
+ await settle();
148
+ if (abandoned()) throw cancelledError();
149
+ continue;
150
+ }
151
+
152
+ try {
153
+ onStage('waiting');
154
+ const result = await readChip(native, mrz, onStage);
155
+ if (!result.sod && attempt < MAX_CHIP_ATTEMPTS && !abandoned()) {
156
+ partial = result;
157
+ devLog(attempt, 'security-file', new Error('EF.SOD missing — retrying for a complete read'));
158
+ await native.stopSession('').catch(() => undefined);
159
+ onStage('waiting');
160
+ await settle();
161
+ if (abandoned()) throw cancelledError();
162
+ continue;
163
+ }
164
+ await native.stopSession(options.successMessage ?? 'Passport read');
165
+ return result;
166
+ } catch (err) {
167
+ lastError = err;
168
+ devLog(attempt, lastStage, err);
169
+ const willRetry = attempt < MAX_CHIP_ATTEMPTS && isRetryableChipError(err) && !abandoned();
170
+ // Between attempts the sheet is dismissed QUIETLY — painting it with an
171
+ // error the next attempt may erase reads as a crash mid-recovery. The
172
+ // message is reserved for the terminal failure, where it is the user's
173
+ // only feedback on iOS (the system sheet covers the app) — unless a
174
+ // banked partial makes this a success after all.
175
+ await native
176
+ .stopSession(
177
+ willRetry || partial
178
+ ? ''
179
+ : err instanceof EmrtdSessionError
180
+ ? err.message
181
+ : 'Could not read the chip',
182
+ )
183
+ .catch(() => undefined);
184
+
185
+ if (!willRetry) {
186
+ // A banked DG1-only read beats surfacing an error: the user already
187
+ // saw their data come off the chip once this run.
188
+ if (partial) return partial;
189
+ throw err;
190
+ }
191
+
192
+ // Back to the start: the next attempt re-runs BAC from scratch, which is
193
+ // what a new session requires. The pause lets iOS finish dismissing the
194
+ // sheet and the user reseat the document.
195
+ onStage('waiting');
196
+ await settle();
197
+ if (abandoned()) throw cancelledError();
198
+ }
199
+ }
200
+
201
+ // The attempt budget ran out — a banked partial is still a read.
202
+ if (partial) return partial;
203
+ // Unreachable — the loop either returns or throws — but it keeps the function
204
+ // honest about always producing a result.
205
+ throw lastError ?? new EmrtdSessionError('Could not read the chip.', 'transport');
206
+ }
207
+
208
+ /** The read was abandoned mid-loop (step left, or a newer read superseded it). */
209
+ function cancelledError(): EmrtdSessionError {
210
+ return new EmrtdSessionError('The chip read was cancelled.', 'transport');
211
+ }
212
+
213
+ /**
214
+ * Per-attempt failure diagnostics, dev builds only.
215
+ *
216
+ * The friendly on-screen message deliberately hides the platform error, which
217
+ * makes field debugging impossible without this line: the STAGE names where in
218
+ * the protocol the read died, and the raw string names why.
219
+ */
220
+ function devLog(attempt: number, stage: string, err: unknown): void {
221
+ if (typeof __DEV__ === 'undefined' || !__DEV__) return;
222
+ const raw = err instanceof Error ? err.message : String(err ?? '');
223
+ console.log(`[myaza] nfc attempt ${attempt} failed at '${stage}': ${raw}`);
224
+ }
@@ -0,0 +1,98 @@
1
+ import { EmrtdSessionError } from './session';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Which chip-read failures are worth another session.
5
+ //
6
+ // A dropped session is NOT recoverable in place. iOS tears the whole thing down
7
+ // (`NFCError 202`), so there is no session left to retry an APDU into — and
8
+ // secure messaging cannot survive it either, because the session keys and the
9
+ // send-sequence counter are established by BAC and die with it. The only
10
+ // recovery is a NEW session from the top, which is why this is a decision about
11
+ // restarting rather than about resending.
12
+ //
13
+ // Restarting is the right default because the usual cause is physical: the chip
14
+ // sits in the cover or centre spread of a booklet, and a few millimetres of
15
+ // movement breaks the link. The user has not done anything wrong and the
16
+ // document is fine — asking them to press a button to try what we could simply
17
+ // try again is friction for its own sake.
18
+ //
19
+ // What must NOT be retried is anything a second attempt cannot change:
20
+ // • the user cancelling — they meant it, and reopening the sheet fights them;
21
+ // • BAC failing — the MRZ is check-digit validated before it is used as a
22
+ // key, so a mismatch means this chip belongs to a different document. A
23
+ // retry produces the same failure and wastes the user's time twice.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Total session attempts, including the first. */
27
+ export const MAX_CHIP_ATTEMPTS = 3;
28
+
29
+ /**
30
+ * Pause before reopening the session, per platform.
31
+ *
32
+ * iOS: the system sheet takes well over a second to animate away after
33
+ * `invalidate()`, and a new `NFCTagReaderSession` begun while it is still
34
+ * dismissing is refused with an immediate `Code=202 "Session invalidated
35
+ * unexpectedly"` — the very error the retry exists to recover from. The
36
+ * previous 700ms sat inside that window, so the recovery attempt died before
37
+ * its sheet ever appeared and the user was shown the 202 message for a blip
38
+ * that was entirely recoverable. The pause also gives them time to reseat the
39
+ * document — the thing most likely to make the next attempt succeed.
40
+ *
41
+ * Android has no sheet to dismiss; it only needs the reseat beat.
42
+ */
43
+ export function chipRetryDelayMs(os: string): number {
44
+ return os === 'ios' ? 2200 : 700;
45
+ }
46
+
47
+ /**
48
+ * iOS session-level `NFCReaderError` codes that justify another session.
49
+ * 200 (user cancelled) is deliberately absent.
50
+ */
51
+ const RETRYABLE_IOS_SESSION = /Code=(201|202|203)\b/;
52
+
53
+ /**
54
+ * iOS transceive-level codes — how a physical drop surfaces while a command is
55
+ * IN FLIGHT: 100 tag connection lost, 101 retry exceeded, 102 tag response
56
+ * error, 103 session invalidated, 104 tag not connected. During SELECT and BAC
57
+ * — the first seconds of contact, where most drops happen — these arrive RAW,
58
+ * because nothing has wrapped them in an EmrtdSessionError yet. 105 (packet too
59
+ * long) is deliberately absent: that is a protocol bug, and a new session
60
+ * reproduces it exactly.
61
+ */
62
+ const RETRYABLE_IOS_TRANSCEIVE = /Code=(10[0-4])\b/;
63
+
64
+ /** Android's IsoDep raises `TagLostException("Tag was lost.")` on a drop. */
65
+ const RETRYABLE_ANDROID = /tag was lost/i;
66
+
67
+ /** Whether a failed chip read is worth reopening the session for. */
68
+ export function isRetryableChipError(err: unknown): boolean {
69
+ if (err instanceof EmrtdSessionError) {
70
+ // A chip that stopped answering mid-read is the physical case: the document
71
+ // moved. `bac_failed` and `select_failed` are about WHICH document this is,
72
+ // and a new session cannot change that.
73
+ return err.code === 'read_failed' || err.code === 'transport';
74
+ }
75
+ const raw = err instanceof Error ? err.message : String(err ?? '');
76
+ return (
77
+ RETRYABLE_IOS_SESSION.test(raw) ||
78
+ RETRYABLE_IOS_TRANSCEIVE.test(raw) ||
79
+ RETRYABLE_ANDROID.test(raw)
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Whether a failure to OPEN a session is worth another try.
85
+ *
86
+ * Narrower than the mid-read set, because here the codes mean different
87
+ * things: 202 is the radio refusing a session while the previous sheet is
88
+ * still tearing down, and 203 is the radio busy elsewhere — both are states
89
+ * that pass on their own. 201 means the user never presented a document in the
90
+ * whole wait, and 200 that they dismissed the sheet; reopening it fights them.
91
+ */
92
+ export function isRetryableStartError(err: unknown): boolean {
93
+ const raw = err instanceof Error ? err.message : String(err ?? '');
94
+ // Android: a drop between discovery and connect() surfaces here as
95
+ // TagLostException. Without this arm every Android start failure was
96
+ // terminal — the 3-attempt budget only ever applied to iOS.
97
+ return /Code=(202|203)\b/.test(raw) || RETRYABLE_ANDROID.test(raw);
98
+ }
@@ -0,0 +1,178 @@
1
+ import { concat, padToBlock, timingSafeEqual, unpadFromBlock } from './bytes';
2
+ import { encodeTlv, readTlvSequence } from './der';
3
+ import {
4
+ BLOCK_SIZE,
5
+ decrypt3Des,
6
+ encrypt3Des,
7
+ macWithPadding,
8
+ type EmrtdPrimitives,
9
+ type SessionKeys,
10
+ } from './crypto';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Secure messaging (ICAO 9303 Part 11 §9.8).
14
+ //
15
+ // After BAC every command and response is wrapped: the data encrypted under
16
+ // KSenc, the whole thing MACed under KSmac, and a send-sequence counter mixed
17
+ // in so neither side can replay a message from earlier in the session.
18
+ //
19
+ // The SSC is the part that is easy to get subtly wrong. It is incremented
20
+ // BEFORE each command and again BEFORE checking the response, so one command
21
+ // consumes two values. Getting that out of step produces a MAC failure on the
22
+ // second command of a session — which looks like a wrong key rather than an
23
+ // off-by-one, and is why it is centralised here in one small object.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const DO87 = 0x87; // Encrypted data, with a padding-content indicator
27
+ const DO85 = 0x85; // Encrypted data, no indicator (some chips use this)
28
+ const DO97 = 0x97; // Expected response length
29
+ const DO99 = 0x99; // Processing status (SW1 SW2)
30
+ const DO8E = 0x8e; // Cryptographic checksum
31
+
32
+ export class SecureMessagingError extends Error {
33
+ constructor(message: string) {
34
+ super(message);
35
+ this.name = 'SecureMessagingError';
36
+ Object.setPrototypeOf(this, SecureMessagingError.prototype);
37
+ }
38
+ }
39
+
40
+ /** An APDU, before wrapping. */
41
+ export interface CommandApdu {
42
+ cla: number;
43
+ ins: number;
44
+ p1: number;
45
+ p2: number;
46
+ data?: Uint8Array;
47
+ /** Expected response length. Undefined means the command expects none. */
48
+ le?: number;
49
+ }
50
+
51
+ /**
52
+ * A secure-messaging session: the keys plus the counter.
53
+ *
54
+ * Stateful ON PURPOSE — the SSC has to advance in lockstep with the exchange,
55
+ * and threading it through every call site is how it drifts.
56
+ */
57
+ export class SecureMessagingSession {
58
+ private ssc: Uint8Array;
59
+
60
+ constructor(
61
+ private readonly p: EmrtdPrimitives,
62
+ private readonly keys: SessionKeys,
63
+ initialSsc: Uint8Array,
64
+ ) {
65
+ this.ssc = new Uint8Array(initialSsc);
66
+ }
67
+
68
+ /** Increment the counter as a big-endian integer, with wraparound. */
69
+ private incrementSsc(): void {
70
+ for (let i = this.ssc.length - 1; i >= 0; i--) {
71
+ this.ssc[i] = (this.ssc[i]! + 1) & 0xff;
72
+ if (this.ssc[i] !== 0) break;
73
+ }
74
+ }
75
+
76
+ /** Wrap a command for transmission. Consumes one SSC value. */
77
+ protect(apdu: CommandApdu): Uint8Array {
78
+ this.incrementSsc();
79
+
80
+ // The class byte gains the secure-messaging bits, and the MAC is computed
81
+ // over the PADDED header — a detail the standard is easy to misread.
82
+ const cla = apdu.cla | 0x0c;
83
+ const header = padToBlock(
84
+ new Uint8Array([cla, apdu.ins, apdu.p1, apdu.p2]),
85
+ BLOCK_SIZE,
86
+ );
87
+
88
+ const parts: Uint8Array[] = [];
89
+
90
+ if (apdu.data && apdu.data.length > 0) {
91
+ const encrypted = encrypt3Des(this.p, this.keys.ksEnc, padToBlock(apdu.data, BLOCK_SIZE));
92
+ // The leading 0x01 is the padding-content indicator: "ISO 9797-1 method
93
+ // 2 was used". Omitting it is a common source of chips rejecting DO'87'.
94
+ parts.push(encodeTlv(DO87, concat(new Uint8Array([0x01]), encrypted)));
95
+ }
96
+
97
+ if (apdu.le !== undefined) {
98
+ const le =
99
+ apdu.le > 0xff
100
+ ? new Uint8Array([(apdu.le >> 8) & 0xff, apdu.le & 0xff])
101
+ : new Uint8Array([apdu.le & 0xff]);
102
+ parts.push(encodeTlv(DO97, le));
103
+ }
104
+
105
+ const body = concat(...parts);
106
+ const mac = macWithPadding(this.p, this.keys.ksMac, concat(this.ssc, header, body));
107
+ const checksum = encodeTlv(DO8E, mac);
108
+
109
+ const payload = concat(body, checksum);
110
+ // Le of 0x00 asks for the maximum: the wrapped response is longer than the
111
+ // wrapped command, and a short Le silently truncates it.
112
+ return concat(
113
+ new Uint8Array([cla, apdu.ins, apdu.p1, apdu.p2, payload.length]),
114
+ payload,
115
+ new Uint8Array([0x00]),
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Unwrap a response. Consumes one SSC value.
121
+ *
122
+ * Throws when the checksum is absent or does not verify — an unauthenticated
123
+ * response is not data, it is whatever the channel decided to hand back.
124
+ */
125
+ unprotect(response: Uint8Array): { data: Uint8Array; statusWord: number } {
126
+ this.incrementSsc();
127
+
128
+ const parsed = readTlvSequence(response);
129
+ const checksumIndex = parsed.findIndex((o) => o.tag === DO8E);
130
+ if (checksumIndex < 0) throw new SecureMessagingError('The response carried no checksum.');
131
+ const checksum = parsed[checksumIndex]!;
132
+
133
+ // The MAC covers the objects BEFORE DO'8E' — everything after it is outside
134
+ // the protected data. Taking a prefix rather than filtering matters: a
135
+ // response that still carries its outer status word parses `9000` as a
136
+ // perfectly valid empty TLV, and folding that into the MAC fails every
137
+ // verification for a reason nothing about the message reveals.
138
+ const objects = parsed.slice(0, checksumIndex);
139
+ const macInput = concat(
140
+ this.ssc,
141
+ ...objects.map((o) => encodeTlv(o.tag, o.value)),
142
+ );
143
+ const expected = macWithPadding(this.p, this.keys.ksMac, macInput);
144
+ if (!timingSafeEqual(expected, checksum.value)) {
145
+ throw new SecureMessagingError('The response failed its integrity check.');
146
+ }
147
+
148
+ const statusObject = objects.find((o) => o.tag === DO99);
149
+ // The status comes from DO'99', NOT from the outer status word. A chip can
150
+ // return 0x9000 on the wrapper while reporting a failure inside — reading
151
+ // the outer word treats that as success.
152
+ const statusWord =
153
+ statusObject && statusObject.value.length >= 2
154
+ ? (statusObject.value[0]! << 8) | statusObject.value[1]!
155
+ : 0x0000;
156
+
157
+ const encrypted = objects.find((o) => o.tag === DO87 || o.tag === DO85);
158
+ if (!encrypted) return { data: new Uint8Array(0), statusWord };
159
+
160
+ // DO'87' carries the padding-content indicator as its first byte; DO'85'
161
+ // does not.
162
+ const body =
163
+ encrypted.tag === DO87 ? encrypted.value.subarray(1) : encrypted.value;
164
+ if (body.length === 0 || body.length % BLOCK_SIZE !== 0) {
165
+ throw new SecureMessagingError('The response ciphertext was misaligned.');
166
+ }
167
+
168
+ return {
169
+ data: unpadFromBlock(decrypt3Des(this.p, this.keys.ksEnc, body), BLOCK_SIZE),
170
+ statusWord,
171
+ };
172
+ }
173
+
174
+ /** The current counter — for diagnostics and tests only. */
175
+ get sendSequenceCounter(): Uint8Array {
176
+ return new Uint8Array(this.ssc);
177
+ }
178
+ }