mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
feat: enhance biometric enrollment with Mantra device integration and finger position support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './biometric-enrollment.types';
|
||||
export * from './biometric-enrollment-api';
|
||||
export * from './mantra-capture-agent';
|
||||
|
||||
@@ -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 11100–11105 (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, 0–100, 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user