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

@@ -77,11 +77,16 @@ public class DeviceEnrollmentClient(
}
/**
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` under the enroll
* [bearerToken] (silent-rotation seam). Required fields validated client-side before any I/O.
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
*
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
*/
public suspend fun renew(bearerToken: String, deviceId: String, csrDer: ByteArray): EnrollmentResult {
if (bearerToken.isEmpty() || deviceId.isEmpty() || csrDer.isEmpty()) {
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
if (deviceId.isEmpty() || csrDer.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
@@ -106,7 +111,9 @@ public class DeviceEnrollmentClient(
): HttpRequest {
val headers = LinkedHashMap<String, String>()
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
if (bearer != null) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
// string must never emit a bare "Bearer " header.
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
}

View File

@@ -208,20 +208,21 @@ class DeviceEnrollmentClientTest {
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
}
// ── renew (silent rotation seam) ───────────────────────────────────────────────────────────
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
@Test
fun renewTargetsDeviceIdRenewWithTheMinimalBody() = runTest {
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
)
val csr = byteArrayOf(0x02)
val result = client.renew(BEARER, "dev-9", csr)
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
val result = client.renew("dev-9", csr)
val request = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-9/renew", request.url)
assertEquals("Bearer $BEARER", request.headers["Authorization"])
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
val obj = bodyObject(request)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
@@ -234,10 +235,21 @@ class DeviceEnrollmentClientTest {
}
@Test
fun renewRejectsEmptyFieldsBeforeAnyNetworkIo() = runTest {
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", "d", byteArrayOf(1)) }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew(BEARER, "", byteArrayOf(1)) }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew(BEARER, "d", ByteArray(0)) }.exceptionOrNull())
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
)
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
}
@Test
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
assertTrue(transport.recordedRequests.isEmpty())
}