Skip to content

9. Device Management

Device types and how scanners authenticate via mTLS — CA/CSR signing, certificate lifecycle, and SSL configuration, with a flow diagram per topic.

This section consolidates all device types and authentication patterns.

9.1 Device Types

All palm-capture hardware is managed by the Identity Platform — one fleet, one trust root (the platform CA), one console. Every device authenticates to the platform via mTLS (§9.2) regardless of class.

device_class Example Primary pattern Bound to
personal_scanner Bank counter, service desk (e-signature) Challenge-poll — integrator-initiated (§9.2) — (the challenge carries the user)
pos Wallet point-of-sale Device-initiated broker (§10) one {product, action}, e.g. wallet/pay
gate Access entry gate Device-initiated broker (§10) one {product, action}, e.g. access/entry
kiosk Signup / enrollment kiosk Device-initiated signup + enroll (§13) one product

All devices are tenant-scoped — the cert authenticates the device, not the user, and a single device can serve many users. Personal scanners receive work via challenges routed by device_id; pos/gate/kiosk devices are bound to exactly one {product, action} (bound_product, bound_action — see §9.3) so a bare scan resolves to a single product without per-request ambiguity.

9.2 Device Authentication (mTLS)

For all Identity-managed devices — personal scanners (e-signature, bank counters) and pos/gate/kiosk devices alike. The same pairing → CSR → platform-CA-signed cert → mTLS → revocation lifecycle applies to every device_class; nothing below is personal-scanner-specific.

The device authenticates itself to the platform via mTLS (mutual TLS with client certificate). The user is identified by the challenge context or palm scan, not by the device certificate.

TLS termination is at the application layer (the Identity Platform backend itself), not at an upstream proxy. This keeps the platform deployable anywhere — managed cloud (GCP, AWS), Cloudflare-fronted, or on-prem — without depending on a specific proxy's mTLS feature. See §9.2.1 for the certificate authority and signing specification, and §9.2.2 for SSL configuration management.

Pairing flow: 1. Admin registers device via POST /v1/devices → receives pairing code 2. Admin enters pairing code on scanner 3. Scanner generates key pair in Android Keystore/StrongBox (private key never leaves hardware) 4. Scanner sends pairing code + CSR + device_info (model, firmware, serial, hardware_id) to POST /v1/devices/pair 5. Identity Platform validates pairing code, signs CSR, returns client certificate 6. Scanner stores certificate, uses mTLS for all subsequent communication

Fleet provisioning

The attended per-device pairing above is the MVP for every device class; large POS/gate rollouts will later add pre-registered batch enrollment (each device authenticating with a per-device single-use secret delivered via MDM / zero-touch — no on-device entry), with hardware attestation or per-device X.509 as the production hardening.

Certificate lifecycle:

Aspect Policy
Default validity 90 days (configurable per tenant)
Renewal window Scanner auto-renews when cert is within 30 days of expiry
Renewal auth mTLS with current valid certificate — no admin involvement
Renewal endpoint POST /v1/devices/renew with new CSR
Old cert Fingerprint added to revocation list upon successful renewal
Key storage Android Keystore / StrongBox — private key never leaves hardware

Revocation: - Admin revokes via DELETE /v1/devices/{id} — cert fingerprint added to revocation list immediately - Revoked scanners fail on next mTLS handshake and must complete a fresh pairing flow to re-activate - Revocation is checked on every TLS handshake (cert fingerprint against revocation list) - All revocation events are audit-logged with device_id, tenant_id, and revoked_by

Forced rotation: If a tenant's security policy requires it, Platform Admin can trigger a bulk cert revocation for all devices in a tenant. Each scanner must re-pair individually. This is audit-logged as device_certs_bulk_revoked (§16).

Challenge-based operations: All palm operations on personal scanners (verification and enrollment) are challenge-based: 1. Integrator backend creates a challenge specifying user_id, device_id, and type (verify or enroll) via POST /v1/verify or POST /v1/enroll. 2. Scanner polls GET /v1/challenges/pending (mTLS — its cert identifies the device). 3. User scans palm at the scanner. Scanner extracts features and submits via POST /v1/challenges/{id}/complete (mTLS). 4. Identity Platform calls X-Telcom BioWave Pass (POST /KZ/query for verify, POST /KZ/add for enroll) and applies the tenant's match policy. 5. Result returned to scanner; webhook fired to integrator.

This means enrollment at a personal scanner uses the same challenge pattern as e-signature verification — the integrator initiates, the scanner picks up the challenge by polling, and Identity authoritatively maps user_id to palm_id. Personal scanners never have direct access to a user_id outside the challenge envelope.

For full sequences: - E-signature verification: Section 5.2 of identity-platform-diagrams.md - Personal scanner enrollment: Section 5.3 of identity-platform-diagrams.md - SSL certificate management: Section 5.4 of identity-platform-diagrams.md

9.2.1 Certificate Authority and CSR Signing

This section specifies the cryptographic detail behind the pairing flow above.

Two-PKI model. The platform maintains two unrelated PKIs that never share trust roots:

PKI Purpose Where it lives
Server TLS PKI The backend's own HTTPS cert. Presented during every TLS handshake, regardless of whether the client is a browser, the admin frontend's edge proxy, or a scanner doing mTLS. Admin-uploaded via console UI. Public-CA-signed (Let's Encrypt, corporate CA, etc.). Stored in the platform_secrets DB table.
Platform CA PKI Signs device client certificates. Used by the backend to validate incoming scanner client certs. Never presented in any TLS handshake. Generated or uploaded via console UI. Self-signed, 10-year validity by default. Private key + public cert PEM stored in platform_secrets.

The same server TLS cert serves both regular HTTPS endpoints (e.g., /v1/devices/pair) and mTLS endpoints. mTLS does not require a separate server cert — it adds a client-cert verification step on top of the standard handshake.

Platform CA storage. The CA private key + public cert are stored as PEM strings in the platform_secrets DB table. The application loads them into memory at startup; the CA private key never touches disk. Database engine encryption-at-rest (RDS, Cloud SQL, on-prem Postgres TDE) protects the data layer. There are no environment variables, no external secret managers, and no HSM dependency in v1. Future hardening (sign-as-a-service via cloud KMS or HashiCorp Vault Transit) is a one-day refactor when compliance requires it.

CA rotation is performed via the admin UI: - Generate a new CA → all subsequently issued device certs use the new chain - Existing device certs remain valid until natural expiry (or admin force-revokes for immediate rotation)

Cert profile. Every device certificate the platform CA issues conforms to this template:

Field Value
Key algorithm ECDSA P-256
Validity 90 days
Subject CN {device_id} (server-constructed; client-supplied DN is stripped)
SAN URI urn:link:tenant:{tenant_id}:device:{device_id}
KeyUsage digitalSignature
ExtKeyUsage clientAuth
BasicConstraints CA:false (critical)

The SAN URI is the canonical source of identity at request time. The mTLS middleware parses it on every request to derive device_id and tenant_id. CN is for human readability only.

CSR validation rules. The backend enforces three checks before signing a CSR:

  1. Reject if the public key algorithm is not ECDSA P-256.
  2. Strip the client-supplied Subject DN entirely; the server constructs the canonical CN from the pairing context.
  3. Reject any extension that would escalate privileges, including BasicConstraints CA:true, additional ExtKeyUsage values beyond clientAuth, or any policy-asserting extensions.

The PKCS#10 self-signature on the CSR is verified as standard proof-of-possession.

Server config. The backend's TLS layer is configured with ssl_cert_reqs=CERT_OPTIONAL (the server requests but does not require a client cert). Route-level middleware enforces mTLS for all scanner endpoints except /v1/devices/pair, which is the only public-HTTPS scanner route. The middleware:

  1. Verifies the client cert was signed by the platform CA and is not in the fingerprint denylist.
  2. Parses the SAN URI to extract device_id and tenant_id.
  3. Re-checks devices.status = 'paired' per request (belt-and-suspenders defense against in-flight revocation propagation lag).
  4. Attaches device_id and tenant_id to the request scope for downstream handlers.

Handshake mechanics on authenticated endpoints. Every authenticated scanner request (challenge poll, challenge complete, renewal, …) goes through this mTLS handshake before any application code runs:

  1. The scanner opens a TLS connection. The server presents its uploaded TLS cert plus a CertificateRequest (ssl_cert_reqs=CERT_OPTIONAL, with ssl_ca_certs pointing to the platform CA public cert).
  2. The scanner replies with its leaf cert + chain and a CertificateVerify message, signing the handshake transcript with its StrongBox-held private key.
  3. The TLS layer validates the leaf cert — signed by the platform CA, within its validity window, fingerprint not in the denylist. Any failure aborts the handshake, and the request never reaches application code.
  4. On success, the application middleware runs the four steps above.

/v1/devices/pair is the only scanner route that skips this; every other scanner route requires a valid client cert. Sequence diagram: identity-platform-diagrams.md §5.1.3.

Key design properties of the mTLS layer.

  • Identity travels in the cert, not in a session table. No SELECT session WHERE token=... on every request — the SAN URI is the identity.
  • The TLS layer is the auth boundary. A revoked cert never reaches application code; OpenSSL rejects it during the handshake.
  • The per-request devices.status check is defense-in-depth, not the primary auth mechanism. Its purpose is to close the small window between admin revocation and the denylist being honored by all in-flight connections.
  • Tenant isolation is implicit. Because tenant_id is in the cert and gets attached to the request scope, handler code does not need to filter by tenant manually — the database layer does it via scope-bound query helpers.

Pairing security. The /v1/devices/pair endpoint accepts pairing codes that are 9 alphanumeric characters (~47 bits of entropy). Codes are: - Hashed at rest in the devices table - TTL'd to 5 minutes - Single-use, enforced via atomic SQL: the code is consumed in the same UPDATE ... WHERE status='pending_pairing' RETURNING ... statement that paired the device - Rate-limited per source IP

Revocation behavior. Admin revocation is an atomic database transaction: 1. devices.status set to revoked 2. The cert's fingerprint is inserted into revoked_cert_fingerprints

The mTLS handshake validates against the denylist on every connection setup. Existing TCP/TLS connections established before revocation may complete in-flight requests — an acceptable trade-off given the small connection-keepalive window. New connections from a revoked scanner fail at the TLS layer; the scanner sees a TLS error and must initiate a fresh pairing flow.

Recovery paths:

Scenario Recovery
Half-pairing (server signed, scanner crashed before storing the cert) Admin issues a new pairing code; the orphaned cert is revoked.
Hardware key loss (factory reset, app reinstall — StrongBox wipes the keypair) Same — new pairing code, old cert revoked.
Long offline period with clock drift causing apparent cert expiry Scanner re-syncs via NTP; if clock is unrecoverable, re-pair from scratch.

Time sync. Scanners require NTP-synced system time. Cert validity (notBefore / notAfter) is checked against the local clock; sustained clock drift can produce false expiry errors that require manual time correction or re-pairing.

Diagram 5.1 — Personal Scanner Pairing

sequenceDiagram
    autonumber
    participant Admin as InvestGlass Admin
    participant IG as InvestGlass Backend
    participant Identity as Identity Platform
    participant Scanner as Personal Scanner
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting

    Admin->>IG: Request new scanner token<br/>for "Counter 3, Branch A"
    IG->>Identity: POST /v1/devices<br/>Authorization: Bearer <token><br/>{device_name: "Counter 3 Scanner",<br/>location: "Branch A"}

    Identity->>Identity: Generate pairing code<br/>(9 alphanumeric chars, ~47 bits entropy,<br/>expires in 5 min)
    Identity->>DB: Store device record<br/>(status: pending_pairing,<br/>pairing_code_hash, tenant_id)
    Identity->>Audit: Log: device_created<br/>(device_id, tenant_id, status: pending)

    Identity-->>IG: {device_id: "dev_789",<br/>pairing_code: "K7M2P9XQ4",<br/>expires_at: "..."}

    IG-->>Admin: Display "K7M2P9XQ4"
    Admin->>Admin: Enter code on scanner at counter

    Note over Scanner: Admin enters code on scanner

    Scanner->>Scanner: Display pairing screen
    Scanner->>Scanner: Admin enters "K7M2P9XQ4"
    Scanner->>Scanner: KeyPairGenerator (EC, AndroidKeyStore provider)<br/>KeyGenParameterSpec:<br/>• curve = P-256<br/>• purposes = PURPOSE_SIGN | PURPOSE_VERIFY<br/>• digests = DIGEST_SHA256<br/>• setIsStrongBoxBacked(true)<br/>• alias = "link.identity.device.${device_uuid}"<br/>→ private key sealed inside StrongBox<br/>(non-exportable, non-extractable)
    Scanner->>Scanner: Build PKCS#10 CSR (Bouncy Castle):<br/>• SubjectPublicKeyInfo = generated public key<br/>• Subject = placeholder (server overwrites)<br/>• ContentSigner from StrongBox-backed KeyStore entry<br/>• algorithm = ecdsa-with-SHA256<br/>• PEM-encode (BEGIN/END CERTIFICATE REQUEST)

    Note over Scanner, Identity: Regular HTTPS — no client cert.<br/>Server's uploaded TLS cert validated by Android default trust store.

    Scanner->>Identity: POST /v1/devices/pair<br/>{pairing_code: "K7M2P9XQ4",<br/>csr: "<base64 CSR>",<br/>device_info: {model, firmware, serial, hardware_id}}

    Identity->>Identity: Rate-limit check by source IP<br/>(reject if exceeded)
    Identity->>DB: Atomic UPDATE devices<br/>WHERE pairing_code_hash=$1<br/>AND status='pending_pairing'<br/>AND created_at > now() - interval '5 min'
    alt Code invalid/expired (0 rows updated)
        Identity->>Audit: Log: device_paired<br/>(status: failed, reason: invalid_code)
        Identity-->>Scanner: 401 Unauthorized
    else Code valid (row claimed atomically)
        Identity->>Identity: Verify CSR self-signature<br/>(proof-of-possession of private key)
        Identity->>Identity: Check key algorithm = ECDSA P-256<br/>(reject otherwise)
        Identity->>Identity: Strip client-supplied Subject DN<br/>(server constructs canonical CN)
        Identity->>Identity: Reject privileged extensions<br/>(BasicConstraints CA:true, extra EKUs,<br/>policy constraints)
        Identity->>Identity: Build canonical cert template:<br/>Subject CN = {device_id}<br/>SAN URI = urn:link:tenant:{tenant_id}:device:{device_id}<br/>KeyUsage = digitalSignature<br/>ExtKeyUsage = clientAuth<br/>BasicConstraints = CA:false (critical)<br/>Validity = 90 days
        Identity->>Identity: Sign cert in-process<br/>using platform CA private key<br/>(loaded from platform_secrets DB row<br/>at app startup — never on disk)
        Identity->>DB: Store cert fingerprint,<br/>update status=paired, paired_at
        Identity->>Audit: Log: device_paired<br/>(device_id, tenant_id, fingerprint)

        Identity-->>Scanner: {device_id: "dev_789",<br/>certificate: "<PEM leaf cert>",<br/>ca_chain: "<PEM platform CA cert>",<br/>expires_at: "...",<br/>status: "paired"}

        Scanner->>Scanner: Store certificate + chain<br/>alongside StrongBox keypair
        Scanner-->>Scanner: Display "Paired successfully"
    end

    Note over Scanner, Identity: All subsequent calls use mTLS.<br/>See §5.1.3 for handshake mechanics.<br/>device_id and tenant_id extracted from<br/>client cert SAN URI on every request.

Diagram 5.1.1 — Certificate Auto-Renewal

sequenceDiagram
    autonumber
    participant Scanner as Personal Scanner
    participant Identity as Identity Platform
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Scanner: Certificate approaching expiry<br/>(e.g., 30 days before expiration)

    Scanner->>Scanner: Generate new ECDSA P-256 keypair<br/>in Android StrongBox<br/>(old key still in place until success)
    Scanner->>Scanner: Build new PKCS#10 CSR<br/>(self-signed for proof-of-possession)

    Note over Scanner, Identity: mTLS handshake — scanner presents current valid cert.<br/>Server TLS layer validates: signed by platform CA,<br/>not expired, fingerprint not in revoked_cert_fingerprints denylist.<br/>(See §5.1.3 for full handshake mechanics.)

    Scanner->>Identity: POST /v1/devices/renew<br/>(mTLS authenticated)<br/>{csr: "<base64 CSR>"}

    Identity->>Identity: Middleware parses cert SAN URI<br/>→ extract device_id, tenant_id<br/>(canonical identity from cert)
    Identity->>DB: SELECT status FROM devices WHERE id=$1<br/>(belt-and-suspenders: cert valid<br/>but device may have been revoked)

    alt Device revoked or cert in denylist
        Identity->>Audit: Log: cert_renewal_failed<br/>(device_id, reason)
        Identity-->>Scanner: 403 Forbidden
        Note over Scanner: Must re-pair from scratch
    else Valid
        Identity->>Identity: Verify CSR self-signature (PoP)
        Identity->>Identity: Check key algorithm = ECDSA P-256
        Identity->>Identity: Strip client-supplied Subject DN
        Identity->>Identity: Reject privileged extensions<br/>(BasicConstraints CA:true, extra EKUs)
        Identity->>Identity: Build canonical cert<br/>(same template as pairing,<br/>validity = 90 days from now)
        Identity->>Identity: Sign cert in-process<br/>using platform CA private key<br/>(same in-memory key as pairing —<br/>no separate signing service)
        Identity->>DB: Atomic txn:<br/>UPDATE devices SET cert_fingerprint=new_fp<br/>+ INSERT into revoked_cert_fingerprints<br/>(fingerprint=old_fp, reason='renewed')
        Identity->>Audit: Log: cert_renewed<br/>(device_id, tenant_id,<br/>old_fingerprint, new_fingerprint)

        Identity-->>Scanner: {certificate: "<new PEM leaf cert>",<br/>ca_chain: "<PEM platform CA cert>",<br/>expires_at: "..."}

        Scanner->>Scanner: Store new certificate + chain<br/>Delete old keypair from StrongBox
        Scanner-->>Scanner: Display "Certificate renewed"
    end

Diagram 5.1.2 — Device Revocation

sequenceDiagram
    autonumber
    participant Admin as Tenant Admin
    participant Console as Web Console
    participant Identity as Identity Platform
    participant DB as PostgreSQL
    participant Audit as Audit Log
    participant Scanner as Personal Scanner

    Admin->>Console: Revoke scanner "Counter 3"
    Console->>Identity: DELETE /v1/devices/dev_789<br/>Authorization: Bearer <token>

    Identity->>DB: Atomic txn:<br/>UPDATE devices SET status='revoked',<br/>revoked_at=now(), revoked_by=admin_id<br/>+ INSERT into revoked_cert_fingerprints<br/>(fingerprint=device.cert_fingerprint, revoked_at=now(), revoked_by=admin_id)
    Identity->>Audit: Log: device_revoked<br/>(device_id, tenant_id,<br/>revoked_by: admin_id)

    Identity-->>Console: 200 OK
    Console-->>Admin: "Scanner revoked"

    Note over Scanner, Identity: Already-open TLS connections may finish<br/>in-flight requests, but app-layer<br/>devices.status check rejects any new request.

    Note over Scanner: Next NEW connection attempt fails at TLS layer

    Scanner->>Identity: GET /v1/challenges/pending<br/>(initiates mTLS handshake with revoked cert)
    Identity->>Identity: OpenSSL/uvicorn checks leaf cert<br/>fingerprint against denylist<br/>during handshake validation
    Identity-->>Scanner: TLS handshake abort<br/>(request never reaches app)

    Scanner-->>Scanner: Display "Certificate revoked —<br/>contact administrator"

    Note over Scanner: Requires fresh pairing flow<br/>to re-activate

Diagram 5.1.3 — mTLS Handshake on Authenticated Endpoints

sequenceDiagram
    autonumber
    participant Scanner as Personal Scanner
    participant Backend as Identity Platform
    participant DB as PostgreSQL

    Note over Scanner: Applies to every authenticated scanner route<br/>(everything except /v1/devices/pair, which is public)

    Note over Scanner, Backend: Scanner initiates mTLS handshake.<br/>Presents its leaf cert + ca_chain.<br/>StrongBox-held private key signs the handshake.

    Backend->>Backend: Validate leaf cert:<br/>signed by platform CA, within validity window
    Backend->>DB: Is cert fingerprint in revoked_cert_fingerprints?
    alt Revoked
        DB-->>Backend: Yes
        Backend-->>Scanner: TLS handshake rejected<br/>(request never reaches the app)
        Note over Scanner: Scanner shows "Certificate invalid",<br/>offers re-pair
    else Not revoked
        DB-->>Backend: No

        Note over Scanner, Backend: Handshake complete. Request proceeds.

        Backend->>Backend: Extract device_id and tenant_id<br/>from cert SAN URI<br/>(urn:link:tenant:{tenant_id}:device:{device_id})
        Backend->>DB: SELECT status FROM devices WHERE id=device_id
        alt status ≠ 'paired'
            Backend-->>Scanner: 401 Unauthorized<br/>(device revoked since handshake)
        else status = 'paired'
            Backend-->>Scanner: 200 OK + response payload
        end
    end

9.2.2 SSL Configuration Management

The platform exposes admin endpoints under /v1/admin/ssl/* for managing both the server TLS cert and the platform CA. No automatic Let's Encrypt cron runs on the backend; certificate renewal is admin-initiated.

Server TLS cert: - GET /v1/admin/ssl/server-cert — returns issuer, subject, expiry, days remaining, fingerprint - PUT /v1/admin/ssl/server-cert — uploads a new cert + key pair (multipart). Validates the pair, persists to platform_secrets, hot-reloads uvicorn, audit-logs server_cert_uploaded.

Platform CA: - GET /v1/admin/ssl/ca-cert — returns subject, expiry, days remaining, fingerprint, public cert PEM - POST /v1/admin/ssl/ca-cert/generate — generates a new ECDSA P-256 CA keypair, self-signs a 10-year cert, persists to platform_secrets. Audit-logged as platform_ca_generated. - PUT /v1/admin/ssl/ca-cert — uploads an existing CA cert + key (for orgs migrating from a CA they already operate) - POST /v1/admin/ssl/ca-cert/regenerate — destructive. Same as generate, but invalidates all existing device certs (admin must trigger force-revocation as a separate confirm step). Requires platform-admin auth. Audit-logged as platform_ca_regenerated (high-severity event).

Device cert policy: - GET /v1/admin/ssl/device-policy — current default validity and renewal window - PUT /v1/admin/ssl/device-policy — update policy (platform-admin only). Audit-logged as device_policy_changed.

First-run SSL setup. On a fresh install, platform_secrets is empty and the backend listens on plain HTTP — no self-signed throwaway cert, no bootstrap mode, no separate bootstrap admin. Setup is a one-time wizard:

  1. The env-seeded Platform Admin (§3.6) signs in over HTTP via the normal /v1/auth/login, is forced to change the initial password, and opens the SSL Settings page.
  2. The console calls GET /v1/admin/ssl/status{server_cert_configured: bool, platform_ca_configured: bool, setup_complete: bool}. While server_cert_configured is false, the page shows an inline wizard to upload a server TLS cert and to generate (or upload) the platform CA.
  3. On the first server-cert upload (PUT /v1/admin/ssl/server-cert), the backend persists it, returns 200 OK with restart_scheduled: true, and exits after the response flushes. The orchestrator (systemd, Docker restart: always, k8s) restarts it; the second boot reads platform_secrets and launches uvicorn in HTTPS. The admin reloads over HTTPS and adds the platform CA.

Steady-state rotations (HTTPS → HTTPS) do not restart the process — they use SIGHUP for zero-downtime reload. During the HTTP window the console is fully usable (tenants, OAuth clients, webhooks); only scanner mTLS endpoints are blocked until the platform CA exists (no trust root yet), which is fine since no scanners exist on a fresh install. Once setup is done, the env-seeded admin credentials are no longer read and can be removed.

This matches self-hosted conventions — GitLab (GITLAB_ROOT_PASSWORD), Grafana (GF_SECURITY_ADMIN_USER / GF_SECURITY_ADMIN_PASSWORD), Mattermost, Sentry: boot on plain HTTP, require an explicit cert upload (or reverse-proxy termination) to enable HTTPS.

Expiry alerts. Because the platform does not auto-acquire certs (admin uploads — Pattern B), the operator must be alerted well before any cert expires. Alerts fire on three channels:

  1. Admin console UI banner. SSL Settings + the global header surface a banner when either cert is approaching expiry. The CA banner is high-severity (CA expiry invalidates every device cert in one stroke).
  2. Email to all Platform Admins. A daily background job emails every user with role='platform_admin' at each threshold crossing. No separate notification config to maintain — the existing admin user list is the recipient list. Subject line example: [Link Identity] Server TLS cert expires in 14 days.
  3. Webhook event. A platform-scoped webhook is fired at each threshold crossing (platform.server_cert.expiring, platform.platform_ca.expiring, plus their .expired variants — see §16). Webhook endpoints for platform events are registered by Platform Admin via the standard webhook management API with scope='platform'; the same HMAC signing, retry policy, and audit logging applies as for tenant webhooks.

Threshold schedule. A daily job inspects expires_at for both rows in platform_secrets and fires alerts at the following thresholds. Each threshold fires at most once per cert (tracked by last_alert_threshold_days on the platform_secrets row), so re-uploading the same cert mid-window does not re-spam recipients.

Days remaining Server cert Platform CA
60 UI banner + email + webhook
30 UI banner + email + webhook UI banner + email + webhook
14 email + webhook email + webhook
7 email + webhook email + webhook
1 email + webhook (high-severity) email + webhook (high-severity)
0 (expired) email + webhook (expired event) email + webhook (expired event)

The CA gets an additional 60-day notice because CA rotation is operationally heavier (every device must eventually re-pair) and customers often need lead time to plan.

Audit + idempotency. Each alert that goes out (per channel) is audit-logged as cert_expiry_alert_sent with {kind, channel, threshold_days, fingerprint, recipient_count}. Re-running the daily job is safe — it short-circuits if last_alert_threshold_days is already at or below the current threshold.

Diagram 5.4 — SSL Certificate Management (Admin)

sequenceDiagram
    autonumber
    participant Admin as Platform Admin
    participant Console as Web Console
    participant Identity as Identity Platform
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Admin, Audit: Two flows in this diagram —<br/>(A) upload server cert, (B) generate platform CA

    %% --- (A) Upload server TLS cert ---
    Admin->>Console: Upload server.crt + server.key (PEM)
    Console->>Identity: PUT /v1/admin/ssl/server-cert<br/>Authorization: Bearer <admin token><br/>multipart: cert + key
    Identity->>Identity: Validate PEM format,<br/>verify cert/key pair matches,<br/>extract fingerprint + expiry
    Identity->>DB: UPSERT platform_secrets<br/>(kind='server_cert', cert_pem, private_key_pem,<br/>uploaded_by, uploaded_at, fingerprint)
    Identity->>Identity: Rewrite /tmp/server.{crt,key}<br/>(ephemeral tmpfs)
    Identity->>Identity: Send SIGHUP to uvicorn<br/>(graceful TLS reload, zero downtime)
    Identity->>Audit: Log: server_cert_uploaded<br/>(platform_admin_id, fingerprint, expires_at)
    Identity-->>Console: 200 OK<br/>{fingerprint, expires_at, days_remaining}
    Console-->>Admin: "Server certificate updated"

    Note over Admin, Audit: ────────────────────────

    %% --- (B) Generate platform CA ---
    Admin->>Console: Click "Generate Platform CA"
    Console->>Identity: POST /v1/admin/ssl/ca-cert/generate<br/>Authorization: Bearer <platform-admin token>
    Identity->>Identity: Generate ECDSA P-256 keypair
    Identity->>Identity: Self-sign 10-year CA certificate
    Identity->>DB: UPSERT platform_secrets<br/>(kind='platform_ca', cert_pem, private_key_pem,<br/>uploaded_by, uploaded_at, fingerprint)
    Identity->>Identity: Reload in-memory CA key<br/>(no uvicorn restart — only signer changes)
    Identity->>Audit: Log: platform_ca_generated<br/>(platform_admin_id, fingerprint, expires_at)
    Identity-->>Console: 200 OK<br/>{fingerprint, expires_at, public_cert_pem}
    Console-->>Admin: "Platform CA generated.<br/>Subsequent device pairings sign with this CA."

Diagram 5.4.1 — First-Run SSL Setup

sequenceDiagram
    autonumber
    participant Admin as Platform Admin
    participant Console as Web Console
    participant Backend as Identity Platform
    participant DB as PostgreSQL
    participant Orchestrator as systemd / Docker / k8s

    Note over Backend: First boot. platform_secrets is empty.<br/>Backend listens on plain HTTP (no TLS).

    Backend->>DB: Seed Platform Admin from env vars<br/>(PLATFORM_ADMIN_EMAIL + PLATFORM_ADMIN_INITIAL_PASSWORD)<br/>must_change_password = true

    Admin->>Console: Open admin URL over HTTP
    Admin->>Console: Log in with env-seeded credentials
    Console->>Backend: POST /v1/auth/login (HTTP)
    Backend-->>Console: 200 + session<br/>(must_change_password: true)
    Console-->>Admin: Force password change
    Admin->>Console: Set new password

    Console->>Backend: GET /v1/admin/ssl/status
    Backend->>DB: Any rows in platform_secrets?
    DB-->>Backend: none
    Backend-->>Console: {server_cert_configured: false,<br/>platform_ca_configured: false,<br/>setup_complete: false}

    Console-->>Admin: SSL Settings renders inline setup wizard

    Admin->>Console: Upload server.crt + server.key
    Console->>Backend: PUT /v1/admin/ssl/server-cert (HTTP)
    Backend->>Backend: Parse + validate cert/key pair,<br/>extract fingerprint + expiry
    Backend->>DB: UPSERT platform_secrets<br/>(kind='server_cert', ...)
    Backend-->>Console: 200 OK<br/>{fingerprint, expires_at, restart_scheduled: true}
    Console-->>Admin: "Backend restarting —<br/>reload over HTTPS shortly"

    Backend->>Backend: os._exit(0) after response flush
    Orchestrator->>Backend: Restart process
    Backend->>DB: Read platform_secrets at startup
    Backend->>Backend: Launch uvicorn with<br/>ssl_keyfile + ssl_certfile
    Note over Backend: Backend now serving HTTPS

    Admin->>Console: Reload over HTTPS
    Admin->>Console: Generate or upload platform CA
    Console->>Backend: POST /v1/admin/ssl/ca-cert/generate (see §5.4)
    Backend->>Backend: Load CA private key into memory<br/>(ready to sign device CSRs)

    Console->>Backend: GET /v1/admin/ssl/status
    Backend-->>Console: {server_cert_configured: true,<br/>platform_ca_configured: true,<br/>setup_complete: true}
    Console-->>Admin: Wizard collapses — normal cert management shown

    Note over Admin: Env-seeded admin credentials no longer read by backend.<br/>Can be removed from deployment config<br/>or left harmlessly in place.

9.3 Device → Product Binding

pos, gate, and kiosk devices are bound to exactly one product and action, so that a bare palm scan (which carries no claimed identity) resolves to a single downstream product without per-request ambiguity.

Field Description
device_class personal_scanner | pos | gate | kiosk
bound_product product_key of the linked service this device routes to (e.g. wallet, access) — see §10
bound_action The action the bound product authorizes for this device (e.g. pay, entry)

Binding is set at registration and editable via PUT /v1/devices/{id} (§14.1). personal_scanner devices carry no binding — they receive work via challenges (§9.2). Every device→Identity call (transaction, signup, enroll) authenticates with the device's mTLS cert; the bound {product, action} plus per-request context (amount, gate_id, …) is the intent the broker acts on (§10).

Diagram 5.2 — E-Signature Flow (Scanner Polls for Jobs)

sequenceDiagram
    autonumber
    participant User
    participant IGApp as InvestGlass Web App
    participant IG as InvestGlass Backend
    participant Identity as Identity Platform
    participant Scanner as Personal Scanner
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting

    User->>IGApp: Click "Sign Document"
    IGApp->>IG: POST /documents/{id}/sign

    IG->>Identity: POST /v1/verify<br/>Authorization: Bearer <token><br/>{user_id: "user_456",<br/>device_id: "dev_789",<br/>context: "e_signature",<br/>metadata: {document_id: "doc_123",<br/>document_hash: "sha256:..."}}

    Identity->>DB: Validate user enrolled
    alt User not enrolled
        DB-->>Identity: palm_enrolled: false
        Identity-->>IG: 400 Bad Request<br/>"User not enrolled"
    else User enrolled
        Identity->>DB: Create challenge<br/>(type: verify, user_id,<br/>device_id, status: pending,<br/>expires_at, metadata)
        Identity->>Audit: Log: challenge_created<br/>(challenge_id, user_id,<br/>device_id, context: e_signature)
        Identity-->>IG: {challenge_id: "ch_abc",<br/>status: "pending",<br/>expires_at: "..."}
    end

    IG-->>IGApp: "Please scan your palm<br/>on the scanner at your counter"

    loop Scanner polling (every 2-3 sec)
        Scanner->>Identity: GET /v1/challenges/pending<br/>(mTLS — device cert identifies scanner)

        Identity->>DB: Get device from cert fingerprint
        Identity->>DB: Get pending challenges for device_id

        alt No pending challenges
            Identity-->>Scanner: {challenges: []}
        else Challenge available
            Identity-->>Scanner: {challenges: [{<br/>challenge_id: "ch_abc",<br/>type: "verify",<br/>context: "e_signature",<br/>expires_at: "..."}]}
        end
    end

    Scanner-->>Scanner: Display "Verification needed<br/>for document signing"
    Scanner-->>User: "Place palm to sign document"
    User->>Scanner: Place palm

    Scanner->>Scanner: Capture palm<br/>Extract features

    Scanner->>Identity: POST /v1/challenges/ch_abc/complete<br/>(mTLS — device cert identifies scanner)<br/>{palm_template}

    Identity->>DB: Get challenge, validate not expired
    Identity->>DB: Get user's enrolled palm reference

    Note over Identity, Palm: X-Telcom BioWave Pass has no dedicated 1:1 endpoint.<br/>We use /KZ/query (1:N) and validate the<br/>returned user_id matches the challenge's expected user_id.<br/>For tighter binding, use Section 4.2 to set query_type<br/>per user during enrollment.

    Identity->>Palm: POST /KZ/query<br/>Headers: request_id: <uuid><br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {type: "left",<br/>is_encrypted: false}

    alt Match returned — code: 0
        Palm-->>Identity: {code: 0,<br/>data: {results: [{user_id, id,<br/>query_type, scores: [4 floats]}],<br/>thresholds: [4 floats]}}

        Identity->>Identity: Verify returned user_id ==<br/>challenge.expected_user_id<br/>AND apply tenant score policy

        alt user_id matches & policy passes
            Identity->>DB: Update challenge<br/>(status: completed)
            Identity->>Audit: Log: verification_complete<br/>(challenge_id, user_id,<br/>scores, context: e_signature)

            Identity-->>Scanner: {status: "completed",<br/>user_id, scores, timestamp}
            Scanner-->>User: "Verification successful"

            opt Webhook subscribed
                Identity->>IG: Send webhook: verification.complete<br/>{challenge_id: "ch_abc",<br/>user_id: "user_456",<br/>scores, timestamp,<br/>metadata: {document_id, document_hash}}
            end

            IG->>IG: Mark document signed<br/>Store verification metadata as proof
            IG-->>IGApp: Push notification
            IGApp-->>User: "Document signed!"

        else user_id mismatch (matched a different user)
            Identity->>DB: Increment attempt count
            Identity->>Audit: Log: verification_failed<br/>(challenge_id, attempt,<br/>reason: identity_mismatch,<br/>severity: high)
            Identity-->>Scanner: {status: "failed",<br/>reason: "Identity mismatch"}
            Scanner-->>User: "Wrong user — please try again"
        end

    else No match — platform-decided (scores < thresholds)
        Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds → platform no-match)
        Identity->>DB: Increment attempt count
        Identity->>Audit: Log: verification_failed<br/>(challenge_id, user_id, attempt,<br/>reason: not_found)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Palm not recognized",<br/>attempts_remaining: 2}
        Scanner-->>User: "Please try again"

    else Low confidence — platform-decided
        Palm-->>Identity: {code: 0}<br/>(scores < thresholds → platform low-confidence)
        Identity->>DB: Increment attempt count
        Identity->>Audit: Log: verification_failed<br/>(challenge_id, user_id, attempt,<br/>reason: low_confidence)

        alt Max attempts reached
            Identity->>DB: Update challenge (status: failed)
            Identity-->>Scanner: {status: "failed",<br/>reason: "Max attempts exceeded"}
            opt Webhook subscribed
                Identity->>IG: Send webhook: verification.failed<br/>{user_id, challenge_id, reason}
            end
        else Attempts remaining
            Identity-->>Scanner: {status: "failed",<br/>reason: "Low confidence",<br/>attempts_remaining: 2}
            Scanner-->>User: "Please try again"
        end

    else Query type forbidden — code: 30008
        Note over Palm: User has restriction set<br/>(see Section 4.2)
        Palm-->>Identity: {code: 30008,<br/>msg: "DB query type is forbidden"}
        Identity->>Audit: Log: verification_failed<br/>(challenge_id, reason: query_type_restricted)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Palm verification disabled<br/>for this user"}
    end

Diagram 5.3 — Palm Enrollment at Personal Scanner

sequenceDiagram
    autonumber
    participant Operator as Bank Operator
    participant IGApp as InvestGlass Web App
    participant IG as InvestGlass Backend
    participant Identity as Identity Platform
    participant Scanner as Personal Scanner
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting

    Operator->>IGApp: Select user → "Enroll Palm"
    IGApp->>IG: POST /users/{user_id}/enroll-palm<br/>{device_id, type: "left"}

    IG->>Identity: POST /v1/enroll<br/>Authorization: Bearer <token><br/>(client_credentials)<br/>{user_id, device_id, type: "left"}

    Identity->>DB: Validate user exists,<br/>device paired and active

    rect rgb(255, 245, 230)
        Note over Identity, DB: Compliance gates<br/>(KYC + consent — same as 4.1)
        Identity->>DB: Check kyc_required_for_enrollment<br/>+ consent_required
        alt KYC missing or consent missing
            Identity-->>IG: 403 Forbidden<br/>{error: "kyc_required" or "consent_required"}
            IG-->>IGApp: Display compliance error
        end
    end

    alt User already enrolled with this palm_type
        Identity-->>IG: 409 Conflict<br/>"User already has left palm enrolled"
    else Ready to enroll
        Identity->>DB: Create challenge<br/>(type: enroll, user_id,<br/>device_id, status: pending,<br/>expires_at, metadata: {palm_type})
        Identity->>Audit: Log: challenge_created<br/>(type: enroll, user_id, device_id)

        Identity-->>IG: {challenge_id: "ch_enroll_1",<br/>status: "pending"}
        IG-->>IGApp: "Ask user to place palm<br/>on scanner"
    end

    loop Scanner polling (every 2-3 sec)
        Scanner->>Identity: GET /v1/challenges/pending<br/>(mTLS — device cert identifies scanner)

        alt No pending challenges
            Identity-->>Scanner: {challenges: []}
        else Challenge available
            Identity-->>Scanner: {challenges: [{<br/>challenge_id: "ch_enroll_1",<br/>type: "enroll",<br/>user_id, palm_type: "left"}]}
        end
    end

    Scanner-->>Operator: "Enrollment ready —<br/>ask user to place palm"
    Operator->>Scanner: User places palm
    Scanner->>Scanner: Capture palm image<br/>Extract features (RGB + IR)

    Scanner->>Identity: POST /v1/challenges/ch_enroll_1/complete<br/>(mTLS — device cert identifies scanner)<br/>{palm_template: {features_rgb,<br/>features_ir, image_rgb,<br/>image_ir, type: "left"}}

    Identity->>DB: Get challenge, validate not expired,<br/>type == enroll

    Note over Identity, Palm: Recommended: Section 4.1<br/>pre-enrollment duplicate detection

    Identity->>Palm: POST /KZ/add<br/>Headers: request_id: <uuid><br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {user_id, type: "left",<br/>is_encrypted: false}

    alt Success — code: 0
        Palm-->>Identity: {code: 0, msg: "Success",<br/>data: {user_id, id: 100001,<br/>query_type: "left"}}

        Identity->>DB: Update user<br/>(palm_enrolled: true,<br/>palm_id: 100001, enrolled_at)
        Identity->>DB: Update challenge<br/>(status: completed)
        Identity->>Audit: Log: palm_enrolled<br/>(user_id, palm_id: 100001,<br/>type: left, device_id)
        Identity->>Identity: Publish PalmEnrolled event

        opt Webhook subscribed
            Identity->>IG: Send webhook: enrollment.complete<br/>{user_id, palm_type: "left",<br/>device_id, timestamp}
        end

        Identity-->>Scanner: {status: "enrolled",<br/>user_id, enrolled_at}
        Scanner-->>Operator: "Palm enrolled successfully"

        IG-->>IGApp: Update UI
        IGApp-->>Operator: "User palm enrolled"

    else Duplicate biometrics — code: 30007
        Note over Palm: Different user_id but<br/>matching features in Milvus<br/>(potential identity fraud)
        Palm-->>Identity: {code: 30007,<br/>msg: "DB ID already registered"}
        Identity->>DB: Update challenge (status: failed)
        Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: duplicate_biometrics,<br/>severity: high)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Biometrics already registered"}
        Scanner-->>Operator: "Cannot enroll —<br/>contact support"

    else User+type already exists — code: 30005
        Palm-->>Identity: {code: 30005,<br/>msg: "DB register name duplicated"}
        Identity->>DB: Update challenge (status: failed)
        Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: already_enrolled_for_type)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Already enrolled"}

    else Enrollment failed — code: 30002 (DB insert)
        Palm-->>Identity: {code: 30002,<br/>msg: "DB insert failed"}
        Identity->>DB: Update challenge (status: failed)
        Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: license_capacity)
        Identity->>Identity: Trigger ops alert:<br/>license capacity reached
        Identity-->>Scanner: {status: "failed",<br/>reason: "Service unavailable"}

    else Vendor error — code: 50000
        Palm-->>Identity: {code: 50000,<br/>msg: "Milvus error"}
        Identity->>Audit: Log: palm_vendor_unauthorized<br/>(severity: critical)
        Identity->>Identity: Trigger ops alert
        Identity-->>Scanner: {status: "failed",<br/>reason: "Service unavailable"}
    end