feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)

- wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI +
  host-menu "自动获取证书"), mirroring iOS — Android previously only had manual
  .p12 import; the enroll library was built but unreachable.
- renew is now mTLS-only ({csr}-only body, no Authorization header) matching the
  /device/:id/renew contract (the enroll bearer is minutes-lived → silent
  rotation would have thrown weeks later).
- enroll refreshes the identity-repository cache so a mid-session-enrolled cert
  is presented on the next mTLS handshake without a process restart.
gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify
green. On-device QA (keygen/enroll/present) is the operator's step.
This commit is contained in:
Yaojia Wang
2026-07-19 08:31:29 +02:00
parent c98f5e6a1f
commit 0b35dc043f
16 changed files with 1128 additions and 32 deletions

View File

@@ -39,6 +39,10 @@ public class DeviceEnroller(
private val sharedClient: OkHttpClient,
private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS,
private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider,
// FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no
// process restart. Optional so the JVM orchestration tests can construct the enroller without it.
private val cacheRefresher: IdentityCacheRefresher? = null,
) {
private val commitMutex = Mutex()
@@ -75,19 +79,20 @@ public class DeviceEnroller(
/**
* Silent rotation: re-CSR from the SAME hardware key and replace the leaf via
* `POST /device/:id/renew`. [bearerToken] is supplied by the caller (the app-layer rotation
* scheduler) — the renew-endpoint auth model (mTLS-with-current-cert vs. a fresh bearer) is the
* server's A6 concern, so this method does not bake in a credential policy; it only re-signs and
* re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to renew or the key
* is gone.
* `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over
* mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the
* production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still
* accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy;
* it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to
* renew or the key is gone.
*/
public suspend fun renew(bearerToken: String): CertificateSummary = commitMutex.withLock {
public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock {
val record = recordStore.load()
?: throw EnrollmentStateException("no enrollment record — nothing to renew")
val key = keyProvider.load(record.keyStoreAlias)
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
val csr = CertificateSigningRequest.der(record.deviceName, key)
val result = client.renew(bearerToken, record.deviceId, csr)
val result = client.renew(record.deviceId, csr, bearerToken)
commitIdentity(result, record.deviceName, record.keyStoreAlias)
summaryOf(result)
}
@@ -127,6 +132,10 @@ public class DeviceEnroller(
),
)
sharedClient.connectionPool.evictAll()
// FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the
// newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done
// AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf.
cacheRefresher?.refreshFromStore()
Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'")
}

View File

@@ -56,6 +56,18 @@ public interface IdentityRepository {
public suspend fun remove()
}
/**
* B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory
* identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT
* mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller
* depends only on this one operation (it never needs the import/rotate/remove surface). The production
* implementation is [AndroidIdentityRepository]; a JVM test uses a recording double.
*/
public fun interface IdentityCacheRefresher {
/** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */
public fun refreshFromStore()
}
/**
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
@@ -90,7 +102,7 @@ public class AndroidIdentityRepository(
private val importer: AndroidKeyStoreImporter,
private val certStore: CertStore,
private val sharedClient: OkHttpClient,
) : IdentityRepository {
) : IdentityRepository, IdentityCacheRefresher {
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
@@ -150,6 +162,20 @@ public class AndroidIdentityRepository(
sharedClient.connectionPool.evictAll()
}
/**
* FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a
* device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller]
* writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running
* repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the
* freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf
* present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and
* simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread.
*/
override fun refreshFromStore() {
liveOverride = Box(loadInstalledOrNull())
sharedClient.connectionPool.evictAll()
}
/**
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that