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