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:
@@ -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()
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user