21. Appendix¶
21.1 Key Decisions¶
| Decision | Outcome | Reason |
|---|---|---|
| Auth model | OAuth + custom grants | Unified |
| KYC architecture | Ports & adapters | Pluggable KYC providers |
| Palm architecture | Ports & adapters | Pluggable palm vendors |
| Repo structure | Hexagonal | Clean separation |
| Social login | Google + Apple | User convenience |
| Account linking | Auto-link | Seamless UX |
| Tenant model | All capabilities available to all tenants | No tenant types — each tenant configures individually |
| Access control | RBAC with 5 roles | Clear separation between platform ops, tenant management, and API access |
| Tenant isolation | Logical (tenant_id on all entities) | Simpler than physical separation, sufficient for security requirements |
| Console users | Separate from end users | Avoids conflating managed identities with platform operators |
| Full OIDC | Not included | Post-MVP. May be added when third-party 'Login with Link' is needed. |
21.2 Success Metrics¶
| Metric | Target |
|---|---|
| Token issuance | < 100ms |
| KYC initiation | < 500ms |
| Identification (1:N) | < 2s |
| Uptime | 99.9% |
21.3 Open Items¶
| Item | Status |
|---|---|
| Nafath integration | TBD |
| Google/Apple setup | TBD |
| SMS provider | TBD |
| Password policy | TBD |
21.4 Tenant Config Checks in Flows¶
| Flow | Config Checks |
|---|---|
| OTP Signup | auth_methods includes "otp" |
| Password Signup | auth_methods includes "password" |
| Google/Apple Login | auth_methods includes "google"/"apple" |
| KYC | kyc_provider configured |
| Palm Enrollment | kyc_required_for_enrollment, consent_required |
| Data Export | data_subject_rights_enabled |
| User Deletion | data_subject_rights_enabled |
21.5 Scanner Client Implementation Reference (Android)¶
Status: Implementation reference, not normative spec. The main spec (§9.2.1) defines what the scanner must produce — a P-256 keypair plus a PKCS#10 CSR signed by that keypair, with a SAN URI matching the canonical format. This appendix documents how to produce those artifacts on the Android-based VP930Pro scanner hardware, for the Android developer who will write the scanner client.
The personal scanner is the HFSecurity VP930Pro running Android. A developer implementing the scanner client needs three pieces of platform-specific code; everything else (pairing-code prompt UI, the HTTP POST, the cert storage layout) is unconstrained.
1. Hardware-backed keypair generation
Use Android's KeyPairGenerator with the AndroidKeyStore provider. The keypair is generated inside the secure element (StrongBox on devices that support it; the TEE-backed Keymaster otherwise). Once generated, the private key is referenced only by its alias — the raw key bytes are not accessible to app code, the OS, or any debugger.
Required KeyGenParameterSpec configuration:
| Parameter | Value |
|---|---|
| Algorithm | KeyProperties.KEY_ALGORITHM_EC |
| Curve | secp256r1 (P-256) via ECGenParameterSpec("secp256r1") |
| Purposes | PURPOSE_SIGN \| PURPOSE_VERIFY |
| Digests | KeyProperties.DIGEST_SHA256 |
| StrongBox-backed | setIsStrongBoxBacked(true) |
| Alias | A stable string the app uses to retrieve the entry later — e.g., link.identity.device.<device_uuid>. Persist this alias in app prefs alongside the eventual cert. |
If setIsStrongBoxBacked(true) fails on devices without a discrete secure element, the pairing flow should abort and surface a hardware-unsupported error. StrongBox is required — no software-keystore fallback in v1.
2. CSR construction
Android's stdlib has no built-in PKCS#10 CSR builder. Use Bouncy Castle (bcpkix-jdk18on). The critical detail is that the ContentSigner must be wired to the StrongBox-backed PrivateKey retrieved from AndroidKeyStore — that way the signing operation runs inside the secure element and the private key never crosses into app memory:
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val privateKey = (keyStore.getEntry(alias, null) as KeyStore.PrivateKeyEntry).privateKey
val publicKey = keyStore.getCertificate(alias).publicKey
val signer = JcaContentSignerBuilder("SHA256withECDSA").build(privateKey)
val csr = JcaPKCS10CertificationRequestBuilder(
X500Name("CN=placeholder"), // server overwrites this
publicKey
).build(signer)
val pem = StringWriter().apply { JcaPEMWriter(this).use { it.writeObject(csr) } }.toString()
Fields the server cares about:
- SubjectPublicKeyInfo — the public key half of the StrongBox keypair
- The CSR's outer signature — proof-of-possession of the private key
Fields the server discards:
- Any client-supplied Subject DN — the server overwrites with the canonical CN={device_id} and adds the SAN URI.
- Any non-permitted extensions (see §9.2.1 CSR validation rules).
Send the resulting PEM string in the csr field of the POST /v1/devices/pair JSON body.
3. Cert storage after pairing succeeds
The server returns {certificate, ca_chain, expires_at} — both PEMs are public material, not secrets. The actual secret (the private key) is already in StrongBox.
Recommended:
- Store leaf cert + CA chain in app-local files or EncryptedSharedPreferences. Either is fine; the encryption layer is belt-and-suspenders since the cert isn't sensitive.
- At app startup, load both into an in-memory KeyManager / TrustManager pair for OkHttp's SSLSocketFactory. The TLS stack will automatically pull the private key out of StrongBox when it needs to sign the CertificateVerify message during each mTLS handshake — there's no application code involved in the per-request signing.
Reuse and renewal lifecycle
The StrongBox keypair lives for the lifetime of the cert (90 days). On renewal:
1. Generate a new keypair under a new alias (link.identity.device.<device_uuid>.<renewal_n>).
2. Build a new CSR signed by the new private key.
3. POST /v1/devices/renew over mTLS using the current valid cert.
4. On success, swap the stored cert + chain to the new ones and call keyStore.deleteEntry(oldAlias) to wipe the old key from StrongBox.
If renewal fails (server unreachable, denylist hit, etc.), keep using the old keypair until either renewal succeeds or the cert expires — the scanner is not stranded.
Loss-of-key recovery
Factory reset, app uninstall, or any operation that wipes the AndroidKeyStore makes the device's keypair permanently unrecoverable. The scanner must initiate a fresh pairing flow with a new pairing code from the admin; the stranded cert on the server side is revoked. There is no rekey-without-pairing path by design — that would defeat the proof-of-possession property.