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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user