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:
- Reject if the public key algorithm is not ECDSA P-256.
- Strip the client-supplied Subject DN entirely; the server constructs the canonical CN from the pairing context.
- Reject any extension that would escalate privileges, including
BasicConstraints CA:true, additionalExtKeyUsagevalues beyondclientAuth, 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:
- Verifies the client cert was signed by the platform CA and is not in the fingerprint denylist.
- Parses the SAN URI to extract
device_idandtenant_id. - Re-checks
devices.status = 'paired'per request (belt-and-suspenders defense against in-flight revocation propagation lag). - Attaches
device_idandtenant_idto 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:
- The scanner opens a TLS connection. The server presents its uploaded TLS cert plus a
CertificateRequest(ssl_cert_reqs=CERT_OPTIONAL, withssl_ca_certspointing to the platform CA public cert). - The scanner replies with its leaf cert + chain and a
CertificateVerifymessage, signing the handshake transcript with its StrongBox-held private key. - 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.
- 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.statuscheck 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_idis 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:
- 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. - The console calls
GET /v1/admin/ssl/status→{server_cert_configured: bool, platform_ca_configured: bool, setup_complete: bool}. Whileserver_cert_configuredisfalse, the page shows an inline wizard to upload a server TLS cert and to generate (or upload) the platform CA. - On the first server-cert upload (
PUT /v1/admin/ssl/server-cert), the backend persists it, returns200 OKwithrestart_scheduled: true, and exits after the response flushes. The orchestrator (systemd, Dockerrestart: always, k8s) restarts it; the second boot readsplatform_secretsand 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:
- 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).
- 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. - Webhook event. A platform-scoped webhook is fired at each threshold crossing (
platform.server_cert.expiring,platform.platform_ca.expiring, plus their.expiredvariants — see §16). Webhook endpoints for platform events are registered by Platform Admin via the standard webhook management API withscope='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