feat(android): device enrollment library + rotation (B4)

Hardware-backed (StrongBox/TEE) key + PKCS#10 CSR + /device/enroll client in
:api-client, presented via the existing X509KeyManager; renew body {csr}-only;
DeviceKeyProvider seam makes the orchestration JVM-testable. api-client tests +
koverVerify 80% gate pass.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 07bcbf0c08
commit 5e427dcf98
15 changed files with 1981 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
package wang.yaojia.webterm.tlsandroid
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
* the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider]
* double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most
* importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore.
*
* The security-critical invariant under test: the enrollment record is persisted BEFORE the cert
* live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is
* fully live (see [DeviceEnroller.commitIdentity]).
*/
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.
const val LEAF_CN = "t1-device"
const val CA_CN = "webterm-device-ca"
const val LEAF_B64 =
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
"cdoleWDlqcMKU"
const val CA_B64 =
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
fun loginBody(): ByteArray =
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
"""
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
"renewAfter":"2026-09-05T00:00:00.000Z"}
""".trimIndent().toByteArray()
fun softwareKey(alias: String): HardwareBackedKey {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val kp = kpg.generateKeyPair()
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
}
}
private val events = mutableListOf<String>()
private val transport = FakeHttpTransport()
private val certStore = RecordingCertStore(events)
private val recordStore = RecordingRecordStore(events)
private val keyProvider = RecordingKeyProvider(events)
private fun enroller(): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(BASE, transport),
certStore = certStore,
recordStore = recordStore,
sharedClient = OkHttpClient(),
keyAlias = ALIAS,
keyProvider = keyProvider,
)
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
@Test
fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
// Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST.
assertTrue(events.contains("record.save") && events.contains("cert.save"))
assertTrue(
events.indexOf("record.save") < events.indexOf("cert.save"),
"the enrollment record must be committed BEFORE the cert live-pointer flip",
)
// Both stores received the issued identity; the stored chain is leaf + issuer (from caChain).
assertEquals("dev-1", recordStore.saved!!.deviceId)
assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias)
val chain = certStore.saved!!.certificateChain
assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer")
assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf")
assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer")
// The install summary is read off the leaf via the production CertificateSummaryReader.
assertEquals(LEAF_CN, summary.subjectCommonName)
}
@Test
fun enrollShapesTheLoginAndEnrollRequests() = 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")
val login = transport.recordedRequests[0]
assertEquals("$BASE/auth/login", login.url)
assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\""))
val enroll = transport.recordedRequests[1]
assertEquals("$BASE/device/enroll", enroll.url)
assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer")
val enrollBodyStr = enroll.body!!.decodeToString()
assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\""))
assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\""))
}
// ── enroll: error handling ─────────────────────────────────────────────────────────────────
@Test
fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray())
val error = runCatching {
enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel")
}.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error)
assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login")
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure")
assertNull(recordStore.saved, "no record is committed when enroll fails")
assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails")
}
@Test
fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray())
val error = runCatching {
enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel")
}.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot")
assertNull(recordStore.saved)
assertNull(certStore.saved)
}
// ── renew: preconditions + request shaping + commit ────────────────────────────────────────
@Test
fun renewThrowsWhenNothingIsEnrolled() = runTest {
val error = runCatching { enroller().renew(BEARER) }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
}
@Test
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()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
}
@Test
fun renewReCsrsFromTheSameKeyAndSendsACsrOnlyBody() = 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)
val renew = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-1/renew", renew.url)
assertEquals("Bearer $BEARER", renew.headers["Authorization"])
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.
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
}
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
@Test
fun removeClearsBothStoresAndDeletesTheKey() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
enroller().remove()
assertTrue(certStore.cleared)
assertTrue(recordStore.cleared)
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove")
}
// ── recording doubles ──────────────────────────────────────────────────────────────────────
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
var saved: StoredIdentityMetadata? = null
var cleared = false
override fun save(metadata: StoredIdentityMetadata) {
saved = metadata
events += "cert.save"
}
override fun load(): StoredIdentityMetadata? = saved
override fun clear() {
cleared = true
saved = null
events += "cert.clear"
}
}
private class RecordingRecordStore(private val events: MutableList<String>) : EnrollmentRecordStore {
var saved: EnrollmentRecord? = null
var cleared = false
private var current: EnrollmentRecord? = null
fun seed(record: EnrollmentRecord) {
current = record
}
override fun save(record: EnrollmentRecord) {
saved = record
current = record
events += "record.save"
}
override fun load(): EnrollmentRecord? = current
override fun clear() {
cleared = true
current = null
events += "record.clear"
}
}
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
private val keys = mutableMapOf<String, HardwareBackedKey>()
val generatedAliases = mutableListOf<String>()
val deletedAliases = mutableListOf<String>()
fun seed(alias: String, key: HardwareBackedKey) {
keys[alias] = key
}
override fun generate(alias: String): HardwareBackedKey {
val key = softwareKey(alias)
keys[alias] = key
generatedAliases += alias
events += "generate:$alias"
return key
}
override fun load(alias: String): HardwareBackedKey? = keys[alias]
override fun delete(alias: String) {
keys.remove(alias)
deletedAliases += alias
events += "delete:$alias"
}
}
}