Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions public/models/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
"sha256": "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3",
"bytes": 2327524,
"purpose": "Voice activity detection — skip upload on silent captures. Canonical release from github.com/snakers4/silero-vad."
},
{
"name": "facenet512-1ad91552.fp16.onnx",
"sha256": "1ad9155214adb83595b60492b0624bbd44300427c5c3921c876f390c7b44bd66",
"bytes": 47072627,
"purpose": "Client-side FACE recognition embedding (SP-A) — Facenet512 FP16 export (47 MB, opset 17). When VITE_CLIENT_SIDE_EMBEDDING is ON the browser runs this via onnxruntime-web to compute the authoritative 512-d L2-normalized embedding locally and uploads ONLY the vector (the raw face image never leaves the device). The CPU-only server expects the embedding and 400s the legacy image path while the identity flag app.auth.client-side-embedding is ON. Fetched + SHA256-verified at build time (prebuild → fetch-models) into dist/models so a deploy rsync can't wipe a manual upload, and fanned out to the verify build. INT8 is NOT web-compatible (ConvInteger unimplemented on the onnxruntime-web WASM EP) — FP16 is the ship format."
}
]
}
10 changes: 10 additions & 0 deletions src/features/auth/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import StepProgress from '../../../verify-app/StepProgress'
import { usePrefersReducedMotion } from '@hooks/usePrefersReducedMotion'
import { loginShellBackgroundSx, glassCardBackdropFilter } from './loginBackground'
import FloatingShape from './FloatingShape'
import { scheduleFacenetPrefetch } from '@features/biometrics/embedding/prefetchFacenetModel'

/**
* Login form validation schema
Expand Down Expand Up @@ -189,6 +190,15 @@ export default function LoginPage() {
return () => { cancelled = true; clearTimeout(fallback) }
}, [httpClient])

// Warm the Facenet512 model cache on login-surface mount (SP-A go-live).
// Flag-gated + de-duped inside the helper, so with VITE_CLIENT_SIDE_EMBEDDING
// OFF this is a NO-OP (no schedule, no download) and the dashboard login is
// byte-identical to before. When ON, the ~47 MB model downloads on idle while
// the user types their identifier/password, so by the time a FACE step is
// reached the embed is a cache hit (no frozen 47 MB-on-submit download). The
// cleanup cancels a still-pending idle schedule on unmount.
useEffect(() => scheduleFacenetPrefetch(), [])

// Re-fetch the login-config when the user taps "Retry" on the
// config-unavailable banner (the initial fetch failed, almost always because
// the API was unreachable). On success the banner clears and the real Layer-1
Expand Down
75 changes: 62 additions & 13 deletions src/features/auth/components/steps/FaceCaptureStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { BiometricEngine } from '../../../../lib/biometric-engine/core/Biometric
import { dataURLToImageData } from '../../utils/faceCropper'
import { isClientPadAdvisoryEnabled } from '@features/biometrics/pad/clientPadFlag'
import { computeClientPadScore } from '@features/biometrics/pad/computeClientPadScore'
import { scheduleFacenetPrefetch } from '@features/biometrics/embedding/prefetchFacenetModel'
import StepLayout from './StepLayout'
import { stepItemVariants as itemVariants } from './stepMotion'

Expand All @@ -51,7 +52,7 @@ interface FaceCaptureStepProps {
clientEmbedding?: number[],
faceLandmarks?: NormalizedLandmark[],
clientPadScore?: number,
) => void
) => void | Promise<void>
loading: boolean
error?: string
}
Expand All @@ -68,6 +69,14 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur
const [capturedImage, setCapturedImage] = useState<string | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)

// True while the parent `onSubmit` is running its on-device preparation
// (client-side Facenet512 embed: ~47 MB model download on first use + WASM
// init + inference) BEFORE the actual /auth/mfa/step verify call. Distinct
// from `loading` (the in-flight verify owned by the surface's flow): on
// mobile the embed alone can take seconds, and without this the submit button
// looks frozen. Surfaced as a "Preparing secure verification…" button state.
const [preparing, setPreparing] = useState(false)

// ADVISORY client-side PAD / passive-liveness confidence (0..1) for the
// captured frame (SP-D). Computed only when the advisory flag is on and the
// analyzer succeeds; null otherwise. DISPLAY + forward only — the client
Expand Down Expand Up @@ -185,6 +194,16 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// Warm the Facenet512 model cache as soon as the FACE step mounts (the user
// is now seconds from capturing). Flag-gated + de-duped inside the helper, so
// with client-side embedding OFF this is a no-op, and a login surface that
// already prefetched shares the same in-flight download. On idle (fallback
// setTimeout) so it never competes with camera start / face-detector load.
// By submit time the ~47 MB model is cached → the embed is instant instead of
// a frozen 47 MB-on-submit download. The cleanup cancels a still-pending
// idle schedule if the step unmounts first.
useEffect(() => scheduleFacenetPrefetch(), [])

const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return

Expand Down Expand Up @@ -270,15 +289,26 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur
// Non-critical: embedding extraction failure is silently ignored.
}

onSubmit(
capturedImage,
clientEmbedding,
capturedLandmarksRef.current ?? undefined,
// ADVISORY ONLY (SP-D): forward the client PAD score if one was
// computed. undefined when the flag is off or the analyzer failed —
// the submit is identical to the legacy path in that case.
padScoreRef.current ?? undefined,
)
// Show the "Preparing secure verification…" state for the duration of the
// parent's onSubmit. When client-side embedding is ON this covers the model
// download (first use) + ONNX init + inference that happens BEFORE the
// verify call — so the user sees it is working, not frozen, on mobile. When
// the flag is OFF, onSubmit returns synchronously (legacy { image } upload)
// and `preparing` flips on/off within the same tick (no visible flicker).
setPreparing(true)
try {
await onSubmit(
capturedImage,
clientEmbedding,
capturedLandmarksRef.current ?? undefined,
// ADVISORY ONLY (SP-D): forward the client PAD score if one was
// computed. undefined when the flag is off or the analyzer failed —
// the submit is identical to the legacy path in that case.
padScoreRef.current ?? undefined,
)
} finally {
setPreparing(false)
}
}, [capturedImage, onSubmit])

// Determine bounding box overlay color
Expand Down Expand Up @@ -676,7 +706,7 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur
variant="outlined"
size="large"
onClick={retakePhoto}
disabled={loading}
disabled={loading || preparing}
startIcon={<Replay />}
sx={{
flex: 1,
Expand All @@ -691,8 +721,8 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur
variant="contained"
size="large"
onClick={handleSubmit}
disabled={loading}
endIcon={!loading && <ArrowForward />}
disabled={loading || preparing}
endIcon={!loading && !preparing && <ArrowForward />}
sx={{
flex: 1,
py: 1.5,
Expand All @@ -706,8 +736,27 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur
transition: 'all 0.3s ease',
}}
>
{/* `preparing` = on-device prep (client-side embed: model
download + ONNX init + inference) BEFORE the verify
call; `loading` = the in-flight verify. Both show a
spinner; `preparing` adds a status line so the user
knows the (potentially multi-second, mobile) embed is
working, not frozen. */}
{loading ? (
<CircularProgress size={24} sx={{ color: 'white' }} />
) : preparing ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
}}
role="status"
aria-live="polite"
>
<CircularProgress size={20} sx={{ color: 'white' }} />
{t('mfa.face.preparingSecure')}
</Box>
) : (
t('mfa.face.submit')
)}
Expand Down
18 changes: 14 additions & 4 deletions src/features/auth/login-shared/MfaStepRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,27 @@ export default function MfaStepRenderer({
// makes the client embedding self-consistent capture-to-
// capture; without it the embedding is an unaligned crop.
//
// Resilient: a null embedding (no model / ORT load fail /
// inference error) falls back to the unchanged legacy
// image upload so a login is never blocked. Flag OFF is
// byte-identical to the legacy `{ image }` path.
// GPU-LESS ENFORCEMENT (SP-A go-live): the CPU-only server
// EXPECTS the embedding and 400s the legacy image path when
// the identity flag `app.auth.client-side-embedding` is ON.
// So a null embedding (no model / ORT load fail / inference
// error) must NOT silently fall back to uploading the image
// — that upload would just be rejected by the server. Surface
// a clear, RETRYABLE error instead and let the user try again
// (the model cache self-heals on the next attempt + the
// prefetch re-warms it). NEVER send `{ image }` while ON.
if (isClientSideEmbeddingEnabled()) {
const embedding = await embedCapturedFace(image, faceLandmarks)
if (embedding) {
verifyStep(AuthMethodType.FACE, { embedding, ...advisory })
return
}
// On-device prep failed — show a retryable message; the
// captured frame stays so the user can simply re-submit.
onError(t('mfa.face.clientPrepFailed'))
return
}
// Flag OFF: legacy image upload, byte-identical to before.
verifyStep(AuthMethodType.FACE, { image, ...advisory })
}}
loading={loading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
*
* Contract verified here (deterministic — embedder + flag are mocked, no ONNX):
* - Flag ON → verifyStep(FACE, { embedding: number[512] }) and NOT { image }.
* - Flag ON + embedding fails → onError(clientPrepFailed) + NO verifyStep
* (GPU-less enforcement: the CPU-only server 400s the image path when ON,
* so we must NEVER silently fall back to uploading the image).
* - Flag OFF → verifyStep(FACE, { image }) exactly as the legacy path did.
*
* The FaceCaptureStep internals (camera, detection, capture) are mocked to a
Expand Down Expand Up @@ -73,6 +76,7 @@ import MfaStepRenderer from '../MfaStepRenderer'

function renderFaceStep() {
const verifyStep = vi.fn()
const onError = vi.fn()
render(
<MfaStepRenderer
method={AuthMethodType.FACE}
Expand All @@ -89,10 +93,10 @@ function renderFaceStep() {
onAuthenticated={vi.fn()}
onBack={vi.fn()}
loading={false}
onError={vi.fn()}
onError={onError}
/>,
)
return { verifyStep }
return { verifyStep, onError }
}

describe('MfaStepRenderer — FACE submit: client embedding vs legacy image', () => {
Expand Down Expand Up @@ -133,14 +137,18 @@ describe('MfaStepRenderer — FACE submit: client embedding vs legacy image', ()
expect((payload as { embedding: number[] }).embedding).toHaveLength(512)
})

it('flag ON but embedding fails: falls back to the legacy { image } upload (never blocks auth)', async () => {
it('flag ON but embedding fails: surfaces a retryable error and NEVER uploads the image (GPU-less enforcement)', async () => {
flagState.enabled = true
embedCapturedFaceMock.mockResolvedValue(null)
const { verifyStep } = renderFaceStep()
const { verifyStep, onError } = renderFaceStep()

await userEvent.click(screen.getByRole('button', { name: 'face-submit' }))

await waitFor(() => expect(verifyStep).toHaveBeenCalledTimes(1))
expect(verifyStep).toHaveBeenCalledWith(AuthMethodType.FACE, { image: CAPTURED_IMAGE })
// The CPU-only server 400s the image path when the flag is ON, so a null
// embedding must NOT fall back to { image }. Instead a clear, retryable
// error is surfaced and verifyStep is never called.
await waitFor(() => expect(onError).toHaveBeenCalledTimes(1))
expect(onError).toHaveBeenCalledWith(expect.any(String))
expect(verifyStep).not.toHaveBeenCalled()
})
})
Loading
Loading