From e393b632fcc0c43e63585f51539c55b97421eaca Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 20:02:56 +0000 Subject: [PATCH] feat(voice): route VOICE verify/enroll to client-side-embedding bio endpoints (audit H3, flag-gated default OFF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPU-less VOICE: accept a browser-computed 256-d Resemblyzer speaker embedding and route it to the new bio /voice/verify-embedding + /voice/enroll-embedding endpoints, so the raw audio never leaves the device and the CPU-only server skips the Resemblyzer forward pass. Mirrors the existing FACE client-embedding wiring. Default OFF (legacy audio path unchanged); reversible via env flag, no redeploy. - ClientSideVoiceEmbeddingPolicy (app.auth.client-side-voice-embedding) — VOICE twin of ClientSideEmbeddingPolicy; SEPARATE class so voice/face are independent kill-switches. Global + per-tenant-canary, same parse/overload semantics. - BiometricServicePort.verifyVoiceEmbedding / enrollVoiceEmbedding + adapter impls POSTing JSON {user_id, embedding[256], tenant_id?, optimize?} to the bio voice embedding routes, with the same fail-closed error mapping as the face methods. - VoiceVerifyMfaStepHandler: when the voice policy is ON for the tenant AND the MFA data carries a precomputed embedding, match via /voice/verify-embedding; else the UNCHANGED legacy voiceData path. entity.User boundary respected (cache user.getId() once, no new entity.User access in application/controller). - BiometricController.enrollVoiceEmbedding — POST /api/v1/biometric/voice/ enroll-embedding/{userId} + VoiceEnrollEmbeddingRequest (256-length-validated), fail-closed when policy OFF, same @PreAuthorize + per-user cap as audio enroll. - Compose passthrough APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING(_TENANTS) in the prod environment: block (explicit block, NOT env_file:) + application(.yml/-prod.yml) defaults (false/empty). SECURITY: embedding carries no audio ⇒ no server replay/liveness check; an embedding VOICE factor MUST be paired with a liveness factor. Rollout: flip this flag ON before the web flag. The web preprocessing port is a documented scaffold (see biometric-processor VOICE_CLIENT_EMBEDDING_SPEC.md) — keep OFF until validated. Tests: ClientSideVoiceEmbeddingPolicyTest, VoiceVerifyMfaStepHandlerTest, +5 adapter HTTP cases, + voice enroll @PreAuthorize sweep, + the WebMvc controller test's new @MockBean. Full suite green: mvn -o test = 1840 run / 0 fail / 0 error / 67 skipped (Testcontainers ITs), JDK 21. ArchUnit UserDomainBoundaryTest green. --- CLAUDE.md | 33 ++++ docker-compose.prod.yml | 13 ++ .../command/VoiceEnrollEmbeddingRequest.java | 54 +++++++ .../port/output/BiometricServicePort.java | 37 +++++ .../ClientSideVoiceEmbeddingPolicy.java | 133 ++++++++++++++++ .../handler/VoiceVerifyMfaStepHandler.java | 61 +++++++- .../controller/BiometricController.java | 69 ++++++++ .../adapter/BiometricServiceAdapter.java | 50 ++++++ src/main/resources/application-prod.yml | 9 ++ src/main/resources/application.yml | 12 ++ .../ClientSideVoiceEmbeddingPolicyTest.java | 134 ++++++++++++++++ .../VoiceVerifyMfaStepHandlerTest.java | 148 ++++++++++++++++++ .../BiometricControllerSecurityTest.java | 27 ++++ .../controller/BiometricControllerTest.java | 1 + .../adapter/BiometricServiceAdapterTest.java | 90 +++++++++++ 15 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fivucsas/identity/application/dto/command/VoiceEnrollEmbeddingRequest.java create mode 100644 src/main/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicy.java create mode 100644 src/test/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicyTest.java create mode 100644 src/test/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandlerTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 7f6bc1c8..a8a6fa10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,39 @@ signal the web login UI reads to switch on the **identifier-first** experience `engineActive=false` keeps the legacy single-screen email+password form, so the UI redesign reverts with the env flag and no web redeploy. +### Client-side embedding kill-switches — FACE + VOICE (default OFF) + +Two independent gates route a precomputed client-computed embedding to the +biometric-processor's embedding endpoints instead of uploading the raw +image/audio (privacy + GPU-less; the raw media never leaves the device). Both +default OFF (legacy server-side path, byte-identical), flip WITHOUT a redeploy, +and support per-tenant canary. **Both MUST be passed via the compose +`environment:` block** (the service uses an explicit block, NOT `env_file:` — a +var only in `.env.prod` is silently dropped; this exact gap once broke the face +flag). + +- **FACE** — `ClientSideEmbeddingPolicy` (`app.auth.client-side-embedding`): + `FaceVerifyMfaStepHandler` + `BiometricController.enrollFaceEmbedding` route a + 512-d Facenet512 vector to bio `/verify-embedding` / `/enroll-embedding`. + Env: `APP_AUTH_CLIENT_SIDE_EMBEDDING(_TENANTS)`. +- **VOICE (audit H3, GPU-less)** — `ClientSideVoiceEmbeddingPolicy` + (`app.auth.client-side-voice-embedding`): `VoiceVerifyMfaStepHandler` (reads + the `embedding` key off the MFA `data` map when ON, else the legacy `voiceData`) + + `BiometricController.enrollVoiceEmbedding` + (`POST /api/v1/biometric/voice/enroll-embedding/{userId}`, + `VoiceEnrollEmbeddingRequest`, 256-length-validated, fail-closed when OFF) route + a 256-d Resemblyzer speaker vector to bio `/voice/verify-embedding` / + `/voice/enroll-embedding`. Env: + `APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING(_TENANTS)`. + +SECURITY: an embedding carries no media, so the bio side cannot run +liveness/replay on it — an embedding FACE/VOICE factor MUST be paired with a +liveness factor in the auth flow. **Rollout ordering:** flip this (identity) flag +ON BEFORE the web `VITE_CLIENT_SIDE_*_EMBEDDING` flag (web-ON + identity-OFF +breaks the factor). The VOICE web preprocessing port is still a documented +scaffold (browser mel+VAD not yet parity-validated — see biometric-processor +`docs/design/VOICE_CLIENT_EMBEDDING_SPEC.md`); keep the voice flag OFF until it is. + ### Operator reality — 2026-05-30 stabilize-&-harden backlog (P1-1 + P1-5, DEPLOYED) - **P1-1 — cross-tenant isolation ITs are now a CI gate (PR #155/#156).** The diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2b9df9a7..0f5499c5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -84,6 +84,19 @@ services: # this (identity) flag ON before web VITE_CLIENT_SIDE_EMBEDDING. APP_AUTH_CLIENT_SIDE_EMBEDDING: ${APP_AUTH_CLIENT_SIDE_EMBEDDING:-false} APP_AUTH_CLIENT_SIDE_EMBEDDING_TENANTS: ${APP_AUTH_CLIENT_SIDE_EMBEDDING_TENANTS:-} + # Client-side VOICE embedding (audit H3, GPU-less). MUST be listed here for + # the SAME reason as the face flag above (explicit environment: block, not + # env_file:). When ON, the browser computes the 256-d Resemblyzer speaker + # vector and the server does ONLY a pgvector cosine compare — the raw audio + # never leaves the device. Default OFF = legacy server-side Resemblyzer + # forward pass on uploaded audio. Reversible via .env.prod + `up -d` recreate + # (no rebuild). Canary one tenant via _TENANTS=. Pair ordering: this + # (identity) flag ON before web VITE_CLIENT_SIDE_VOICE_EMBEDDING. NOTE: the + # browser preprocessing port is a documented scaffold (not yet parity-verified + # — see biometric-processor docs/design/VOICE_CLIENT_EMBEDDING_SPEC.md); keep + # OFF until the client mel+VAD is validated. + APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING: ${APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING:-false} + APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING_TENANTS: ${APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING_TENANTS:-} # NFC serial-only login (2026-06-01). MUST be listed here — the service uses an # explicit `environment:` block, NOT `env_file:`, so a var only in .env.prod is # NOT passed into the container (this exact gap left the flag dead after the diff --git a/src/main/java/com/fivucsas/identity/application/dto/command/VoiceEnrollEmbeddingRequest.java b/src/main/java/com/fivucsas/identity/application/dto/command/VoiceEnrollEmbeddingRequest.java new file mode 100644 index 00000000..47ca6dfc --- /dev/null +++ b/src/main/java/com/fivucsas/identity/application/dto/command/VoiceEnrollEmbeddingRequest.java @@ -0,0 +1,54 @@ +package com.fivucsas.identity.application.dto.command; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * JSON request body for the CLIENT-SIDE-EMBEDDING voice enroll endpoint + * ({@code POST /api/v1/biometric/voice/enroll-embedding/{userId}} — audit H3, + * GPU-less). + * + *

Carries the precomputed 256-dim Resemblyzer speaker embedding the browser + * computed on-device, so the raw audio never leaves the client. This is the + * embedding counterpart of {@code POST /api/v1/biometric/voice/enroll/{userId}} + * (which carries base64 audio).

+ * + *

The embedding MUST be exactly {@value #EMBEDDING_DIM} elements — the + * Resemblyzer output dimensionality. A wrong-length (or empty) vector is rejected + * by bean validation with {@code 400 Bad Request} (the bio side independently + * re-validates to 256, returning {@code 422}).

+ * + * @param embedding the 256-dim client-side speaker embedding (required, exactly + * {@value #EMBEDDING_DIM} elements) + * @param tenantId optional tenant identifier; when null/blank the controller + * derives it from the authenticated principal + * @param optimize re-enroll & optimize: when true and the user already has a + * voiceprint, the bio side FUSES this sample into the existing + * centroid (defaults to false via {@link #optimizeOrDefault()}) + */ +public record VoiceEnrollEmbeddingRequest( + @NotEmpty(message = "embedding is required") + @Size(min = EMBEDDING_DIM, max = EMBEDDING_DIM, + message = "embedding must contain exactly " + EMBEDDING_DIM + " elements") + List embedding, + + @JsonProperty("tenant_id") + String tenantId, + + Boolean optimize) { + + /** + * Resemblyzer speaker-embedding output dimensionality. The bio + * {@code /voice/enroll-embedding} store is a 256-dim pgvector column, so a + * vector of any other length cannot be a valid template. + */ + public static final int EMBEDDING_DIM = 256; + + /** Null-safe optimize flag (absent ⇒ false, the plain append/average path). */ + public boolean optimizeOrDefault() { + return Boolean.TRUE.equals(optimize); + } +} diff --git a/src/main/java/com/fivucsas/identity/application/port/output/BiometricServicePort.java b/src/main/java/com/fivucsas/identity/application/port/output/BiometricServicePort.java index e77f03c1..68bf605a 100644 --- a/src/main/java/com/fivucsas/identity/application/port/output/BiometricServicePort.java +++ b/src/main/java/com/fivucsas/identity/application/port/output/BiometricServicePort.java @@ -166,6 +166,43 @@ default Map enrollVoice(UUID userId, String voiceData) { */ Map verifyVoice(UUID userId, String voiceData); + /** + * Verifies a user's voice from a CLIENT-SIDE precomputed embedding (256-dim + * Resemblyzer speaker vector) instead of audio — audit-H3 GPU-less path, + * where the embedding is computed in the browser so the raw audio never + * leaves the device. Routes to the biometric-processor's + * {@code POST /voice/verify-embedding} endpoint. + * + *

Gated by {@code ClientSideVoiceEmbeddingPolicy} at the call site; the + * legacy audio {@link #verifyVoice} path is unchanged. The embedding alone + * carries no audio, so the server-side replay/liveness check cannot run on + * it — an embedding VOICE factor MUST be paired with a liveness factor in the + * auth flow. + * + * @param tenantId optional tenant identifier (currently unused by the bio + * voice routes, which are user-scoped; forwarded for parity + * + forward-compat, null/blank omitted) + * @param userId the user ID to verify against + * @param embedding the 256-dim client-side speaker embedding (list of floats) + * @return Map containing verification response data (e.g. {@code verified}) + */ + Map verifyVoiceEmbedding(String tenantId, UUID userId, List embedding); + + /** + * Enrolls a user's voice from a CLIENT-SIDE precomputed embedding (256-dim + * Resemblyzer speaker vector) instead of audio — audit-H3 GPU-less path. + * Routes to the biometric-processor's {@code POST /voice/enroll-embedding} + * endpoint. Mirrors {@link #enrollVoice(UUID, String, boolean)} but with the + * embedding already computed off-device. + * + * @param tenantId optional tenant identifier (see {@link #verifyVoiceEmbedding}) + * @param userId the user ID to enroll + * @param embedding the 256-dim client-side speaker embedding (list of floats) + * @param optimize re-enroll & optimize: fuse into the existing centroid + * @return Map containing enrollment response data (e.g. {@code success}) + */ + Map enrollVoiceEmbedding(String tenantId, UUID userId, List embedding, boolean optimize); + /** * Deletes a user's enrolled face biometric data. * diff --git a/src/main/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicy.java b/src/main/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicy.java new file mode 100644 index 00000000..1955b82e --- /dev/null +++ b/src/main/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicy.java @@ -0,0 +1,133 @@ +package com.fivucsas.identity.application.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * Feature gate for the CLIENT-SIDE-EMBEDDING voice path (audit H3 — + * GPU-less: the 256-d Resemblyzer speaker embedding is computed in the browser so + * the raw audio never leaves the device, and the CPU-only server skips the + * Resemblyzer forward pass). + * + *

This is the VOICE twin of {@link ClientSideEmbeddingPolicy} (face). It is a + * separate class on purpose so the voice and face rollouts are independent + * kill-switches — the voice client port is a documented scaffold whose browser + * preprocessing is not yet validated to parity, so it must be flippable without + * touching the (production-ready) face path. + * + *

When this policy is ON and a VOICE step / enrollment payload carries a + * precomputed {@code embedding} (256 floats) instead of {@code voiceData}, + * Identity Core forwards to the biometric-processor's + * {@code POST /voice/verify-embedding} / {@code POST /voice/enroll-embedding} + * endpoints. When OFF (the default) the legacy audio path + * ({@code /voice/verify} / {@code /voice/enroll}) is used and behaviour is + * byte-identical to before — so a bad rollout reverts by flipping the env flag, + * WITHOUT a redeploy. + * + *

    + *
  • OFF (default) — legacy audio path for every tenant.
  • + *
  • Global ON ({@code app.auth.client-side-voice-embedding=true}) — the + * embedding path applies to every tenant whose payload carries an + * embedding.
  • + *
  • Per-tenant canary + * ({@code app.auth.client-side-voice-embedding-tenants=,}) — the + * embedding path applies ONLY to the listed tenants even when the global + * flag is false, so one tenant can be canaried in production before the + * master switch is flipped.
  • + *
+ * + *

Both knobs are plain {@code @Value} env-backed properties; no DB write / + * migration is needed to flip them, keeping the kill-switch fast and the blast + * radius minimal. + * + *

SECURITY: the embedding path carries NO audio, therefore the + * biometric-processor cannot run its spectral-fingerprint replay check or any + * live-capture proof. An embedding-only VOICE factor MUST be paired with a + * separate liveness factor in the auth flow; this policy only governs WHERE the + * embedding is matched. + */ +@Component +@Slf4j +public class ClientSideVoiceEmbeddingPolicy { + + private final boolean globallyEnabled; + private final Set canaryTenantIds; + + public ClientSideVoiceEmbeddingPolicy( + @Value("${app.auth.client-side-voice-embedding:false}") boolean globallyEnabled, + @Value("${app.auth.client-side-voice-embedding-tenants:}") String canaryTenants) { + this.globallyEnabled = globallyEnabled; + this.canaryTenantIds = parseTenantIds(canaryTenants); + log.info("Client-side-embedding voice path: globallyEnabled={}, canaryTenantCount={}", + globallyEnabled, canaryTenantIds.size()); + } + + /** + * The GLOBAL master switch state, independent of any tenant. The default + * (false) means the legacy audio path is used everywhere. + */ + public boolean isEnabled() { + return globallyEnabled; + } + + /** + * True when the client-side-embedding path should drive VOICE for + * {@code tenantId}. Defaults to false (legacy audio path) for every tenant + * unless the global flag is on or the tenant is explicitly canaried. + */ + public boolean isEnabledForTenant(UUID tenantId) { + if (globallyEnabled) { + return true; + } + return tenantId != null && canaryTenantIds.contains(tenantId); + } + + /** + * String-tenant overload for call sites that hold the (optional, possibly + * null/non-UUID) tenant id as a {@code String}. Uses the GLOBAL master switch, + * falling back to the per-tenant canary list only when the id parses to a + * UUID. A null / blank / non-UUID tenant id is enabled ONLY under the global + * switch, never via the canary list (a malformed id cannot match a canary + * entry, and must not silently widen the rollout). + */ + public boolean isEnabledForTenant(String tenantId) { + if (globallyEnabled) { + return true; + } + if (tenantId == null || tenantId.isBlank()) { + return false; + } + try { + return isEnabledForTenant(UUID.fromString(tenantId.trim())); + } catch (IllegalArgumentException e) { + return false; + } + } + + private static Set parseTenantIds(String csv) { + Set ids = new LinkedHashSet<>(); + if (csv == null || csv.isBlank()) { + return ids; + } + for (String raw : csv.split(",")) { + String token = raw.trim(); + if (token.isEmpty()) { + continue; + } + try { + ids.add(UUID.fromString(token.toLowerCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + // A typo in the canary list must NOT crash startup or silently + // widen the rollout — skip the bad token with a warning. + log.warn("Ignoring invalid tenant UUID in app.auth.client-side-voice-embedding-tenants: '{}'", token); + } + } + return ids; + } +} diff --git a/src/main/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandler.java b/src/main/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandler.java index e423fc05..d9e89bd0 100644 --- a/src/main/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandler.java +++ b/src/main/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandler.java @@ -1,6 +1,7 @@ package com.fivucsas.identity.application.service.mfa.handler; import com.fivucsas.identity.application.port.output.BiometricServicePort; +import com.fivucsas.identity.application.service.ClientSideVoiceEmbeddingPolicy; import com.fivucsas.identity.application.service.mfa.MfaStepResult; import com.fivucsas.identity.application.service.mfa.VerifyMfaStepHandler; import com.fivucsas.identity.domain.model.auth.AuthMethodType; @@ -9,6 +10,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; import java.util.Map; @Component @@ -16,6 +19,9 @@ public class VoiceVerifyMfaStepHandler implements VerifyMfaStepHandler { private final BiometricServicePort biometricService; + // Audit H3 (GPU-less voice): gates the client-side-embedding path. Default + // OFF means the legacy audio path below is byte-identical to before. + private final ClientSideVoiceEmbeddingPolicy clientSideVoiceEmbeddingPolicy; @Override public AuthMethodType supports() { @@ -24,13 +30,66 @@ public AuthMethodType supports() { @Override public MfaStepResult verify(MfaSession session, User user, Map data) { + // Cache user-id once to keep the entity.User boundary surface + // (ArchUnit UserDomainBoundaryTest) at one call site per method — + // matches FaceVerifyMfaStepHandler. + java.util.UUID userId = user.getId(); + + // ROUTING (audit H3): when the client-side-voice-embedding path is ON for + // this tenant AND the payload carries a precomputed 256-d speaker + // embedding (the raw audio never left the device), match against the bio + // /voice/verify-embedding endpoint. Otherwise fall through to the + // UNCHANGED legacy audio path. Default OFF (policy + no embedding) ⇒ + // identical to the prior behaviour. NOTE: an embedding carries no audio, + // so the bio processor cannot run its replay/liveness check on it — an + // embedding VOICE factor MUST be paired with a liveness factor in the + // flow; this handler only routes the match. + List embedding = extractEmbedding(data.get("embedding")); + boolean embeddingPathEnabled = + clientSideVoiceEmbeddingPolicy.isEnabledForTenant(session.getTenantId()); + if (embeddingPathEnabled && embedding != null && !embedding.isEmpty()) { + String tenantId = session.getTenantId() != null ? session.getTenantId().toString() : null; + Map embeddingResult = + biometricService.verifyVoiceEmbedding(tenantId, userId, embedding); + return Boolean.TRUE.equals(embeddingResult.get("verified")) + ? MfaStepResult.ok() + : MfaStepResult.fail(); + } + + // --- Legacy audio path (UNCHANGED) --- String voiceData = (String) data.get("voiceData"); if (voiceData == null || voiceData.isBlank()) { return MfaStepResult.fail(); } - Map result = biometricService.verifyVoice(user.getId(), voiceData); + Map result = biometricService.verifyVoice(userId, voiceData); return Boolean.TRUE.equals(result.get("verified")) ? MfaStepResult.ok() : MfaStepResult.fail(); } + + /** + * Coerces the {@code embedding} payload field (a JSON array deserialized into + * a {@code List} of {@link Number}, or absent) into a {@code List}. + * Returns null when the value is absent or not a list; a non-numeric element + * makes the whole embedding invalid (null) so a malformed payload falls back + * to the legacy audio path rather than sending garbage to the bio service. + * (Length validation to 256 is enforced bio-side, returning HTTP 422.) + */ + private static List extractEmbedding(Object raw) { + if (!(raw instanceof List list)) { + return null; + } + if (list.isEmpty()) { + return List.of(); + } + List out = new ArrayList<>(list.size()); + for (Object el : list) { + if (el instanceof Number n) { + out.add(n.doubleValue()); + } else { + return null; // malformed element → not a usable embedding + } + } + return out; + } } diff --git a/src/main/java/com/fivucsas/identity/controller/BiometricController.java b/src/main/java/com/fivucsas/identity/controller/BiometricController.java index 0c125d56..f121c153 100644 --- a/src/main/java/com/fivucsas/identity/controller/BiometricController.java +++ b/src/main/java/com/fivucsas/identity/controller/BiometricController.java @@ -8,6 +8,7 @@ import com.fivucsas.identity.application.dto.command.StepUpChallengeRequest; import com.fivucsas.identity.application.dto.command.StepUpVerifyRequest; import com.fivucsas.identity.application.dto.command.VerifyBiometricCommand; +import com.fivucsas.identity.application.dto.command.VoiceEnrollEmbeddingRequest; import com.fivucsas.identity.application.dto.response.BiometricResponse; import com.fivucsas.identity.application.dto.response.DeviceResponse; import com.fivucsas.identity.application.dto.response.StepUpChallengeResponse; @@ -18,6 +19,7 @@ import com.fivucsas.identity.application.port.input.VerifyBiometricUseCase; import com.fivucsas.identity.application.port.output.BiometricServicePort; import com.fivucsas.identity.application.service.ClientSideEmbeddingPolicy; +import com.fivucsas.identity.application.service.ClientSideVoiceEmbeddingPolicy; import com.fivucsas.identity.application.service.EnrollBiometricService; import com.fivucsas.identity.domain.exception.UnauthorizedException; import com.fivucsas.identity.domain.model.auth.AuthMethodType; @@ -67,6 +69,10 @@ public class BiometricController { // path, so when the policy is OFF for the tenant the request is rejected // rather than silently falling through (there is no image to legacy-enroll). private final ClientSideEmbeddingPolicy clientSideEmbeddingPolicy; + // Audit H3 (GPU-less voice): gates the JSON client-side-voice-embedding enroll + // endpoint, same fail-closed contract as the face policy above (no audio to + // legacy-enroll). Separate policy ⇒ independent voice/face kill-switches. + private final ClientSideVoiceEmbeddingPolicy clientSideVoiceEmbeddingPolicy; // #11 (2026-05-21): cap inbound voice payloads. Previously the voice-enroll // path only null/blank-checked voiceData, so an oversized base64 blob would @@ -311,6 +317,69 @@ public ResponseEntity enrollVoice( .build()); } + /** + * CLIENT-SIDE-EMBEDDING voice enroll (audit H3, GPU-less). Stores the + * browser-computed 256-d Resemblyzer speaker embedding so the raw audio never + * leaves the device; routes to bio {@code POST /voice/enroll-embedding}. + * + *

Fail-closed (same contract as the face {@code /enroll-embedding}): this + * endpoint serves ONLY the embedding path (no audio to fall back to), so it is + * rejected unless {@link ClientSideVoiceEmbeddingPolicy} is ON for the tenant. + * The 256-length validation happens at the DTO (400) before the flag check, so + * a malformed payload is rejected regardless of the flag.

+ */ + @PostMapping(value = "/api/v1/biometric/voice/enroll-embedding/{userId}", + consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Enroll a client-computed 256-dim voice embedding (privacy: audio stays on device)") + @PreAuthorize("hasAuthority('biometric:enroll') or @userSecurityService.isCurrentUser(#userId)") + public ResponseEntity enrollVoiceEmbedding( + @PathVariable UUID userId, + @Valid @RequestBody VoiceEnrollEmbeddingRequest request) { + + // Tenant resolution mirrors the face enroll-embedding path: trust the + // authenticated principal's tenant, honour an explicit body tenant_id only + // as the bio scoping hint. + String tenantId = (request.tenantId() != null && !request.tenantId().isBlank()) + ? request.tenantId() + : resolveCurrentTenantId(); + + // Fail-closed: rejected unless the voice policy is ON for the tenant. + if (!clientSideVoiceEmbeddingPolicy.isEnabledForTenant(tenantId)) { + log.warn("Rejected client-embedding voice enroll for user {} (tenant {}): policy OFF", userId, tenantId); + throw new UnauthorizedException("Client-side-voice-embedding enrollment is not enabled for this tenant"); + } + + // Per-user voice-enrollment count cap (same as the audio enroll path). + long existingVoiceEnrollments = manageEnrollmentUseCase.getUserEnrollments(userId).stream() + .filter(e -> e.authMethodType() == AuthMethodType.VOICE) + .count(); + if (existingVoiceEnrollments >= maxVoiceEnrollmentsPerUser) { + log.warn("Voice embedding enrollment rejected for user {} — {} enrollments at/over cap {}", + userId, existingVoiceEnrollments, maxVoiceEnrollmentsPerUser); + return ResponseEntity.status(HttpStatus.CONFLICT).body( + BiometricVerificationResponse.builder() + .verified(false).confidence(0.0) + .message("Maximum number of voice enrollments reached").build()); + } + + log.info("Client-embedding voice enrollment request for user: {} (tenant: {})", userId, tenantId); + + Map result = biometricServicePort.enrollVoiceEmbedding( + tenantId, userId, request.embedding(), request.optimizeOrDefault()); + boolean success = Boolean.TRUE.equals(result.get("success")) + || "true".equalsIgnoreCase(String.valueOf(result.get("success"))); + + if (success) { + recordEnrollmentScores(userId, AuthMethodType.VOICE, result); + } + + return ResponseEntity.ok(BiometricVerificationResponse.builder() + .verified(success) + .confidence(success ? 1.0 : 0.0) + .message(success ? "Voice enrolled successfully" : String.valueOf(result.get("message"))) + .build()); + } + @PostMapping("/api/v1/biometric/voice/verify/{userId}") @Operation(summary = "Verify user's voice against enrolled biometric data") @PreAuthorize("hasAuthority('biometric:verify') or @userSecurityService.isCurrentUser(#userId)") diff --git a/src/main/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapter.java b/src/main/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapter.java index ecf8c80d..a6c2aa62 100644 --- a/src/main/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapter.java +++ b/src/main/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapter.java @@ -266,6 +266,56 @@ public Map verifyVoice(UUID userId, String voiceData) { } } + @Override + public Map verifyVoiceEmbedding(String tenantId, UUID userId, List embedding) { + log.info("Calling biometric service to verify voice embedding for user: {} (tenant: {})", userId, tenantId); + try { + Map response = postJsonObject("/voice/verify-embedding", + embeddingBody(tenantId, userId, embedding)); + log.info("Voice embedding verification response received for user: {}", userId); + return response; + } catch (HttpClientErrorException e) { + log.warn("Biometric service client error for voice embedding verification: {} {}", e.getStatusCode(), e.getMessage()); + return errorResponse("Verification rejected: " + e.getResponseBodyAsString()); + } catch (HttpServerErrorException e) { + log.error("Biometric service server error for voice embedding verification: {} {}", e.getStatusCode(), e.getMessage()); + return errorResponse("Biometric service error, please retry"); + } catch (ResourceAccessException e) { + log.error("Biometric service unreachable for voice embedding verification: {}", e.getMessage()); + return errorResponse("Voice verification service unavailable"); + } catch (RestClientException e) { + log.error("Biometric service communication error for voice embedding verification: {}", e.getMessage()); + return errorResponse("Voice verification service error"); + } + } + + @Override + public Map enrollVoiceEmbedding(String tenantId, UUID userId, List embedding, boolean optimize) { + log.info("Calling biometric service to enroll voice embedding for user: {} (tenant: {}, optimize: {})", + userId, tenantId, optimize); + try { + // postJsonObject so `embedding` (array) + `optimize` (bool) serialize + // as real JSON types the bio VoiceEnrollEmbeddingRequest expects. + Map body = embeddingBody(tenantId, userId, embedding); + body.put("optimize", optimize); + Map response = postJsonObject("/voice/enroll-embedding", body); + log.info("Voice embedding enrollment response received for user: {}", userId); + return response; + } catch (HttpClientErrorException e) { + log.warn("Biometric service client error for voice embedding enrollment: {} {}", e.getStatusCode(), e.getMessage()); + return errorResponse("Enrollment rejected: " + e.getResponseBodyAsString()); + } catch (HttpServerErrorException e) { + log.error("Biometric service server error for voice embedding enrollment: {} {}", e.getStatusCode(), e.getMessage()); + return errorResponse("Biometric service error, please retry"); + } catch (ResourceAccessException e) { + log.error("Biometric service unreachable for voice embedding enrollment: {}", e.getMessage()); + return errorResponse("Voice enrollment service unavailable"); + } catch (RestClientException e) { + log.error("Biometric service communication error for voice embedding enrollment: {}", e.getMessage()); + return errorResponse("Voice enrollment service error"); + } + } + @Override public Map deleteFace(UUID userId) { log.info("Calling biometric service to delete face data for user: {}", userId); diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 3ff78ee0..45548f4e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -117,6 +117,15 @@ app: # so it MUST be paired with a liveness factor (puzzle/passive) in the flow. client-side-embedding: ${APP_AUTH_CLIENT_SIDE_EMBEDDING:false} client-side-embedding-tenants: ${APP_AUTH_CLIENT_SIDE_EMBEDDING_TENANTS:} + # Client-side-embedding VOICE path (audit H3, GPU-less). Default OFF = legacy + # audio path (/voice/verify, /voice/enroll). KILL-SWITCH / CANARY — no redeploy: + # APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING=true → embedding, ALL tenants + # APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING_TENANTS= → canary tenant(s) + # Embedding-only VOICE carries no audio (no server-side replay/liveness check), + # so it MUST be paired with a liveness factor in the flow. Keep OFF until the + # browser preprocessing port is parity-validated (bio VOICE_CLIENT_EMBEDDING_SPEC.md). + client-side-voice-embedding: ${APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING:false} + client-side-voice-embedding-tenants: ${APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING_TENANTS:} # Puzzle layer (sub-project B). Default OFF = PUZZLE absent from catalog. # KILL-SWITCH / CANARY — no redeploy to flip: # APP_AUTH_PUZZLE_LAYER=true → surface PUZZLE for ALL tenants diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 529fc7f1..cda0f3dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -103,6 +103,18 @@ app: # get the embedding path even while the master switch above is false. Invalid # UUIDs are skipped with a warn (never widens the rollout / crashes startup). client-side-embedding-tenants: ${APP_AUTH_CLIENT_SIDE_EMBEDDING_TENANTS:} + # CLIENT-SIDE VOICE embedding (audit H3, GPU-less). VOICE twin of the face flag + # above. When true, a VOICE step / enroll payload carrying a precomputed 256-d + # Resemblyzer speaker embedding is matched via the biometric-processor's + # /voice/verify-embedding and /voice/enroll-embedding endpoints (the raw audio + # never leaves the device). Default false = legacy audio path (/voice/verify, + # /voice/enroll), byte-identical to before. SECURITY: no audio → no server-side + # replay/liveness check, so an embedding VOICE factor MUST be paired with a + # liveness factor in the flow. Separate from the face flag = independent + # kill-switches. + client-side-voice-embedding: ${APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING:false} + # Per-tenant CANARY for the voice embedding path (comma-separated tenant UUIDs). + client-side-voice-embedding-tenants: ${APP_AUTH_CLIENT_SIDE_VOICE_EMBEDDING_TENANTS:} # PUZZLE liveness layer (sub-project B, Phase 1). When true, PUZZLE is # surfaced as a selectable login method in the auth-methods catalog and # may be placed in an auth flow as a liveness step. Default false = PUZZLE diff --git a/src/test/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicyTest.java b/src/test/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicyTest.java new file mode 100644 index 00000000..1e2c7854 --- /dev/null +++ b/src/test/java/com/fivucsas/identity/application/service/ClientSideVoiceEmbeddingPolicyTest.java @@ -0,0 +1,134 @@ +package com.fivucsas.identity.application.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ClientSideVoiceEmbeddingPolicy} — the kill-switch that + * gates the client-side-embedding VOICE path (audit H3, GPU-less). Mirrors + * {@link ClientSideEmbeddingPolicyTest}: default-OFF (legacy audio path for every + * tenant), global master-switch ON, and the per-tenant canary list. + */ +@DisplayName("ClientSideVoiceEmbeddingPolicy") +class ClientSideVoiceEmbeddingPolicyTest { + + private static final UUID TENANT_A = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID TENANT_B = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + @Nested + @DisplayName("default OFF (flag false, no canary)") + class DefaultOff { + + private final ClientSideVoiceEmbeddingPolicy policy = + new ClientSideVoiceEmbeddingPolicy(false, ""); + + @Test + @DisplayName("isEnabled() is false") + void globalOff() { + assertThat(policy.isEnabled()).isFalse(); + } + + @Test + @DisplayName("isEnabledForTenant() is false for any tenant") + void perTenantOff() { + assertThat(policy.isEnabledForTenant(TENANT_A)).isFalse(); + assertThat(policy.isEnabledForTenant(TENANT_B)).isFalse(); + assertThat(policy.isEnabledForTenant((UUID) null)).isFalse(); + } + } + + @Nested + @DisplayName("global ON (master switch true)") + class GlobalOn { + + private final ClientSideVoiceEmbeddingPolicy policy = + new ClientSideVoiceEmbeddingPolicy(true, ""); + + @Test + @DisplayName("isEnabled() is true") + void globalOn() { + assertThat(policy.isEnabled()).isTrue(); + } + + @Test + @DisplayName("isEnabledForTenant() is true for every tenant") + void perTenantOn() { + assertThat(policy.isEnabledForTenant(TENANT_A)).isTrue(); + assertThat(policy.isEnabledForTenant(TENANT_B)).isTrue(); + } + } + + @Nested + @DisplayName("per-tenant canary (master switch false)") + class Canary { + + private final ClientSideVoiceEmbeddingPolicy policy = + new ClientSideVoiceEmbeddingPolicy(false, TENANT_A.toString()); + + @Test + @DisplayName("master switch stays false") + void masterOff() { + assertThat(policy.isEnabled()).isFalse(); + } + + @Test + @DisplayName("the listed tenant is enabled, others are not") + void onlyListedTenant() { + assertThat(policy.isEnabledForTenant(TENANT_A)).isTrue(); + assertThat(policy.isEnabledForTenant(TENANT_B)).isFalse(); + assertThat(policy.isEnabledForTenant((UUID) null)).isFalse(); + } + } + + @Test + @DisplayName("canary list parsing tolerates whitespace, casing and an invalid token") + void canaryListParsing() { + ClientSideVoiceEmbeddingPolicy policy = new ClientSideVoiceEmbeddingPolicy( + false, " " + TENANT_A.toString().toUpperCase() + " , not-a-uuid , " + TENANT_B + " "); + + assertThat(policy.isEnabledForTenant(TENANT_A)).isTrue(); + assertThat(policy.isEnabledForTenant(TENANT_B)).isTrue(); + assertThat(policy.isEnabled()).isFalse(); + } + + @Nested + @DisplayName("String-tenant overload (enroll/verify command shape)") + class StringOverload { + + @Test + @DisplayName("global ON → true for any String (valid UUID, blank, or non-UUID)") + void globalOnEnablesEverything() { + ClientSideVoiceEmbeddingPolicy policy = new ClientSideVoiceEmbeddingPolicy(true, ""); + assertThat(policy.isEnabledForTenant(TENANT_A.toString())).isTrue(); + assertThat(policy.isEnabledForTenant((String) null)).isTrue(); + assertThat(policy.isEnabledForTenant("")).isTrue(); + assertThat(policy.isEnabledForTenant("not-a-uuid")).isTrue(); + } + + @Test + @DisplayName("canary (master OFF) → only the listed UUID String, never null/blank/non-UUID") + void canaryOnlyForListedUuidString() { + ClientSideVoiceEmbeddingPolicy policy = + new ClientSideVoiceEmbeddingPolicy(false, TENANT_A.toString()); + assertThat(policy.isEnabledForTenant(TENANT_A.toString())).isTrue(); + assertThat(policy.isEnabledForTenant(" " + TENANT_A.toString().toUpperCase() + " ")).isTrue(); + assertThat(policy.isEnabledForTenant(TENANT_B.toString())).isFalse(); + assertThat(policy.isEnabledForTenant((String) null)).isFalse(); + assertThat(policy.isEnabledForTenant("")).isFalse(); + assertThat(policy.isEnabledForTenant("not-a-uuid")).isFalse(); + } + + @Test + @DisplayName("default OFF → false for any String") + void defaultOffEverythingFalse() { + ClientSideVoiceEmbeddingPolicy policy = new ClientSideVoiceEmbeddingPolicy(false, ""); + assertThat(policy.isEnabledForTenant(TENANT_A.toString())).isFalse(); + assertThat(policy.isEnabledForTenant((String) null)).isFalse(); + } + } +} diff --git a/src/test/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandlerTest.java b/src/test/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandlerTest.java new file mode 100644 index 00000000..3fc764b2 --- /dev/null +++ b/src/test/java/com/fivucsas/identity/application/service/mfa/handler/VoiceVerifyMfaStepHandlerTest.java @@ -0,0 +1,148 @@ +package com.fivucsas.identity.application.service.mfa.handler; + +import com.fivucsas.identity.application.port.output.BiometricServicePort; +import com.fivucsas.identity.application.service.ClientSideVoiceEmbeddingPolicy; +import com.fivucsas.identity.application.service.mfa.MfaStepResult; +import com.fivucsas.identity.entity.MfaSession; +import com.fivucsas.identity.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Routing tests for {@link VoiceVerifyMfaStepHandler} across both + * {@link ClientSideVoiceEmbeddingPolicy} states (audit H3, GPU-less voice). + * + *
    + *
  • policy OFF → always the legacy audio path ({@code verifyVoice}), even if + * an embedding is present (byte-identical legacy behaviour);
  • + *
  • policy ON + embedding present → the embedding path + * ({@code verifyVoiceEmbedding});
  • + *
  • policy ON but audio (no embedding) → still the legacy audio path.
  • + *
+ */ +@DisplayName("VoiceVerifyMfaStepHandler — audio vs embedding routing") +class VoiceVerifyMfaStepHandlerTest { + + private static final UUID USER_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final UUID TENANT_ID = UUID.fromString("99999999-8888-7777-6666-555555555555"); + private static final String VOICE_DATA = "data:audio/wav;base64,ZmFrZS13YXY="; + private static final List EMBEDDING = List.of(0.1, 0.2, 0.3); + + private BiometricServicePort bio; + private MfaSession session; + private User user; + + @BeforeEach + void setUp() { + bio = mock(BiometricServicePort.class); + session = mock(MfaSession.class); + user = mock(User.class); + when(user.getId()).thenReturn(USER_ID); + when(session.getTenantId()).thenReturn(TENANT_ID); + } + + private VoiceVerifyMfaStepHandler handlerWithPolicy(boolean enabled) { + ClientSideVoiceEmbeddingPolicy policy = + new ClientSideVoiceEmbeddingPolicy(enabled, ""); + return new VoiceVerifyMfaStepHandler(bio, policy); + } + + @Test + @DisplayName("policy OFF + embedding present → legacy verifyVoice (audio), embedding ignored") + void policyOff_embeddingPresent_usesAudioPath() { + when(bio.verifyVoice(eq(USER_ID), eq(VOICE_DATA))).thenReturn(Map.of("verified", true)); + VoiceVerifyMfaStepHandler handler = handlerWithPolicy(false); + + Map data = Map.of("voiceData", VOICE_DATA, "embedding", EMBEDDING); + MfaStepResult result = handler.verify(session, user, data); + + assertThat(result.valid()).isTrue(); + verify(bio, times(1)).verifyVoice(eq(USER_ID), eq(VOICE_DATA)); + verify(bio, never()).verifyVoiceEmbedding(any(), any(), any()); + } + + @Test + @DisplayName("policy ON + embedding present → verifyVoiceEmbedding (embedding path)") + void policyOn_embeddingPresent_usesEmbeddingPath() { + when(bio.verifyVoiceEmbedding(eq(TENANT_ID.toString()), eq(USER_ID), eq(EMBEDDING))) + .thenReturn(Map.of("verified", true)); + VoiceVerifyMfaStepHandler handler = handlerWithPolicy(true); + + // No "voiceData" key at all — the browser computed the embedding on-device. + Map data = Map.of("embedding", EMBEDDING); + MfaStepResult result = handler.verify(session, user, data); + + assertThat(result.valid()).isTrue(); + verify(bio, times(1)).verifyVoiceEmbedding(eq(TENANT_ID.toString()), eq(USER_ID), eq(EMBEDDING)); + verify(bio, never()).verifyVoice(any(), any()); + } + + @Test + @DisplayName("policy ON but audio only (no embedding) → legacy verifyVoice") + void policyOn_audioOnly_usesAudioPath() { + when(bio.verifyVoice(eq(USER_ID), eq(VOICE_DATA))).thenReturn(Map.of("verified", true)); + VoiceVerifyMfaStepHandler handler = handlerWithPolicy(true); + + Map data = Map.of("voiceData", VOICE_DATA); + MfaStepResult result = handler.verify(session, user, data); + + assertThat(result.valid()).isTrue(); + verify(bio, times(1)).verifyVoice(eq(USER_ID), eq(VOICE_DATA)); + verify(bio, never()).verifyVoiceEmbedding(any(), any(), any()); + } + + @Test + @DisplayName("embedding path: server verified=false → fail") + void policyOn_embeddingPath_serverRejects_fails() { + when(bio.verifyVoiceEmbedding(any(), eq(USER_ID), any())) + .thenReturn(Map.of("verified", false)); + VoiceVerifyMfaStepHandler handler = handlerWithPolicy(true); + + MfaStepResult result = handler.verify(session, user, Map.of("embedding", EMBEDDING)); + + assertThat(result.valid()).isFalse(); + } + + @Test + @DisplayName("policy ON + neither audio nor embedding → fail, no bio call") + void policyOn_noPayload_fails() { + VoiceVerifyMfaStepHandler handler = handlerWithPolicy(true); + + MfaStepResult result = handler.verify(session, user, Map.of()); + + assertThat(result.valid()).isFalse(); + verifyNoInteractions(bio); + } + + @Test + @DisplayName("policy ON + empty embedding list + no audio → fail, no bio call") + void policyOn_emptyEmbedding_fails() { + VoiceVerifyMfaStepHandler handler = handlerWithPolicy(true); + + MfaStepResult result = handler.verify(session, user, Map.of("embedding", List.of())); + + assertThat(result.valid()).isFalse(); + verifyNoInteractions(bio); + } + + @Test + @DisplayName("supports() reports VOICE") + void supportsVoice() { + assertThat(handlerWithPolicy(false).supports().name()).isEqualTo("VOICE"); + } +} diff --git a/src/test/java/com/fivucsas/identity/controller/BiometricControllerSecurityTest.java b/src/test/java/com/fivucsas/identity/controller/BiometricControllerSecurityTest.java index 849f6a34..0908c274 100644 --- a/src/test/java/com/fivucsas/identity/controller/BiometricControllerSecurityTest.java +++ b/src/test/java/com/fivucsas/identity/controller/BiometricControllerSecurityTest.java @@ -61,6 +61,33 @@ void jsonEnroll_isGuardedIdenticallyToMultipartEnroll() { .contains(" or "); } + @Test + @DisplayName("enrollVoiceEmbedding (JSON) must carry the SAME @PreAuthorize as the audio enrollVoice") + void jsonVoiceEnroll_isGuardedIdenticallyToAudioEnroll() { + Method json = findMethod("enrollVoiceEmbedding"); + Method audio = findMethod("enrollVoice"); + + PreAuthorize jsonAnn = json.getAnnotation(PreAuthorize.class); + PreAuthorize audioAnn = audio.getAnnotation(PreAuthorize.class); + + assertThat(jsonAnn) + .as("enrollVoiceEmbedding must carry @PreAuthorize") + .isNotNull(); + assertThat(audioAnn) + .as("enrollVoice must carry @PreAuthorize (baseline)") + .isNotNull(); + + // The client-embedding voice enroll is NEITHER stricter nor looser than the + // audio enroll — same biometric:enroll permission OR subject-user ownership. + assertThat(jsonAnn.value()) + .as("JSON voice enroll @PreAuthorize must equal the audio enroll's, never loosened") + .isEqualTo(audioAnn.value()); + assertThat(jsonAnn.value()) + .contains("hasAuthority('biometric:enroll')") + .contains("@userSecurityService.isCurrentUser(#userId)") + .contains(" or "); + } + private static Method findMethod(String name) { for (Method m : BiometricController.class.getDeclaredMethods()) { if (m.getName().equals(name) && m.isAnnotationPresent(PostMapping.class)) { diff --git a/src/test/java/com/fivucsas/identity/controller/BiometricControllerTest.java b/src/test/java/com/fivucsas/identity/controller/BiometricControllerTest.java index 5b808e2f..08eb8976 100644 --- a/src/test/java/com/fivucsas/identity/controller/BiometricControllerTest.java +++ b/src/test/java/com/fivucsas/identity/controller/BiometricControllerTest.java @@ -61,6 +61,7 @@ class BiometricControllerTest { @MockBean private com.fivucsas.identity.application.port.input.ManageEnrollmentUseCase manageEnrollmentUseCase; @MockBean private RbacAuthorizationService rbacService; @MockBean private com.fivucsas.identity.application.service.ClientSideEmbeddingPolicy clientSideEmbeddingPolicy; + @MockBean private com.fivucsas.identity.application.service.ClientSideVoiceEmbeddingPolicy clientSideVoiceEmbeddingPolicy; // Security and infrastructure beans @MockBean private TenantRepository tenantRepository; diff --git a/src/test/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapterTest.java b/src/test/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapterTest.java index d4503317..f79b9267 100644 --- a/src/test/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapterTest.java +++ b/src/test/java/com/fivucsas/identity/infrastructure/adapter/BiometricServiceAdapterTest.java @@ -462,6 +462,96 @@ void enrollEmbedding_unreachable_failsClosed() { mockServer.verify(); } + // --- client-side VOICE embedding (audit H3, GPU-less) --- + + @Test + @DisplayName("verifyVoiceEmbedding posts user_id + embedding (+tenant_id) to /voice/verify-embedding") + void verifyVoiceEmbedding_postsJson() { + mockServer.expect(requestTo(BIO_URL + "/voice/verify-embedding")) + .andExpect(method(HttpMethod.POST)) + .andExpect(req -> { + String body = bodyAsString(req); + assertThat(body).contains("\"user_id\"").contains(USER_ID.toString()); + assertThat(body).contains("\"embedding\"") + .contains("0.11").contains("-0.22").contains("0.33"); + assertThat(body).contains("\"tenant_id\"").contains(TENANT_ID); + }) + .andRespond(withSuccess("{\"verified\":true}", MediaType.APPLICATION_JSON)); + + var result = adapter.verifyVoiceEmbedding(TENANT_ID, USER_ID, EMBEDDING); + + assertThat(result).containsEntry("verified", true); + mockServer.verify(); + } + + @Test + @DisplayName("verifyVoiceEmbedding maps a bio 4xx to a fail-closed error map") + void verifyVoiceEmbedding_4xx_failsClosed() { + mockServer.expect(requestTo(BIO_URL + "/voice/verify-embedding")) + .andExpect(method(HttpMethod.POST)) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY) + .body("{\"detail\":\"embedding must have exactly 256 elements\"}") + .contentType(MediaType.APPLICATION_JSON)); + + var result = adapter.verifyVoiceEmbedding(TENANT_ID, USER_ID, EMBEDDING); + + assertThat(result).containsEntry("success", false); + mockServer.verify(); + } + + @Test + @DisplayName("enrollVoiceEmbedding posts user_id + embedding + optimize to /voice/enroll-embedding") + void enrollVoiceEmbedding_postsJsonWithOptimize() { + mockServer.expect(requestTo(BIO_URL + "/voice/enroll-embedding")) + .andExpect(method(HttpMethod.POST)) + .andExpect(req -> { + String body = bodyAsString(req); + assertThat(body).contains("\"user_id\"").contains(USER_ID.toString()); + assertThat(body).contains("\"embedding\"").contains("0.11"); + assertThat(body).contains("\"tenant_id\"").contains(TENANT_ID); + assertThat(body).contains("\"optimize\":true"); + }) + .andRespond(withSuccess("{\"success\":true}", MediaType.APPLICATION_JSON)); + + var result = adapter.enrollVoiceEmbedding(TENANT_ID, USER_ID, EMBEDDING, true); + + assertThat(result).containsEntry("success", true); + mockServer.verify(); + } + + @Test + @DisplayName("enrollVoiceEmbedding omits a blank tenant_id and defaults optimize=false") + void enrollVoiceEmbedding_blankTenant_omitted() { + mockServer.expect(requestTo(BIO_URL + "/voice/enroll-embedding")) + .andExpect(method(HttpMethod.POST)) + .andExpect(req -> { + String body = bodyAsString(req); + assertThat(body).contains("\"embedding\""); + assertThat(body).doesNotContain("\"tenant_id\""); + assertThat(body).contains("\"optimize\":false"); + }) + .andRespond(withSuccess("{\"success\":true}", MediaType.APPLICATION_JSON)); + + adapter.enrollVoiceEmbedding(" ", USER_ID, EMBEDDING, false); + mockServer.verify(); + } + + @Test + @DisplayName("enrollVoiceEmbedding maps bio unreachable to a fail-closed error map") + void enrollVoiceEmbedding_unreachable_failsClosed() { + mockServer.expect(requestTo(BIO_URL + "/voice/enroll-embedding")) + .andExpect(method(HttpMethod.POST)) + .andRespond(req -> { + throw new org.springframework.web.client.ResourceAccessException("connection refused"); + }); + + var result = adapter.enrollVoiceEmbedding(TENANT_ID, USER_ID, EMBEDDING, false); + + assertThat(result).containsEntry("success", false); + mockServer.verify(); + } + // --- puzzle session proxy (CV-2 of the puzzle-as-login convergence) --- // Canonical bio routes (relative to the /api/v1 base URL): // POST /liveness/puzzle-session