Metadata-Version: 2.4
Name: lorica-sdk
Version: 0.1.7
Summary: Python SDK for the Lorica biometric verification API — prove a real human authorized an action
Author-email: Lorica <sdk@loricaapi.com>
License: MIT
Project-URL: Homepage, https://loricaapi.com
Project-URL: Documentation, https://loricaapi.com/docs
Keywords: biometric,attestation,identity,receipt,jwt,audit,api
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: PyJWT>=2.12
Requires-Dist: cryptography>=42

# Attest Python SDK

**Prove a real human authorized an action.** Attest matches a fresh face capture
against an enrolled identity and returns a cryptographically signed **receipt** binding
*who* authorized *what*, *when*. Receipts are RS256 JWTs anyone can verify
offline against your published JWKS — no shared secret, no callback to Attest.

- Package: `lorica-sdk` • import as `lorica`
- Requires Python 3.10+
- Dependencies: `PyJWT>=2.12`, `cryptography>=42`

```bash
pip install lorica-sdk
```

## Quickstart — consent, enroll, attest

```python
from lorica import LoricaClient

client = LoricaClient("attest_sk_...", base_url="https://api.loricaapi.com")

# 1. Consent first — required before any face is embedded. Show the person
#    the current disclosure text and record their explicit "I agree".
disclosure = client.get_disclosure()
show_to_user(disclosure["text"])                    # your UI, verbatim
client.record_consent("alice", disclosure_version=disclosure["version"])

# 2. Enroll the person once (face + identity).
client.enroll("alice", "/path/to/alice.jpg",
              {"name": "Alice A.", "role": "approver", "license_id": "L-1", "org": "acme"})

# 3. Attest that Alice authorized this specific action.
result = client.attest("alice", "/path/to/live-selfie.jpg",
                       action_type="wire_approval",
                       payload={"amount_usd": 250000})

receipt = result["receipt"]   # signed RS256 JWT — store this as your proof
```

> **Payload numbers must be JSON-stable.** Use integers (`{"amount_usd": 250000}`)
> or decimal strings (`{"amount_usd": "250000.00"}`) for numeric payload fields —
> never floats. A whole-number float like `250000.0` serializes as `250000.0` in
> Python but `250000` in JavaScript, so this SDK and the server reject it to
> guarantee your receipt verifies in any language. NaN and Infinity are rejected
> for the same reason. Integers must stay within ±(2^53-1): JavaScript parses
> every JSON number as a double and silently rounds anything larger, so a Node
> verifier could never re-hash the receipt — this SDK and the server both reject
> such integers (`ValueError` locally, 422 server-side). Use a decimal string
> for identifiers or amounts that large.

`image` accepts a base64 string, raw `bytes`, or a path to an image file
(`lorica.image_from_file(path)` is exposed if you want to encode yourself).

If the live face does not match the enrolled user, `attest()` raises
`MatchFailedError` (HTTP 403) and no receipt is issued.

## Consent — required before enrollment

Biometric privacy law (BIPA; GDPR Art. 9) requires **written informed consent
before any face is embedded**. Fetch the current disclosure, show its text to
the user verbatim, record their explicit agreement, and only then enroll:

```python
# 1. Fetch the exact disclosure text the user must be shown.
disclosure = client.get_disclosure()   # {"version": "v1", "text": ..., "sha256": ...}
print(disclosure["text"])              # show this to the user, verbatim

# 2. Record their explicit agreement (append-only on the server).
client.record_consent("alice", disclosure_version=disclosure["version"], agree=True)

# 3. Now enroll.
client.enroll("alice", "/path/to/alice.jpg",
              {"name": "Alice A.", "role": "approver", "license_id": "L-1", "org": "acme"})
```

Or fold steps 2–3 into one call — consent is recorded first, and the enroll
request is only sent once it succeeds:

```python
client.enroll("alice", "/path/to/alice.jpg",
              {"name": "Alice A.", "role": "approver", "license_id": "L-1", "org": "acme"},
              consent={"disclosure_version": "v1", "agree": True})
```

Pass `agree=True` only on an explicit affirmative action by the person — never
as a default.

**`agree=False` is not a rejected call; it records a withdrawal.** The consent
ledger is append-only, and a withdrawal is a real, meaningful entry: it
returns 200, blocks future enrollment for that user, and refuses attestation
with 403 `consent_withdrawn` (including an attest already in flight). Do not
call `record_consent(..., agree=False)` to "check" or "clear" a value — it is
the withdrawal API. Enrolling a user with no consent on file raises
`ConsentRequiredError` (403). Check status without writing anything via
`client.get_consent("alice")` → `{"consented": bool, "latest", "history"}`.

When `disclosure_version` is omitted, `record_consent` fetches the current
version via `get_disclosure()` first — a convenience that is only appropriate
when the disclosure you actually displayed to the person IS the current one.
Either way, calling `record_consent` asserts your app really showed the text
and received an explicit affirmative action.

## Retention — how long templates are kept

Each enrolled user has a retention mode for their biometric template
(the org-wide schedule is public at `GET /retention/policy`):

- `"standing_credential"` (default) — kept until account closure or explicit
  deletion, so the person can keep authorizing actions.
- `"ephemeral"` — destroyed once `window_seconds` elapse after last use;
  a `0` window 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` (raised as `ConsentRequiredError`). Record
consent again, then flip:

```python
client.get_retention("alice")                 # {"user_id", "mode", "window_seconds"}

# Fresh consent AFTER enrollment authorizes the destructive flip:
client.record_consent("alice")
client.set_retention("alice", "ephemeral", window_seconds=0)
```

(Loosening a window or flipping back to `"standing_credential"` is never
destructive and needs no fresh consent.)

## Offline verification

A receipt proves itself: the server is never asked for a verdict. Verify it
anywhere — a different service, an auditor's laptop, years later — with only
the SDK and your JWKS. `verify_offline` never touches the network by default:
fetch the key set once with `fetch_jwks()` (or pin it from an evidence
package), persist it, and pass it in.

```python
from lorica import LoricaClient, ReceiptInvalidError

client = LoricaClient("attest_sk_...", base_url="https://api.loricaapi.com")
jwks = client.fetch_jwks()   # the ONE network call — pin/persist this document

try:
    claims = client.verify_offline(receipt, jwks=jwks)   # RS256 verify + recompute record hash
    # `action` is the action-type string; the payload binding lives in
    # `action_payload` ({"ref", "hash"}).
    print("authorized by:", claims["sub"], "->", claims["action"])

    # Bind the receipt to the exact action payload you are about to execute.
    client.verify_payload(claims, {"amount_usd": 250000})
    print("payload matches the attested action")
except ReceiptInvalidError as e:
    print("REJECT — do not act on this receipt:", e.message)
```

`verify_offline` performs the contract's verification algorithm:

1. Resolve the JWT's `kid` in the JWKS and verify the **RS256** signature
   (`algorithms=["RS256"]` is explicit; `alg=none` is rejected).
2. Rebuild the canonical record from the claims and require its SHA-256 to
   equal the `record_hash` claim — any tampered claim fails here.

The rebuilt field set is **version-dispatched** on the receipt's `ver` claim
(current receipts are `ver: 2`; both versions verify forever):

- `ver` absent or `1` — the frozen v1 ten: `record_id, sub, identity, action,
  action_payload, live, match_score, iat, seq, prev_hash`.
- `ver: 2` — the v1 ten **plus** `ver, env, key_mode, auth_id, conf_hash,
  action_schema_version, enrollment_version` in the same flat dict.
- `ver > 2` — fails closed (`unsupported_receipt_version`): upgrade the SDK.

v2 receipts can additionally be pinned to a deployment with
`verify_offline(receipt, jwks=jwks, expected_env="production",
expected_key_mode="live")` — enforced only when supplied and the receipt is v2.

The returned claims also include **`assurance`** (on every receipt): for each
signal (`match`, `liveness`, `anti_spoof`, `injection`, `challenge`,
`active_verdict`), the operator's *configured* posture (`required`) beside
what the flow *actually did* (`achieved`); a signal that did not run says
`"not_evaluated"` outright. Like `liveness` and `chal`, it is signed but
rides **outside** `record_hash` — authenticated by the receipt signature, not
the chain hash — and it records posture; it does not upgrade what any signal
proves.

`verify_payload` recomputes `sha256(canonical(payload))` and compares it to the
receipt's `action_payload.hash`, proving the plaintext you hold is the one that
was actually authorized. Pass it a receipt string or the claims returned by
`verify_offline`.

A pinned key set is never refreshed over the network, not even on a `kid`
miss. An evidence package carries its own `jwks` block, so it verifies
air-gapped with nothing else in hand:

```python
package = client.export_evidence(user_id="alice")
claims = client.verify_offline(package["records"][0]["receipt"], jwks=package["jwks"])
```

Calling `verify_offline` with no `jwks` and nothing cached raises
`ReceiptInvalidError` (`error_code="jwks_unavailable_offline"`) 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
`allow_network=True`:

```python
claims = client.verify_offline(receipt, allow_network=True)   # convenience fetch
```

## Other methods

| Method | Endpoint | Purpose |
| --- | --- | --- |
| `get_disclosure()` | `GET /consent/disclosure` | current disclosure text + hash |
| `record_consent(user_id, disclosure_version=None, agree=True)` | `POST /consent` | record affirmative consent |
| `get_consent(user_id)` | `GET /consent/{id}` | consent status + history |
| `get_retention(user_id)` | `GET /retention/{id}` | template retention mode |
| `set_retention(user_id, mode, window_seconds=None)` | `POST /retention/{id}` | set retention mode |
| `enroll(user_id, image, identity, consent=None)` | `POST /enroll` | register a face + identity |
| `attest(user_id, image, action_type, payload, ref="")` | `POST /attest` | issue a receipt |
| `verify(receipt, mode=None)` | `POST /verify` | server-side verification + chain check. Authenticated default is **`"redeem"`: single-use** — a second `verify()` of the same receipt returns `{"valid": False, "reason": "nonce_replay"}` by design. Pass `mode="evidence"` for a repeatable, non-consuming check |
| `fetch_jwks()` | `GET /.well-known/jwks.json` | fetch the JWKS to pin for offline verification |
| `verify_offline(receipt, jwks=None, allow_network=False)` | — | local RS256 + record-hash verification, zero-network by default |
| `verify_payload(receipt_or_claims, payload)` | — | local payload-binding check |
| `delete_user(user_id)` | `DELETE /users/{id}` | erase embeddings, tombstone records |
| `audit(**filters)` | `GET /audit` | list records + signed manifest |
| `export_evidence(**filters)` | `GET /audit/export` | offline-verifiable evidence package |
| `get_receipt(record_id)` | `GET /audit/receipt/{id}` | fetch one record + receipt |
| `usage()` | `GET /usage` | attestation counters |
| `health()` | `GET /health` | service health |

`audit` filters: `user_id`, `action_type`, `from_ts`, `to_ts`,
`limit` (default 100), `offset` (default 0).

`export_evidence` filters: `user_id`, `action_type`, `from_ts`, `to_ts` only.
The server ignores `limit`/`offset` on `/audit/export` and always returns up
to a hard cap of 1000 records, so the SDK does not accept or send them there.

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.

## Errors

All errors derive from `LoricaError` (`.status_code`, `.error_code`, `.message`):

`AuthError` (401) · `MatchFailedError` (403) · `ConsentRequiredError` (403) ·
`NotFoundError` (404) ·
`ValidationError` (400/422) · `RateLimitError` (429, `.retry_after`) ·
`ServerError` (5xx) · `ConnectionError` / `TimeoutError` (transport) ·
`ReceiptInvalidError` (local verification failed).

## Configuration

```python
LoricaClient(
    api_key,
    base_url=None,      # or LORICA_API_URL env var; default https://api.loricaapi.com
    timeout=30.0,       # per-attempt seconds
    max_retries=3,      # retries on 429 / 5xx / transport drops, honoring Retry-After
)
```

The client keeps one keep-alive connection. GETs, `delete_user()` and
`set_retention()` (an idempotent upsert) retry automatically on 429 / 5xx /
transport drops. `attest()` sends an `Idempotency-Key` the server deduplicates,
so its automatic retries replay the original receipt instead of re-attesting
(reusing a key with a different body is rejected server-side with
`409 idempotency_conflict`; the SDK never does this — the key is minted with the
body it is sent with). `verify()` auto-retries **only** with
`mode="evidence"`: 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 `record_consent()` are **never**
auto-retried (the server does not deduplicate them), so a transient failure
there surfaces immediately for you to handle.
Use one client per thread for concurrency, or `with LoricaClient(...) as c:` to
close the connection promptly.
