@myazahq/kyc-sdk-react-native 2.0.1 → 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.
- package/README.md +111 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraPermissionView.tsx +39 -3
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +67 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +98 -1
- package/src/config/keyPeople.ts +152 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +331 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
- package/src/screens/BusinessKeyPersonRow.tsx +170 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +190 -145
- package/src/screens/IdInputStep.tsx +19 -9
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/LivenessStep.tsx +181 -267
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +3 -2
- package/src/services/validators.ts +3 -0
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -119
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- package/src/__tests__/validators.test.ts +0 -38
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { LivenessFaceData } from './types';
|
|
2
|
+
|
|
3
|
+
// ─── Face continuity ──────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// "Is this the same face it was a moment ago?"
|
|
6
|
+
//
|
|
7
|
+
// Liveness proves a live human performed the challenges. It proves nothing about
|
|
8
|
+
// WHO unless the face that performed them is the face that gets captured —
|
|
9
|
+
// otherwise one person can nod and blink while another is photographed, and
|
|
10
|
+
// every gesture check passes on the wrong person.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here identifies anyone. It tracks whether the face in frame is
|
|
13
|
+
// CONTINUOUS with the one before it, which is all that is needed to reject a
|
|
14
|
+
// substitution:
|
|
15
|
+
//
|
|
16
|
+
// • a jump — the face leaps across the frame or changes size abruptly. Real
|
|
17
|
+
// heads move continuously at 15–30fps; a cut to another person does not.
|
|
18
|
+
// • a gap — the face left and a different one arrived. After a loss nothing
|
|
19
|
+
// vouches that the returning face is the same person, so progress earned
|
|
20
|
+
// before the gap is not carried across it.
|
|
21
|
+
// • a changed tracking id, where the platform provides one (Android ML Kit;
|
|
22
|
+
// Apple Vision has no cross-frame equivalent). Proof rather than inference,
|
|
23
|
+
// so it is used when present and never depended upon.
|
|
24
|
+
//
|
|
25
|
+
// Ported from the Flutter SDK's lib/src/liveness/face_continuity.dart — same
|
|
26
|
+
// thresholds, same reasoning. Pure logic, so the thresholds are testable.
|
|
27
|
+
|
|
28
|
+
export type FaceContinuity =
|
|
29
|
+
/** Same face, still tracked. */
|
|
30
|
+
| 'same'
|
|
31
|
+
/**
|
|
32
|
+
* A different face. The session cannot carry on: whoever performed the
|
|
33
|
+
* challenges is not who is in frame now.
|
|
34
|
+
*/
|
|
35
|
+
| 'substituted'
|
|
36
|
+
/**
|
|
37
|
+
* The face returned after being absent. Not proof of substitution — people
|
|
38
|
+
* look away — but nothing vouches for it either, so anything already earned
|
|
39
|
+
* must be re-earned.
|
|
40
|
+
*/
|
|
41
|
+
| 'reacquired';
|
|
42
|
+
|
|
43
|
+
export interface FaceContinuityOptions {
|
|
44
|
+
/**
|
|
45
|
+
* How far the face centre may travel between consecutive frames, as a share
|
|
46
|
+
* of the frame. Generous: at 15fps a real head covers ground, and the cost of
|
|
47
|
+
* a false positive is a user being told to start again.
|
|
48
|
+
*/
|
|
49
|
+
maxCentreJump?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Largest allowed frame-to-frame change in face size, as a ratio. A face that
|
|
52
|
+
* suddenly measures 1.6× its previous size is a different face, not someone
|
|
53
|
+
* leaning in.
|
|
54
|
+
*/
|
|
55
|
+
maxSizeRatioChange?: number;
|
|
56
|
+
/**
|
|
57
|
+
* A gap longer than this means the returning face is unvouched for. Short
|
|
58
|
+
* enough to catch a camera pan between two people, long enough to survive the
|
|
59
|
+
* dropped frames a colour flash causes.
|
|
60
|
+
*/
|
|
61
|
+
maxGapMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class FaceContinuityGuard {
|
|
65
|
+
private readonly maxCentreJump: number;
|
|
66
|
+
private readonly maxSizeRatioChange: number;
|
|
67
|
+
private readonly maxGapMs: number;
|
|
68
|
+
|
|
69
|
+
private centreX: number | null = null;
|
|
70
|
+
private centreY: number | null = null;
|
|
71
|
+
private size: number | null = null;
|
|
72
|
+
private trackingId: number | null = null;
|
|
73
|
+
private lastSeen: number | null = null;
|
|
74
|
+
|
|
75
|
+
constructor(options: FaceContinuityOptions = {}) {
|
|
76
|
+
this.maxCentreJump = options.maxCentreJump ?? 0.35;
|
|
77
|
+
this.maxSizeRatioChange = options.maxSizeRatioChange ?? 1.6;
|
|
78
|
+
this.maxGapMs = options.maxGapMs ?? 900;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Forget everything — call when the flow restarts and the previous face is no
|
|
83
|
+
* longer the reference.
|
|
84
|
+
*/
|
|
85
|
+
reset(): void {
|
|
86
|
+
this.centreX = null;
|
|
87
|
+
this.centreY = null;
|
|
88
|
+
this.size = null;
|
|
89
|
+
this.trackingId = null;
|
|
90
|
+
this.lastSeen = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Records that no face was visible. The stored position is KEPT: it is the
|
|
95
|
+
* comparison point for whatever comes back, and it is `lastSeen` ageing that
|
|
96
|
+
* turns an absence into a gap.
|
|
97
|
+
*/
|
|
98
|
+
reportNoFace(): void {}
|
|
99
|
+
|
|
100
|
+
/** Feeds a frame and reports what it means for continuity. */
|
|
101
|
+
update(data: LivenessFaceData, nowMs: number): FaceContinuity {
|
|
102
|
+
const lastSeen = this.lastSeen;
|
|
103
|
+
const prevX = this.centreX;
|
|
104
|
+
const prevY = this.centreY;
|
|
105
|
+
const prevSize = this.size;
|
|
106
|
+
const prevTrack = this.trackingId;
|
|
107
|
+
|
|
108
|
+
this.lastSeen = nowMs;
|
|
109
|
+
this.centreX = data.faceCenterX ?? null;
|
|
110
|
+
this.centreY = data.faceCenterY ?? null;
|
|
111
|
+
this.size = data.faceSizeRatio;
|
|
112
|
+
if (data.trackingId != null && data.trackingId >= 0) {
|
|
113
|
+
this.trackingId = data.trackingId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Nothing to compare against yet.
|
|
117
|
+
if (lastSeen === null) return 'same';
|
|
118
|
+
|
|
119
|
+
// A tracking id that changed is proof, where the platform offers one.
|
|
120
|
+
if (
|
|
121
|
+
prevTrack != null &&
|
|
122
|
+
data.trackingId != null &&
|
|
123
|
+
data.trackingId >= 0 &&
|
|
124
|
+
data.trackingId !== prevTrack
|
|
125
|
+
) {
|
|
126
|
+
return 'substituted';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A gap: the face was away long enough that nothing vouches for its return.
|
|
130
|
+
if (nowMs - lastSeen > this.maxGapMs) return 'reacquired';
|
|
131
|
+
|
|
132
|
+
// A jump: continuous frames, discontinuous face.
|
|
133
|
+
if (
|
|
134
|
+
prevX != null &&
|
|
135
|
+
prevY != null &&
|
|
136
|
+
data.faceCenterX != null &&
|
|
137
|
+
data.faceCenterY != null
|
|
138
|
+
) {
|
|
139
|
+
const dx = Math.abs(data.faceCenterX - prevX);
|
|
140
|
+
const dy = Math.abs(data.faceCenterY - prevY);
|
|
141
|
+
if (dx > this.maxCentreJump || dy > this.maxCentreJump) return 'substituted';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (prevSize != null && prevSize > 0.01 && data.faceSizeRatio > 0.01) {
|
|
145
|
+
const ratio = data.faceSizeRatio / prevSize;
|
|
146
|
+
if (ratio > this.maxSizeRatioChange || ratio < 1 / this.maxSizeRatioChange) {
|
|
147
|
+
return 'substituted';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return 'same';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Flash (screen-reflection) liveness — the pure correlation core.
|
|
3
|
+
//
|
|
4
|
+
// The screen emits a short RANDOMIZED colour sequence while the camera records;
|
|
5
|
+
// a live face physically reflects those colours in that order. A replayed
|
|
6
|
+
// video, an injected feed, or a screen held up to the camera emits its own
|
|
7
|
+
// light and cannot match a sequence that did not exist until this session
|
|
8
|
+
// started. That is the whole anti-replay property, and it is why the sequence
|
|
9
|
+
// must be generated fresh per session and never reused.
|
|
10
|
+
//
|
|
11
|
+
// No camera or native dependency here: the caller feeds face-region RGB
|
|
12
|
+
// samples. That is what makes the physics testable with synthetic numbers.
|
|
13
|
+
//
|
|
14
|
+
// The palette names and the dominance formula are a CONTRACT WITH THE SERVER.
|
|
15
|
+
// `src/docbio/analysis.ts` re-analyses the recorded video with the same palette
|
|
16
|
+
// and the same test, then LCS-matches what it detects against the sequence the
|
|
17
|
+
// client claims. If the two sides measured different things, an honest client
|
|
18
|
+
// would look like a mismatch — so this mirrors the server exactly, including
|
|
19
|
+
// using the positive-shift SHARE rather than a vector projection.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface FlashColor {
|
|
23
|
+
/** Stable name — this is what the claim and the server's analysis compare. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Colour painted over the screen. */
|
|
26
|
+
css: string;
|
|
27
|
+
/** Which RGB channels a live reflection should shift along. */
|
|
28
|
+
boost: [number, number, number];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** High-luminance primaries — maximum reflected signal per channel. */
|
|
32
|
+
export const FLASH_PALETTE: readonly FlashColor[] = [
|
|
33
|
+
{ name: 'red', css: '#ff2020', boost: [1, 0, 0] },
|
|
34
|
+
{ name: 'green', css: '#20ff40', boost: [0, 1, 0] },
|
|
35
|
+
{ name: 'blue', css: '#2050ff', boost: [0, 0, 1] },
|
|
36
|
+
{ name: 'magenta', css: '#ff20d0', boost: [1, 0, 1] },
|
|
37
|
+
{ name: 'cyan', css: '#20ffe0', boost: [0, 1, 1] },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minimum shift magnitude (0–255 scale) for a flash to be measurable. Below it
|
|
42
|
+
* the ambient light drowned the reflection — inconclusive, not failed.
|
|
43
|
+
*/
|
|
44
|
+
export const MIN_SHIFT_MAGNITUDE = 3;
|
|
45
|
+
|
|
46
|
+
/** The share of the positive shift that must lie on the boosted channels. */
|
|
47
|
+
export const DOMINANCE_THRESHOLD = 0.55;
|
|
48
|
+
|
|
49
|
+
/** Colours in a sequence. More is harder to spoof but longer to sit through. */
|
|
50
|
+
export const MIN_FLASH_SEQUENCE = 2;
|
|
51
|
+
export const MAX_FLASH_SEQUENCE = 5;
|
|
52
|
+
export const DEFAULT_FLASH_SEQUENCE = 4;
|
|
53
|
+
|
|
54
|
+
export type Rgb = readonly [number, number, number];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A fresh random sequence.
|
|
58
|
+
*
|
|
59
|
+
* `random` is injectable so tests can pin the order; production must use real
|
|
60
|
+
* randomness — a predictable sequence is a replayable one, which defeats the
|
|
61
|
+
* entire check.
|
|
62
|
+
*/
|
|
63
|
+
export function generateFlashSequence(
|
|
64
|
+
count = DEFAULT_FLASH_SEQUENCE,
|
|
65
|
+
random: () => number = Math.random,
|
|
66
|
+
): FlashColor[] {
|
|
67
|
+
const pool = [...FLASH_PALETTE];
|
|
68
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
69
|
+
const j = Math.floor(random() * (i + 1));
|
|
70
|
+
[pool[i], pool[j]] = [pool[j]!, pool[i]!];
|
|
71
|
+
}
|
|
72
|
+
const n = Math.min(MAX_FLASH_SEQUENCE, Math.max(MIN_FLASH_SEQUENCE, Math.round(count)));
|
|
73
|
+
return pool.slice(0, n);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FlashSample {
|
|
77
|
+
/** The ambient light drowned the flash — no verdict either way. */
|
|
78
|
+
inconclusive: boolean;
|
|
79
|
+
matched: boolean;
|
|
80
|
+
/** Boosted-channel share of the positive shift (0–1); 0 when inconclusive. */
|
|
81
|
+
dominance: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Correlate one flash: did the face's colour shift the way this flash should
|
|
86
|
+
* have made it shift?
|
|
87
|
+
*
|
|
88
|
+
* `baseline` and `lit` are mean face-region RGB (0–255) from immediately before
|
|
89
|
+
* and during the flash. Taking the baseline per-flash rather than once tracks
|
|
90
|
+
* ambient drift — someone walking under a light mid-sequence would otherwise
|
|
91
|
+
* look like a failure.
|
|
92
|
+
*/
|
|
93
|
+
export function correlateFlash(baseline: Rgb, lit: Rgb, boost: Rgb): FlashSample {
|
|
94
|
+
const shift = [lit[0] - baseline[0], lit[1] - baseline[1], lit[2] - baseline[2]];
|
|
95
|
+
const magnitude = Math.abs(shift[0]!) + Math.abs(shift[1]!) + Math.abs(shift[2]!);
|
|
96
|
+
if (magnitude < MIN_SHIFT_MAGNITUDE) {
|
|
97
|
+
return { inconclusive: true, matched: false, dominance: 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Only the POSITIVE shift counts: a flash adds light, it never removes it, so
|
|
101
|
+
// a channel that dropped is noise (or the previous flash decaying) rather
|
|
102
|
+
// than evidence about this one.
|
|
103
|
+
const totalPositive = shift.reduce((acc, v) => acc + Math.max(0, v!), 0);
|
|
104
|
+
const boosted = shift.reduce((acc, v, i) => acc + Math.max(0, v!) * boost[i]!, 0);
|
|
105
|
+
const dominance = totalPositive > 0 ? boosted / totalPositive : 0;
|
|
106
|
+
|
|
107
|
+
return { inconclusive: false, matched: dominance >= DOMINANCE_THRESHOLD, dominance };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FlashResult {
|
|
111
|
+
passed: boolean;
|
|
112
|
+
/** Matched / measurable (0–1). */
|
|
113
|
+
score: number;
|
|
114
|
+
matched: number;
|
|
115
|
+
total: number;
|
|
116
|
+
inconclusive: number;
|
|
117
|
+
/** The colours emitted, in order — the claim the server checks the video against. */
|
|
118
|
+
sequence: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Aggregate the per-flash samples.
|
|
123
|
+
*
|
|
124
|
+
* Two thirds of the MEASURABLE flashes must match. Flashes the ambient light
|
|
125
|
+
* drowned are excluded rather than counted against the user: a phone in direct
|
|
126
|
+
* sunlight cannot reflect a screen, and failing people for standing outdoors
|
|
127
|
+
* would be a worse error than missing a spoof — which is why an all-inconclusive
|
|
128
|
+
* run passes soft. Paper-photo and mask attacks are the gesture checks' job.
|
|
129
|
+
*/
|
|
130
|
+
export function evaluateFlashSequence(
|
|
131
|
+
sequence: readonly FlashColor[],
|
|
132
|
+
samples: readonly FlashSample[],
|
|
133
|
+
): FlashResult {
|
|
134
|
+
const measurable = samples.filter((s) => !s.inconclusive);
|
|
135
|
+
const matched = measurable.filter((s) => s.matched).length;
|
|
136
|
+
const inconclusive = samples.length - measurable.length;
|
|
137
|
+
return {
|
|
138
|
+
passed: measurable.length === 0 || matched >= Math.ceil(measurable.length * 0.66),
|
|
139
|
+
score: measurable.length === 0 ? 0 : matched / measurable.length,
|
|
140
|
+
matched,
|
|
141
|
+
total: samples.length,
|
|
142
|
+
inconclusive,
|
|
143
|
+
sequence: sequence.map((c) => c.name),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Flash-ready gate — decides WHEN a flash-only sequence may start.
|
|
3
|
+
//
|
|
4
|
+
// Pure logic, no camera and no component, so the timing is unit-tested rather
|
|
5
|
+
// than tuned on a device.
|
|
6
|
+
//
|
|
7
|
+
// It exists because flash-only mode otherwise flashes the instant a face is at
|
|
8
|
+
// a good distance, off a SINGLE frame, before lighting has been confirmed. That
|
|
9
|
+
// is survivable in the gesture flow — the challenges take seconds and re-check
|
|
10
|
+
// framing throughout — but flash mode has no such buffer: nothing follows the
|
|
11
|
+
// flash, so the "come closer / more light" guidance never gets a chance to show
|
|
12
|
+
// and the screen flashes at a face that was merely passing through frame.
|
|
13
|
+
//
|
|
14
|
+
// Three conditions, each earning its place:
|
|
15
|
+
// • FRAMED — the face is at the right distance (no position warning).
|
|
16
|
+
// • LIT — no lighting warning. But "no warning" is ambiguous until the
|
|
17
|
+
// brightness sampler has produced a reading: during warm-up lighting is
|
|
18
|
+
// UNKNOWN, not confirmed-good, so a dim room would read as fine and flash
|
|
19
|
+
// with no "more light" prompt. Hence `lightingConfirmed` gates separately.
|
|
20
|
+
// • DWELL — framed+lit held CONTINUOUSLY for a visible moment, so a face
|
|
21
|
+
// crossing through the right distance doesn't trigger, and the guidance and
|
|
22
|
+
// a "hold still" beat land before the screen lights up.
|
|
23
|
+
//
|
|
24
|
+
// Ported from the Flutter SDK's FlashReadyGate — keep the two in step.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** How long framed+lit must hold before flashing. */
|
|
28
|
+
export const FLASH_READY_DWELL_MS = 1200;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Safety valve: if framing is good but the sampler NEVER confirms lighting
|
|
32
|
+
* (sampling unsupported or failing on this device), proceed anyway after this
|
|
33
|
+
* so the user is never stuck on a permanent "hold still".
|
|
34
|
+
*
|
|
35
|
+
* Longer than the dwell AND the sampler's warm-up, so a healthy device always
|
|
36
|
+
* confirms lighting first and this never fires in the normal case.
|
|
37
|
+
*/
|
|
38
|
+
export const FLASH_LIGHTING_WAIT_MS = 3000;
|
|
39
|
+
|
|
40
|
+
/** The gate's verdict for one frame. */
|
|
41
|
+
export interface FlashReadyState {
|
|
42
|
+
/** Start the flash now. */
|
|
43
|
+
ready: boolean;
|
|
44
|
+
/** 0..1 across the dwell, for a "getting ready" indicator. */
|
|
45
|
+
progress: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NOT_READY: FlashReadyState = { ready: false, progress: 0 };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tracks the framed+lit hold across frames. One instance per liveness attempt;
|
|
52
|
+
* `reset()` reuses it for a retry.
|
|
53
|
+
*/
|
|
54
|
+
export class FlashReadyGate {
|
|
55
|
+
private heldSince: number | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly dwellMs: number = FLASH_READY_DWELL_MS,
|
|
59
|
+
private readonly lightingWaitMs: number = FLASH_LIGHTING_WAIT_MS,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
reset(): void {
|
|
63
|
+
this.heldSince = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Feed one frame's verdict.
|
|
68
|
+
*
|
|
69
|
+
* @param framed face at the right distance (no position warning)
|
|
70
|
+
* @param lit no lighting warning — note this is also true while
|
|
71
|
+
* lighting is still UNKNOWN, which is why
|
|
72
|
+
* `lightingConfirmed` is separate
|
|
73
|
+
* @param lightingConfirmed the sampler has produced at least one real reading
|
|
74
|
+
* @param now injected clock, so tests are deterministic
|
|
75
|
+
*/
|
|
76
|
+
update({
|
|
77
|
+
framed,
|
|
78
|
+
lit,
|
|
79
|
+
lightingConfirmed,
|
|
80
|
+
now,
|
|
81
|
+
}: {
|
|
82
|
+
framed: boolean;
|
|
83
|
+
lit: boolean;
|
|
84
|
+
lightingConfirmed: boolean;
|
|
85
|
+
now: number;
|
|
86
|
+
}): FlashReadyState {
|
|
87
|
+
// Any break in framing or lighting restarts the hold — the point is a
|
|
88
|
+
// CONTINUOUS steady moment, not a cumulative one.
|
|
89
|
+
if (!framed || !lit) {
|
|
90
|
+
this.heldSince = null;
|
|
91
|
+
return NOT_READY;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.heldSince === null) this.heldSince = now;
|
|
95
|
+
const held = now - this.heldSince;
|
|
96
|
+
|
|
97
|
+
// Lighting still unmeasured: keep holding, but don't hold forever on a
|
|
98
|
+
// device whose sampler never reports.
|
|
99
|
+
if (!lightingConfirmed && held < this.lightingWaitMs) {
|
|
100
|
+
return { ready: false, progress: Math.min(held / this.dwellMs, 0.99) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (held >= this.dwellMs) return { ready: true, progress: 1 };
|
|
104
|
+
return { ready: false, progress: held / this.dwellMs };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
correlateFlash,
|
|
3
|
+
evaluateFlashSequence,
|
|
4
|
+
generateFlashSequence,
|
|
5
|
+
type FlashColor,
|
|
6
|
+
type FlashResult,
|
|
7
|
+
type FlashSample,
|
|
8
|
+
type Rgb,
|
|
9
|
+
} from './flashDetector';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Driving the flash sequence.
|
|
13
|
+
//
|
|
14
|
+
// The detector knows the physics; this knows the choreography — paint a colour,
|
|
15
|
+
// wait for the screen and camera to catch up, average the face's colour over a
|
|
16
|
+
// window, and compare it with the neutral moment immediately before.
|
|
17
|
+
//
|
|
18
|
+
// The baseline is re-taken BEFORE EACH FLASH rather than once at the start.
|
|
19
|
+
// Ambient light drifts (someone walks past a window, an auto-exposure kicks
|
|
20
|
+
// in), and a single stale baseline would turn that drift into a fake shift on
|
|
21
|
+
// every subsequent flash.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** How long each phase of one flash takes. */
|
|
25
|
+
export const FLASH_TIMING = {
|
|
26
|
+
/** Neutral settle before measuring the baseline. */
|
|
27
|
+
neutralMs: 150,
|
|
28
|
+
/** Baseline measurement window. */
|
|
29
|
+
baselineMs: 300,
|
|
30
|
+
/**
|
|
31
|
+
* Screen paint → camera exposure latency. Measuring during this window would
|
|
32
|
+
* average lit frames together with unlit ones and dilute the shift.
|
|
33
|
+
*/
|
|
34
|
+
litSettleMs: 200,
|
|
35
|
+
/** Lit measurement window. */
|
|
36
|
+
litMs: 450,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/** Roughly one sample per camera frame at 15fps. */
|
|
40
|
+
const SAMPLE_INTERVAL_MS = 66;
|
|
41
|
+
|
|
42
|
+
export interface FlashRunnerDeps {
|
|
43
|
+
/** Paints the fullscreen overlay; null clears it back to neutral. */
|
|
44
|
+
setColor: (css: string | null) => void;
|
|
45
|
+
/** The most recent face RGB, or null when the face was not sampled. */
|
|
46
|
+
readFaceRgb: () => Rgb | null;
|
|
47
|
+
/** False once the caller has gone away (unmount, retry, abort). */
|
|
48
|
+
isActive: () => boolean;
|
|
49
|
+
/** Injectable for tests; production uses setTimeout. */
|
|
50
|
+
sleep?: (ms: number) => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const realSleep = (ms: number): Promise<void> =>
|
|
54
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
|
|
56
|
+
/** Average the face RGB over a window, or null if nothing could be read. */
|
|
57
|
+
async function sampleWindow(
|
|
58
|
+
deps: FlashRunnerDeps,
|
|
59
|
+
durationMs: number,
|
|
60
|
+
sleep: (ms: number) => Promise<void>,
|
|
61
|
+
): Promise<Rgb | null> {
|
|
62
|
+
const samples: Rgb[] = [];
|
|
63
|
+
let elapsed = 0;
|
|
64
|
+
while (elapsed < durationMs && deps.isActive()) {
|
|
65
|
+
const rgb = deps.readFaceRgb();
|
|
66
|
+
if (rgb) samples.push(rgb);
|
|
67
|
+
await sleep(SAMPLE_INTERVAL_MS);
|
|
68
|
+
elapsed += SAMPLE_INTERVAL_MS;
|
|
69
|
+
}
|
|
70
|
+
if (samples.length === 0) return null;
|
|
71
|
+
const total = samples.reduce<[number, number, number]>(
|
|
72
|
+
(acc, s) => [acc[0] + s[0], acc[1] + s[1], acc[2] + s[2]],
|
|
73
|
+
[0, 0, 0],
|
|
74
|
+
);
|
|
75
|
+
return [total[0] / samples.length, total[1] / samples.length, total[2] / samples.length];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run the whole sequence and return the verdict.
|
|
80
|
+
*
|
|
81
|
+
* The overlay is ALWAYS cleared on the way out, including on an abort — leaving
|
|
82
|
+
* a full-screen colour painted over a stopped flow would strand the user on a
|
|
83
|
+
* red screen with no way back.
|
|
84
|
+
*/
|
|
85
|
+
export async function runFlashSequence(
|
|
86
|
+
deps: FlashRunnerDeps,
|
|
87
|
+
sequence: FlashColor[] = generateFlashSequence(),
|
|
88
|
+
): Promise<FlashResult> {
|
|
89
|
+
const sleep = deps.sleep ?? realSleep;
|
|
90
|
+
const samples: FlashSample[] = [];
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
for (const flash of sequence) {
|
|
94
|
+
if (!deps.isActive()) break;
|
|
95
|
+
|
|
96
|
+
deps.setColor(null);
|
|
97
|
+
await sleep(FLASH_TIMING.neutralMs);
|
|
98
|
+
const baseline = await sampleWindow(deps, FLASH_TIMING.baselineMs, sleep);
|
|
99
|
+
|
|
100
|
+
deps.setColor(flash.css);
|
|
101
|
+
await sleep(FLASH_TIMING.litSettleMs);
|
|
102
|
+
const lit = await sampleWindow(deps, FLASH_TIMING.litMs, sleep);
|
|
103
|
+
|
|
104
|
+
if (!baseline || !lit) {
|
|
105
|
+
// The face left the frame, or the platform cannot sample RGB at all.
|
|
106
|
+
// Unmeasured is not failed — it joins the inconclusive pile, which
|
|
107
|
+
// fails soft.
|
|
108
|
+
samples.push({ inconclusive: true, matched: false, dominance: 0 });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
samples.push(correlateFlash(baseline, lit, flash.boost));
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
deps.setColor(null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return evaluateFlashSequence(sequence, samples);
|
|
118
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FlashResult } from './flashDetector';
|
|
2
|
+
import type { LivenessMode } from './types';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Capture-integrity signals.
|
|
6
|
+
//
|
|
7
|
+
// What the client observed while capturing, sent as context alongside the
|
|
8
|
+
// verification. The server persists these on `deviceMetadata.integrity` and —
|
|
9
|
+
// crucially — re-analyses the recorded liveness video against the flash
|
|
10
|
+
// SEQUENCE claimed here. That check is why the claim matters: a client can
|
|
11
|
+
// report whatever it likes, but it cannot make a video reflect colours it never
|
|
12
|
+
// emitted.
|
|
13
|
+
//
|
|
14
|
+
// So none of this is trusted as a verdict. It is the claim the server audits.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface LivenessIntegrity {
|
|
18
|
+
mode: LivenessMode;
|
|
19
|
+
/** How many consecutive-frame discontinuities the continuity guard saw. */
|
|
20
|
+
faceGlitches: number;
|
|
21
|
+
flash?: {
|
|
22
|
+
passed: boolean;
|
|
23
|
+
score: number;
|
|
24
|
+
matched: number;
|
|
25
|
+
total: number;
|
|
26
|
+
inconclusive: boolean;
|
|
27
|
+
/** The colours emitted, in order — what the server checks the video for. */
|
|
28
|
+
sequence: string[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CaptureIntegrity {
|
|
33
|
+
liveness: LivenessIntegrity;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildLivenessIntegrity(
|
|
37
|
+
mode: LivenessMode,
|
|
38
|
+
faceGlitches: number,
|
|
39
|
+
flash: FlashResult | null,
|
|
40
|
+
): LivenessIntegrity {
|
|
41
|
+
return {
|
|
42
|
+
mode,
|
|
43
|
+
faceGlitches,
|
|
44
|
+
...(flash
|
|
45
|
+
? {
|
|
46
|
+
flash: {
|
|
47
|
+
passed: flash.passed,
|
|
48
|
+
score: flash.score,
|
|
49
|
+
matched: flash.matched,
|
|
50
|
+
total: flash.total,
|
|
51
|
+
// Collapsed to a boolean for the wire: the server only needs to
|
|
52
|
+
// know whether the run was unmeasurable, not how many individual
|
|
53
|
+
// flashes were drowned.
|
|
54
|
+
inconclusive: flash.total > 0 && flash.inconclusive === flash.total,
|
|
55
|
+
sequence: flash.sequence,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|