Skip to content

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.