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