feat: enhance biometric enrollment with Mantra device integration and finger position support

This commit is contained in:
nati
2026-09-07 08:57:57 +00:00
parent 29990d3142
commit 5dbc6b27de
4 changed files with 323 additions and 33 deletions

View File

@@ -1,10 +1,25 @@
export type BiometricModality = 'FINGERPRINT' | 'FACE';
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
/** Which finger a FINGERPRINT template belongs to; UNSPECIFIED for FACE or an untracked capture. */
export type BiometricPosition =
| 'RIGHT_THUMB'
| 'RIGHT_INDEX'
| 'RIGHT_MIDDLE'
| 'RIGHT_RING'
| 'RIGHT_LITTLE'
| 'LEFT_THUMB'
| 'LEFT_INDEX'
| 'LEFT_MIDDLE'
| 'LEFT_RING'
| 'LEFT_LITTLE'
| 'UNSPECIFIED';
export interface BiometricEnrollment {
id: string;
profileId: string;
modality: BiometricModality;
position: BiometricPosition | null;
templateFormat: string;
qualityScore: number | null;
deviceId: string | null;
@@ -22,6 +37,8 @@ export interface BiometricEnrollment {
export interface EnrollBiometric {
profileId: string;
modality: BiometricModality;
/** Required in practice for FINGERPRINT captures; a real scanner always knows which finger it read. */
position?: BiometricPosition;
/** Vendor SDK template, base64. Never the raw scan image. */
template: string;
templateFormat: string;

View File

@@ -1,2 +1,3 @@
export * from './biometric-enrollment.types';
export * from './biometric-enrollment-api';
export * from './mantra-capture-agent';

View File

@@ -0,0 +1,169 @@
import type { BiometricPosition } from './biometric-enrollment.types';
/**
* Client for Mantra's Windows RD Service — the local agent that ships with
* MORPHS (and every other Mantra scanner) and is the only way a browser can
* reach a USB-attached device. It implements UIDAI's standard Registered
* Device Service contract (the same one every L1-certified scanner uses),
* exposing three verbs over plain HTTP on `127.0.0.1`:
*
* RDSERVICE http://127.0.0.1:<port>/ → discovery: is it running,
* what are the paths for
* DEVICEINFO and CAPTURE
* DEVICEINFO http://127.0.0.1:<port>/rd/info → device identity
* CAPTURE http://127.0.0.1:<port>/rd/capture → the actual scan
*
* The service binds one port out of 1110011105 (11100 unless something else
* already holds it), so discovery has to probe the range rather than assume
* a fixed port.
*
* This is the one piece "swap the simulated bytes for the real vendor SDK
* once a vendor is chosen" (see `BiometricEnrollmentPage.tsx`) always meant —
* everything else in the enroll pipeline (base64 template + format tag,
* encrypt, store) was already shaped for whatever this returns.
*/
const CANDIDATE_PORTS = [11100, 11101, 11102, 11103, 11104, 11105] as const;
const DISCOVERY_TIMEOUT_MS = 800;
const CAPTURE_TIMEOUT_MS = 30_000;
export interface MantraDeviceInfo {
/** Device provider/serial identity as reported by RD service — goes in `deviceId`. */
deviceId: string;
/** RD service version string, kept for support/troubleshooting, not sent to the API. */
rdsVersion: string;
/** The port discovery found it on. */
port: number;
}
export interface MantraCaptureResult {
/** Base64 PID template block — passed straight through as `EnrollBiometric.template`. */
template: string;
templateFormat: string;
/** RD service's own capture quality score, 0100, when the response carries one. */
qualityScore?: number;
deviceId: string;
}
export class MantraCaptureUnavailableError extends Error {
constructor(message = 'Mantra RD Service was not found on this machine.') {
super(message);
this.name = 'MantraCaptureUnavailableError';
}
}
export class MantraCaptureFailedError extends Error {
constructor(
message: string,
public readonly errCode?: string,
) {
super(message);
this.name = 'MantraCaptureFailedError';
}
}
function withTimeout(ms: number): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return { signal: controller.signal, cancel: () => clearTimeout(timer) };
}
async function rdRequest(port: number, path: string, method: string, timeoutMs: number, body?: string): Promise<string> {
const { signal, cancel } = withTimeout(timeoutMs);
try {
// RD Service answers plain HTTP on localhost, and browsers block a page
// served over https:// from fetching http:// — this must be called from
// a counter page itself served over http://localhost or with the RD
// service's own local HTTPS certificate trusted, per Mantra's install guide.
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
body,
headers: body ? { 'Content-Type': 'application/xml' } : undefined,
signal,
});
return await res.text();
} finally {
cancel();
}
}
/** Parses `attr="value"` out of one XML tag without pulling in an XML library for three fields. */
function attr(xml: string, tag: string, name: string): string | undefined {
const tagMatch = xml.match(new RegExp(`<${tag}\\b[^>]*>`, 'i'));
if (!tagMatch) return undefined;
const attrMatch = tagMatch[0].match(new RegExp(`${name}="([^"]*)"`, 'i'));
return attrMatch?.[1];
}
/**
* Probes the RD service port range and reads back device identity. Resolves
* to `null` (never throws) when nothing answers — callers use that to decide
* whether to show "Simulate Scan" instead, exactly like `simulateEnabled`
* already gates it server-side.
*/
export async function discoverMantraDevice(): Promise<MantraDeviceInfo | null> {
for (const port of CANDIDATE_PORTS) {
try {
const discovery = await rdRequest(port, '/', 'RDSERVICE', DISCOVERY_TIMEOUT_MS);
if (!/status="READY"/i.test(discovery)) continue;
const infoPath = attr(discovery, 'Interface', 'path') ?? '/rd/info';
const info = await rdRequest(port, infoPath, 'DEVICEINFO', DISCOVERY_TIMEOUT_MS);
const dpId = attr(info, 'DeviceInfo', 'dpId') ?? attr(info, 'DeviceInfo', 'dc');
const rdsVersion = attr(info, 'DeviceInfo', 'rdsVer') ?? 'unknown';
if (!dpId) continue;
return { deviceId: dpId, rdsVersion, port };
} catch {
// Nothing on this port (connection refused/timeout) — try the next one.
continue;
}
}
return null;
}
/**
* One fingerprint capture for a specific finger. `position` drives nothing
* in the RD-service request itself (a single-finger scanner like a slap
* reader doesn't need to be told which finger it is), but the caller must
* still know which finger was placed, since MORPHS's four-finger and
* two-thumb slaps arrive pre-segmented by the vendor SDK into one image per
* finger with no position label of their own.
*/
export async function captureFingerprint(
device: MantraDeviceInfo,
position: BiometricPosition,
): Promise<MantraCaptureResult> {
const pidOptions = `<PidOptions ver="1.0"><Opts fCount="1" fType="0" format="0" pidVer="2.0" timeout="${CAPTURE_TIMEOUT_MS}" posh="UNKNOWN" env="P" /></PidOptions>`;
let response: string;
try {
response = await rdRequest(device.port, '/rd/capture', 'CAPTURE', CAPTURE_TIMEOUT_MS, pidOptions);
} catch (err) {
throw new MantraCaptureFailedError(
err instanceof Error && err.name === 'AbortError' ? 'Capture timed out.' : 'Could not reach the scanner.',
);
}
const errCode = attr(response, 'Resp', 'errCode');
if (errCode && errCode !== '0') {
const errInfo = attr(response, 'Resp', 'errInfo') ?? `RD service error ${errCode}`;
throw new MantraCaptureFailedError(errInfo, errCode);
}
const dataMatch = response.match(/<Data[^>]*>([\s\S]*?)<\/Data>/i);
if (!dataMatch) {
throw new MantraCaptureFailedError('Capture response had no template data.');
}
const qScore = attr(response, 'Resp', 'qScore');
return {
template: dataMatch[1].trim(),
// PidData carries an encrypted PID block per UIDAI's spec, not a raw
// ISO/WSQ template — recorded as such so a real matcher/decryption step
// downstream isn't misled into treating it as plaintext ISO-19794-4.
templateFormat: 'UIDAI-PID-2.0',
qualityScore: qScore ? Number(qScore) : undefined,
deviceId: device.deviceId,
};
}