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

@@ -100,6 +100,31 @@ class IdentityRepositoryTest {
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
}
/**
* FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12`
* [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up
* by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity"
* flips to the freshly-committed leaf and is presented on the next handshake.
*/
@Test
fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking {
val running = newRepository()
// Touch it while nothing is installed — caches the (null) initial identity.
assertFalse(running.hasInstalledIdentity())
// Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores).
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
// The running repo still shows its stale cache (no restart yet).
assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity())
running.refreshFromStore()
// The refresh re-read the committed live-pointer → the enrolled leaf is now live.
assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity())
assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName)
}
@Test
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
val repository = newRepository()

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

View File

@@ -29,7 +29,6 @@ class DeviceEnrollerTest {
private companion object {
const val BASE = "https://cp.terminal.yaojia.wang"
const val ALIAS = "test-device-key"
const val BEARER = "device-enroll-token-abc"
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
@@ -75,6 +74,7 @@ class DeviceEnrollerTest {
private val certStore = RecordingCertStore(events)
private val recordStore = RecordingRecordStore(events)
private val keyProvider = RecordingKeyProvider(events)
private val refresher = RecordingRefresher(events)
private fun enroller(): DeviceEnroller =
DeviceEnroller(
@@ -84,6 +84,7 @@ class DeviceEnrollerTest {
sharedClient = OkHttpClient(),
keyAlias = ALIAS,
keyProvider = keyProvider,
cacheRefresher = refresher,
)
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
@@ -112,6 +113,22 @@ class DeviceEnrollerTest {
assertEquals(LEAF_CN, summary.subjectCommonName)
}
@Test
fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
// FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so
// the newly enrolled leaf is presented on the next handshake with no restart.
assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll")
assertTrue(
events.indexOf("cert.save") < events.indexOf("cache.refresh"),
"the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)",
)
}
@Test
fun enrollShapesTheLoginAndEnrollRequests() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
@@ -167,7 +184,7 @@ class DeviceEnrollerTest {
@Test
fun renewThrowsWhenNothingIsEnrolled() = runTest {
val error = runCatching { enroller().renew(BEARER) }.exceptionOrNull()
val error = runCatching { enroller().renew() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
}
@@ -176,30 +193,32 @@ class DeviceEnrollerTest {
fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
// keyProvider has no key at ALIAS → load() returns null.
val error = runCatching { enroller().renew(BEARER) }.exceptionOrNull()
val error = runCatching { enroller().renew() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
}
@Test
fun renewReCsrsFromTheSameKeyAndSendsACsrOnlyBody() = runTest {
fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody())
enroller().renew(BEARER)
// FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS).
enroller().renew()
val renew = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-1/renew", renew.url)
assertEquals("Bearer $BEARER", renew.headers["Authorization"])
assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
val body = renew.body!!.decodeToString()
// The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra.
assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR")
assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg")
assertFalse(body.contains("subdomain"), "renew must not send subdomain")
assertFalse(body.contains("deviceName"), "renew must not send deviceName")
// Same commit sequencing on the rotation path: record before cert.
// Same commit sequencing on the rotation path: record before cert, then cache refresh last.
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
}
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
@@ -260,6 +279,12 @@ class DeviceEnrollerTest {
}
}
private class RecordingRefresher(private val events: MutableList<String>) : IdentityCacheRefresher {
override fun refreshFromStore() {
events += "cache.refresh"
}
}
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
private val keys = mutableMapOf<String, HardwareBackedKey>()
val generatedAliases = mutableListOf<String>()