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()