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
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,38 @@ unchanged and is the default + fallback. See `docs/plans/CLIENT_SIDE_ML_PLAN.md`
data sent is a derived, non-invertible 512-d embedding, over TLS, stored encrypted
(Fernet) — data minimization, NOT "biometric data never leaves the device".

## Client-side VOICE embedding (GPU-less, audit H3, flag-gated `VITE_CLIENT_SIDE_VOICE_EMBEDDING`, default OFF)

VOICE twin of the face client-embedding path. When ON, the browser computes the
**256-d Resemblyzer speaker embedding** locally and uploads **only the vector** —
the raw audio never leaves the device. Flag OFF (default) = byte-identical legacy
audio upload (`{ voiceData }`).

- **Module:** `src/features/biometrics/voice-embedding/` — `clientVoiceEmbeddingFlag.ts`
(mirrors `clientEmbeddingFlag.ts`), `voicePreprocess.ts` (WAV decode → dBFS
normalize → mel partials), `speakerEmbedder.ts` (`SpeakerEmbedder` ONNX class,
lazy `import('onnxruntime-web')`, WASM EP, reuses `embedding/modelCache.getModel`),
`embedCapturedVoice.ts` (orchestrator, returns `number[256] | null`, never throws),
`prefetchVoiceModel.ts` (mirrors `prefetchFacenetModel`, wired into `VoiceStep` mount).
- **Integration:** `MfaStepRenderer.tsx` VOICE case — when the flag is ON, compute the
vector and `verifyStep(VOICE, { embedding })`; **on ANY failure FALL BACK to
`{ voiceData }`** (unlike FACE which hard-fails — the voice preprocessing is scaffold
+ the server accepts either when its flag is ON, so a gap must never block login).
- **Model delivery:** the FP32 `resemblyzer-<sha256>.onnx` (~5.7 MB) must be hosted at
`app.fivucsas.com/models/` and fetched at RUNTIME only when ON. Do **NOT** add it to
`public/models/manifest.json` (a build-prefetch entry FATALs `fetch-models`/deploy —
the exact facenet failure). INT8 rejected (onnxruntime-web WASM lacks quant ops); FP32 ships.
- **⚠️ SCAFFOLD — NOT parity-validated.** The ONNX export + inference are EXACT
(torch↔ONNX cosine 1.0), but the JS preprocessing does NOT reproduce Resemblyzer's
WebRTC-VAD silence trim (no bit-exact JS port) and uses a librosa-APPROXIMATE mel.
Skipping the VAD shifts the embedding ≈0.11 cosine vs server — risky near the 0.65
threshold. **Keep this flag OFF** until the client mel+VAD are validated to parity.
Full contract + canary steps: biometric-processor `docs/design/VOICE_CLIENT_EMBEDDING_SPEC.md`.
- **Ordering caveat:** flip the Identity Core `app.auth.client-side-voice-embedding` flag
**before** `VITE_CLIENT_SIDE_VOICE_EMBEDDING` (web-ON + identity-OFF would send an
embedding the server-OFF path rejects — though the renderer's fallback to `{ voiceData }`
softens this).

## Puzzle as a first-class auth-flow layer (feature-flagged `app.auth.puzzle-layer`, default OFF)

The Biometric Puzzle is composable in the `AuthFlowBuilder` as its own LAYER (allowed
Expand Down
7 changes: 7 additions & 0 deletions src/features/auth/components/steps/VoiceStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { motion } from 'framer-motion'
import { useTranslation } from 'react-i18next'
import { useVoiceRecorder } from '@/lib/biometric-engine/hooks/useVoiceRecorder'
import { scheduleVoiceModelPrefetch } from '@features/biometrics/voice-embedding/prefetchVoiceModel'
import StepLayout from './StepLayout'
import { stepItemVariants as itemVariants } from './stepMotion'

Expand Down Expand Up @@ -70,6 +71,12 @@ export default function VoiceStep({ onSubmit, loading, error }: VoiceStepProps)
const [displayDuration, setDisplayDuration] = useState(0)
const autoStopTriggered = useRef(false)

// GPU-less VOICE (audit H3): warm the speaker-encoder model on mount so the
// in-browser embedding is ready by submit time. No-op unless the
// VITE_CLIENT_SIDE_VOICE_EMBEDDING flag is ON (mirrors FaceCaptureStep's
// scheduleFacenetPrefetch). Cleanup cancels a still-pending idle schedule.
useEffect(() => scheduleVoiceModelPrefetch(), [])

// Auto-stop after MAX_RECORDING_SECONDS.
useEffect(() => {
if (!isRecording) return
Expand Down
21 changes: 21 additions & 0 deletions src/features/auth/login-shared/MfaStepRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import PasswordStep from '../components/steps/PasswordStep'
import PuzzleStep from './steps/PuzzleStep'
import { isClientSideEmbeddingEnabled } from '@features/biometrics/embedding/clientEmbeddingFlag'
import { embedCapturedFace } from '@features/biometrics/embedding/embedCapturedFace'
import { isClientSideVoiceEmbeddingEnabled } from '@features/biometrics/voice-embedding/clientVoiceEmbeddingFlag'
import { embedCapturedVoice } from '@features/biometrics/voice-embedding/embedCapturedVoice'
import type { PuzzleConfig } from '@domain/models/AuthMethod'

/**
Expand Down Expand Up @@ -258,6 +260,25 @@ export default function MfaStepRenderer({
} catch (vadErr) {
console.warn('[MfaStepRenderer] VAD check failed, proceeding:', vadErr)
}

// GPU-less VOICE (audit H3): when the client-side-voice-
// embedding flag is ON, compute the 256-d speaker vector
// in-browser and submit ONLY the vector (the raw audio
// never leaves the device). On ANY failure we fall back to
// uploading the audio so a scaffold/model-hosting gap never
// blocks the login (the server with the voice flag OFF
// would reject an embedding anyway; with it ON it accepts
// either). Flag OFF ⇒ byte-identical legacy audio upload.
if (isClientSideVoiceEmbeddingEnabled()) {
const embedding = await embedCapturedVoice(voiceData)
if (embedding) {
verifyStep(AuthMethodType.VOICE, { embedding })
return
}
console.warn(
'[MfaStepRenderer] client voice embedding unavailable; falling back to audio upload',
)
}
verifyStep(AuthMethodType.VOICE, { voiceData })
}}
loading={loading}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* MfaStepRenderer — VOICE submit path: client-side embedding vs legacy audio.
*
* Audit H3 (GPU-less voice). Behind the `client-side-voice-embedding` flag
* (mirrors the server flag `app.auth.client-side-voice-embedding`), the VOICE
* submit uploads the 256-d speaker embedding vector instead of the recorded
* audio — the raw audio then never leaves the device.
*
* Contract verified here (deterministic — embedder + flag are mocked, no ONNX):
* - Flag OFF → verifyStep(VOICE, { voiceData }) exactly as the legacy path did.
* - Flag ON → verifyStep(VOICE, { embedding: number[256] }) and NOT { voiceData }.
* - Flag ON + embedding fails → FALLS BACK to { voiceData } (the preprocessing
* is scaffold + the server accepts either when ON, so a model/preproc gap must
* never block the login — unlike FACE which hard-fails).
*
* The VoiceStep internals (mic, recorder) are mocked to a single button that
* hands a fixed captured WAV data-URL up to onSubmit, so this suite asserts ONLY
* the renderer's submit-path routing.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '../../../../i18n'
import { AuthMethodType } from '@features/auth/constants'

// Inert biometric engine (the VOICE VAD path reads it; keep the VAD absent so the
// gate is skipped and we test ONLY the embedding routing).
vi.mock('@/lib/biometric-engine/core/BiometricEngine', () => ({
BiometricEngine: {
getInstance: () => ({
initialize: () => Promise.resolve(),
voiceVAD: null,
}),
},
}))

const CAPTURED_VOICE = 'data:audio/wav;base64,AAAA'

// Mock VoiceStep down to a single Submit button so we drive ONLY the renderer's
// onSubmit routing — no mic / MediaRecorder in jsdom.
vi.mock('@features/auth/components/steps/VoiceStep', () => ({
__esModule: true,
default: ({ onSubmit }: { onSubmit: (voiceData: string) => void }) => (
<button type="button" onClick={() => onSubmit(CAPTURED_VOICE)}>
voice-submit
</button>
),
}))

// Mock the client-side voice embedding flag + embed function so the test is
// deterministic and the flag value is injectable.
const flagState = { enabled: false }
const fakeEmbedding = Array.from({ length: 256 }, (_, i) => (i % 7) / 7)
const embedCapturedVoiceMock = vi.fn(async () => fakeEmbedding as number[] | null)

vi.mock('@features/biometrics/voice-embedding/clientVoiceEmbeddingFlag', () => ({
isClientSideVoiceEmbeddingEnabled: () => flagState.enabled,
}))
vi.mock('@features/biometrics/voice-embedding/embedCapturedVoice', () => ({
embedCapturedVoice: (...args: unknown[]) => embedCapturedVoiceMock(...(args as [])),
}))

import MfaStepRenderer from '../MfaStepRenderer'

function renderVoiceStep() {
const verifyStep = vi.fn()
const onError = vi.fn()
render(
<MfaStepRenderer
method={AuthMethodType.VOICE}
mfaSessionToken="sess-123"
verifyStep={verifyStep}
requestWebAuthnChallenge={vi.fn().mockResolvedValue(null)}
httpClient={{
get: vi.fn(),
post: vi.fn().mockResolvedValue({ data: {} }),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as Parameters<typeof MfaStepRenderer>[0]['httpClient']}
onAuthenticated={vi.fn()}
onBack={vi.fn()}
loading={false}
onError={onError}
/>,
)
return { verifyStep, onError }
}

describe('MfaStepRenderer — VOICE submit: client embedding vs legacy audio', () => {
beforeEach(() => {
flagState.enabled = false
embedCapturedVoiceMock.mockClear()
embedCapturedVoiceMock.mockResolvedValue(fakeEmbedding)
})

it('flag OFF (default): uploads the captured { voiceData } unchanged, never embeds', async () => {
flagState.enabled = false
const { verifyStep } = renderVoiceStep()

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

await waitFor(() => expect(verifyStep).toHaveBeenCalledTimes(1))
expect(verifyStep).toHaveBeenCalledWith(AuthMethodType.VOICE, { voiceData: CAPTURED_VOICE })
expect(embedCapturedVoiceMock).not.toHaveBeenCalled()
})

it('flag ON: uploads { embedding: number[256] } and NOT { voiceData }', async () => {
flagState.enabled = true
const { verifyStep } = renderVoiceStep()

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

await waitFor(() => expect(verifyStep).toHaveBeenCalledTimes(1))
expect(embedCapturedVoiceMock).toHaveBeenCalledWith(CAPTURED_VOICE)

const [method, payload] = verifyStep.mock.calls[0]
expect(method).toBe(AuthMethodType.VOICE)
expect(payload).toHaveProperty('embedding')
expect(payload).not.toHaveProperty('voiceData')
expect(Array.isArray((payload as { embedding: number[] }).embedding)).toBe(true)
expect((payload as { embedding: number[] }).embedding).toHaveLength(256)
})

it('flag ON but embedding fails: falls back to { voiceData } (never blocks the login)', async () => {
flagState.enabled = true
embedCapturedVoiceMock.mockResolvedValue(null)
const { verifyStep, onError } = renderVoiceStep()

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

await waitFor(() => expect(verifyStep).toHaveBeenCalledTimes(1))
// Unlike FACE, VOICE falls back to uploading the audio (scaffold preproc +
// the server accepts either when ON), so the login is never blocked.
expect(verifyStep).toHaveBeenCalledWith(AuthMethodType.VOICE, { voiceData: CAPTURED_VOICE })
expect(onError).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { isClientSideVoiceEmbeddingEnabled } from '../clientVoiceEmbeddingFlag'

describe('isClientSideVoiceEmbeddingEnabled', () => {
afterEach(() => {
vi.unstubAllEnvs()
})

it("is OFF by default (env unset)", () => {
vi.stubEnv('VITE_CLIENT_SIDE_VOICE_EMBEDDING', '')
expect(isClientSideVoiceEmbeddingEnabled()).toBe(false)
})

it("is ON only for the exact string 'true'", () => {
vi.stubEnv('VITE_CLIENT_SIDE_VOICE_EMBEDDING', 'true')
expect(isClientSideVoiceEmbeddingEnabled()).toBe(true)
})

it("is OFF for any non-'true' value", () => {
vi.stubEnv('VITE_CLIENT_SIDE_VOICE_EMBEDDING', '1')
expect(isClientSideVoiceEmbeddingEnabled()).toBe(false)
vi.stubEnv('VITE_CLIENT_SIDE_VOICE_EMBEDDING', 'TRUE')
expect(isClientSideVoiceEmbeddingEnabled()).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from 'vitest'
import {
SpeakerEmbedder,
l2Normalize,
VOICE_EMBEDDING_DIMENSION,
} from '../speakerEmbedder'
import { PARTIAL_N_FRAMES, MEL_N_CHANNELS, TARGET_SAMPLE_RATE } from '../voicePreprocess'

/**
* A fake onnxruntime-web module. The fake model returns a FIXED unit-ish vector
* per call so we can assert the embedder's orchestration: one inference per
* partial, (1,160,40) input shape, mean-of-partials then final L2-norm.
*/
function makeFakeOrt(onRun: (dims: readonly number[]) => Float32Array) {
const runs: Array<readonly number[]> = []
const ort = {
env: { wasm: {} as { wasmPaths?: string } },
Tensor: class {
type: string
data: Float32Array
dims: readonly number[]
constructor(type: string, data: Float32Array, dims: readonly number[]) {
this.type = type
this.data = data
this.dims = dims
}
},
InferenceSession: {
create: vi.fn(async () => ({
inputNames: ['mels'],
outputNames: ['embeds'],
run: vi.fn(async (feeds: Record<string, { dims: readonly number[] }>) => {
const dims = feeds['mels'].dims
runs.push(dims)
return { embeds: { data: onRun(dims) } }
}),
})),
},
}
return { ort, runs }
}

function oneSecondTone(): Float32Array {
const wav = new Float32Array(TARGET_SAMPLE_RATE)
for (let i = 0; i < wav.length; i++) wav[i] = 0.3 * Math.sin((2 * Math.PI * 220 * i) / TARGET_SAMPLE_RATE)
return wav
}

describe('l2Normalize', () => {
it('returns a unit-norm vector', () => {
const out = l2Normalize(new Float32Array([3, 4]))
expect(Math.hypot(out[0], out[1])).toBeCloseTo(1, 6)
})
it('passes a zero vector through without NaN', () => {
const out = l2Normalize(new Float32Array([0, 0]))
expect(out.every((v) => Number.isFinite(v))).toBe(true)
})
})

describe('SpeakerEmbedder.embed', () => {
it('feeds (1,160,40) per partial and returns an L2-normalized 256-d vector', async () => {
// Fixed per-call vector → mean == that vector → normalized.
const fixed = new Float32Array(VOICE_EMBEDDING_DIMENSION)
for (let i = 0; i < fixed.length; i++) fixed[i] = (i % 7) - 3
const { ort, runs } = makeFakeOrt(() => fixed.slice())

// sha256 null → skip model fetch; pass a dummy URL.
const embedder = new SpeakerEmbedder(ort as never, 'dummy://model', undefined, null)
const vec = await embedder.embed(oneSecondTone())

expect(vec.length).toBe(VOICE_EMBEDDING_DIMENSION)
// Result is L2-normalized.
let sumSq = 0
for (const v of vec) sumSq += v * v
expect(Math.sqrt(sumSq)).toBeCloseTo(1, 5)

// At least one inference, each with the (1,160,40) partial shape.
expect(runs.length).toBeGreaterThanOrEqual(1)
for (const dims of runs) {
expect(dims).toEqual([1, PARTIAL_N_FRAMES, MEL_N_CHANNELS])
}
})

it('reuses the ONNX session across calls (loads once)', async () => {
const fixed = new Float32Array(VOICE_EMBEDDING_DIMENSION).fill(0.5)
const { ort } = makeFakeOrt(() => fixed.slice())
const embedder = new SpeakerEmbedder(ort as never, 'dummy://model', undefined, null)

await embedder.embed(oneSecondTone())
await embedder.embed(oneSecondTone())

expect(ort.InferenceSession.create).toHaveBeenCalledTimes(1)
})
})
Loading
Loading