lorica-node
v0.1.7
Published
Node.js SDK for the Lorica biometric verification API — prove a real human authorized it
Maintainers
Readme
lorica-node
Node.js SDK for Attest — biometric attestation for high-stakes actions.
When someone releases a pharma batch, approves a wire, or deletes production data, Attest proves a real, identified human authorized it — and hands you a signed receipt anyone can verify later, even offline, without trusting Attest's database.
- Node.js >= 20, TypeScript, ESM + CJS.
- One runtime dependency:
jose.
Install
npm install lorica-nodeQuickstart
import { LoricaClient } from 'lorica-node';
const attest = new LoricaClient({
apiKey: process.env.ATTEST_API_KEY!, // "attest_sk_..."
baseUrl: 'http://localhost:8000', // or LORICA_API_URL
});
// 1. Consent first. Written informed consent is required before any face
// is embedded (BIPA/GDPR) — the server rejects enrollment without it.
// Fetch the exact disclosure text, show it, and record the "I agree":
const disclosure = await attest.getDisclosure(); // { version, text, sha256 }
showToUser(disclosure.text); // your UI — must be shown verbatim
await attest.recordConsent({ // after an explicit "I agree"
userId: 'op-1041',
disclosureVersion: disclosure.version, // omit to auto-fetch the current one
});
// 2. Enroll the operator once (face + verified identity).
await attest.enroll({
userId: 'op-1041',
image: LoricaClient.imageFromFile('./enrollment.jpg'), // Buffer | path | base64
identity: { name: 'Zoë Müller', role: 'qa_lead', license_id: 'L-9', org: 'acme' },
});
// Or do both in one call — consent is recorded first, then enrollment:
// await attest.enroll({ userId, image, identity, consent: {} });
// 3. At the moment of the action: attest it.
const { receipt, record } = await attest.attest({
userId: 'op-1041',
image: webcamFrame, // Buffer | path | base64
actionType: 'batch_release', // letters/digits/_-.: only
payload: { batch_id: 'BATCH-2210', quantity: 5000, unit: 'vials' },
ref: 'BATCH-2210',
});
// Store `receipt` (an RS256 JWT) alongside the action it authorized.
// 4. Later — verify. Online. NOTE: an authenticated verify() defaults to
// mode 'redeem' — SINGLE-USE. It consumes the receipt's nonce, so a second
// verify() of the same receipt returns { valid: false, reason:
// 'nonce_replay' } by design. Use { mode: 'evidence' } for a repeatable,
// non-consuming check of stored evidence.
const check = await attest.verify(receipt); // { valid, reason, who, what, when, chain }
// ...or offline — verify without asking Attest for a verdict. Pass a JWKS for
// a truly zero-network check (fetch the keys once and pin/persist them, or take
// them from an evidence package's `jwks` block). Omitting the arg resolves
// against keys cached by a prior fetchJwks() — with nothing cached it throws
// JWKSRequiredError unless the client opted in with `allowNetwork: true`.
const jwks = await attest.fetchJwks(); // GET /.well-known/jwks.json (once)
const offline = await attest.verifyOffline(receipt, jwks); // zero network
if (offline.valid) {
// Optionally bind the receipt to the exact payload you're about to act on:
const bound = attest.verifyPayload(offline.claims!, {
batch_id: 'BATCH-2210', quantity: 5000, unit: 'vials',
});
console.log('authorized by', offline.claims!.identity.name, '— payload bound:', bound);
}Payload numbers must be JSON-stable. Use integers (
{ amount: 50000 }) or decimal strings ({ amount: "50000.00" }) for numeric payload fields — never floats. A whole-number float like50000.0serializes as50000.0in Python but50000in JavaScript, so the SDKs and server reject it to guarantee your receipt verifies in any language. NaN and Infinity are rejected for the same reason. (In JavaScript50000.0is50000, so there is no whole-number float for this SDK to reject — that rule matters when a Python service mints or re-verifies the same payload.) Integers must stay within ±(2^53-1): JavaScript parses every JSON number as a double and silently rounds anything larger, so no independent verifier could re-hash the receipt.attest()rejects such integers client-side before any network call (the server enforces the same rule with a 422); use a decimal string for identifiers or amounts that large.
What a receipt proves
Each attest() call returns a signed JWT whose claims embed:
- who — the enrolled identity (
sub,identity) and the face match (match_score, withinput_kindand an advisorylivenessblock), - what — the action type and a canonical sha256 of your payload
(
action,action_payload.{ref,hash}), - when / where in the log —
iat,seq,prev_hash, and arecord_hashbinding the whole record into a tamper-evident chain.
Every receipt also carries an assurance claim: for each signal
(match, liveness, anti_spoof, injection, challenge,
active_verdict), the operator's configured posture (required) beside
what the flow actually did (achieved) — e.g.
claims.assurance.liveness records the deployment's liveness mode next to
the method, score, and verified flag that ran. A signal that did not run
says "not_evaluated" outright. It is signed but rides outside
record_hash (like liveness and chal), so it is authenticated by the
receipt signature, not the chain hash — and it records posture, it does not
upgrade what any signal proves.
verifyOffline() checks the RS256 signature against a JWKS document,
recomputes the canonical record hash from the claims, and compares it to
record_hash. The hashed field set is version-dispatched on the
receipt's ver claim (current receipts are ver: 2; both versions verify
forever):
verabsent or1— the frozen v1 ten:record_id, sub, identity, action, action_payload, live, match_score, iat, seq, prev_hash(liveis hash-covered but no longer a top-level claim; it is recomputed frominput_kind === 'image', its only meaning).ver: 2— the v1 ten plusver, env, key_mode, auth_id, conf_hash, action_schema_version, enrollment_versionin the same flat dict.ver > 2— fails closed (reason: 'unsupported_receipt_version').
v2 receipts can additionally be pinned to a deployment via
verifyOffline(receipt, jwks, { expectedEnv: 'production', expectedKeyMode:
'live' }) — enforced only when supplied and the receipt is v2. The server is never asked for a verdict, and the call is
zero-network by default: pass a jwks (pinned from an evidence package's
jwks block, or from a one-time fetchJwks()); omitting it throws
JWKSRequiredError rather than silently fetching. If you want the SDK to
fetch the JWKS for you on first use (cached ~24h — server-independent, but
not network-free), opt in with allowNetwork: true on the client.
API surface
| Method | Endpoint |
|---|---|
| getDisclosure() | GET /consent/disclosure (public) |
| recordConsent({userId, disclosureVersion?, agree?}) | POST /consent |
| getConsent(userId) | GET /consent/{user_id} |
| getRetention(userId) | GET /retention/{user_id} |
| setRetention(userId, {mode, windowSeconds?}) | POST /retention/{user_id} |
| enroll({userId, image, identity, consent?}) | POST /enroll |
| attest({userId, image, actionType, payload?, ref?}) | POST /attest |
| verify(receipt, {mode?}) | POST /verify — authenticated default is 'redeem' (single-use; a repeat returns nonce_replay); 'evidence' is repeatable and consumes nothing |
| fetchJwks() | GET /.well-known/jwks.json (cached 24h) |
| verifyOffline(receipt, jwks?) | verify with no server verdict; zero-network by default (throws without jwks unless allowNetwork: true) |
| verifyPayload(claims, payload) | local |
| deleteUser(userId) | DELETE /users/{id} |
| audit(filters?) | GET /audit |
| exportEvidence(filters?) | GET /audit/export |
| getReceipt(recordId) | GET /audit/receipt/{id} |
| usage() | GET /usage |
| health() | GET /health |
audit filters: { userId, actionType, fromTs, toTs, limit, offset }.
exportEvidence filters: { userId, actionType, fromTs, toTs } only — the
server ignores limit/offset on /audit/export and always returns up to a
hard cap of 1000 records, so the SDK drops them rather than sending no-ops.
recordConsent with no disclosureVersion fetches the current one via
getDisclosure() first — only appropriate when the disclosure you displayed
IS the current one. Calling it at all asserts your app really showed the text
and received an explicit affirmative action from the person.
The server also exposes GET /verify?receipt=... (+ optional mode,
expected_key_mode) — identical semantics and response shape to POST
/verify, so a verifier page can link straight to a result. The SDK always
uses the POST form.
Retention
Each enrolled user's biometric template has a retention mode (the org-wide
schedule is public at GET /retention/policy): 'standing_credential'
(default — kept until account closure or explicit deletion) or 'ephemeral'
(destroyed once windowSeconds elapse after last use; 0 destroys it right
after the action it was captured for).
Flipping an enrolled user into 'ephemeral' (or shortening an existing
ephemeral window) is destructive — it puts a previously-permanent credential
on a self-destruct timer — so the server requires a fresh consent recorded
after enrollment (or an admin override). Without one the call fails with
403 retention_flip_requires_consent (a ConsentRequiredError). Record
consent again, then flip:
await attest.getRetention('op-1041'); // { user_id, mode, window_seconds }
// Fresh consent AFTER enrollment authorizes the destructive flip:
await attest.recordConsent({ userId: 'op-1041' });
await attest.setRetention('op-1041', { mode: 'ephemeral', windowSeconds: 0 });(Loosening a window or flipping back to 'standing_credential' is never
destructive and needs no fresh consent.)
Errors
All HTTP errors are typed subclasses of LoricaError carrying
statusCode, errorCode, and the raw envelope: AuthenticationError (401),
MatchFailedError (403 match_failed — the action was not attested),
ConsentRequiredError (403 — consent_required: enroll attempted with no
consent on file; retention_flip_requires_consent /
reenroll_requires_fresh_consent: a destructive change needs a fresh
consent), NotFoundError (404), ValidationError (422 —
no_face_detected, multiple_faces, image_too_large, invalid_base64,
unknown_disclosure_version, invalid_retention_mode, ...),
RateLimitError (429, with rateLimit.retryAfterMs), ServerError (5xx),
plus NetworkError / TimeoutError for transport failures.
verifyOffline() throws JWKSRequiredError when called without a JWKS
document (the zero-network default); configure the client with
allowNetwork: true to let it fetch the JWKS once instead.
Idempotent requests (GETs, DELETE, setRetention()) automatically
retry on 429/5xx and transport drops with exponential backoff, honoring
Retry-After. attest() also retries: each call mints one Idempotency-Key
and reuses it across every attempt, so the server replays the original receipt
instead of creating (and billing) a second attestation. (Reusing a key with a
different body is rejected server-side with 409 idempotency_conflict; the SDK
never does this because the key is minted with the body it is sent with.)
verify() auto-retries only in 'evidence' mode: the authenticated
default is a single-use redemption, so a blind replay after a lost response
would read back nonce_replay on a receipt that was genuinely just redeemed —
after a transport error on a redeem, treat the redemption as unknown and check
with { mode: 'evidence' } before redeeming again.
enroll() and recordConsent() are never auto-retried — the server does
not deduplicate them, so a transient failure there surfaces immediately for you
to handle.
Canonical JSON & the float caveat
Payload hashing uses canonical JSON: keys sorted recursively by Unicode code
point (matching Python's sort_keys=True, including keys that mix astral and
upper-BMP characters), no whitespace, UTF-8, non-ASCII unescaped —
byte-identical to Python's
json.dumps(obj, sort_keys=True, separators=(",",":"), ensure_ascii=False).
Use integers and strings in payloads, not floats. JavaScript and Python
disagree on rendering whole-number floats (1.0 → "1" vs "1.0"), which
would break cross-language hash equality. The Python SDK and the Attest
server therefore reject whole-number floats (and NaN/Infinity) in payloads
at the boundary, so an ambiguous receipt is never minted; this SDK needs no
whole-float check because JavaScript cannot distinguish 1.0 from 1 in
the first place. Integers beyond ±(2^53-1) are the same bug from the other
direction — JS parses every JSON number as a double and silently rounds
them, so the server (422), the Python SDK, and this SDK (attest() and
canonicalJson both throw) all reject them with the same message.
Represent money as integer cents or a decimal string, and identifiers or
amounts beyond ±(2^53-1) as decimal strings. Fractional floats like 0.97
are fine.
Development
npm install
npm test # vitest, fully offline (mocked fetch, in-test RSA keypairs)
npm run build # dual ESM/CJS via esbuild + tsc declarations → dist/