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,138 @@
package wang.yaojia.webterm.tlsandroid
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.security.Signature
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
/**
* B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated
* device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the
* control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA
* (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE).
*/
@RunWith(AndroidJUnit4::class)
class HardwareBackedKeyTest {
private val alias = "test-device-enroll-key"
@Before
fun clean() = HardwareKeyStore.delete(alias)
@After
fun tearDown() = HardwareKeyStore.delete(alias)
@Test
fun generate_producesA65ByteX963PublicPoint() {
val key = HardwareKeyStore.generate(alias)
val point = key.publicKeyX963()
assertEquals(65, point.size)
assertEquals(0x04, point[0].toInt() and 0xFF)
}
@Test
fun generatedKeyIsNonExportable() {
HardwareKeyStore.generate(alias)
val loaded = HardwareKeyStore.load(alias)
assertNotNull(loaded)
// AndroidKeyStore private keys have no exportable encoding — the material never leaves HW.
assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded)
}
@Test
fun csrSignedByHardwareKeySelfVerifies() {
val key = HardwareKeyStore.generate(alias)
val der = CertificateSigningRequest.der("t1-android", key)
// Re-parse the CertificationRequestInfo + signature and verify with the embedded public key.
val outer = TestDer.read(der, 0)!!
val parts = TestDer.children(der, outer)
val info = der.copyOfRange(parts[0].start, parts[0].end)
val bitString = parts[2]
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
// Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does.
val point = key.publicKeyX963()
val pub = X963PublicKeys.p256(point)
val ok = Signature.getInstance("SHA256withECDSA").apply {
initVerify(pub)
update(info)
}.verify(signature)
assertTrue("hardware-signed CSR must self-verify", ok)
}
@Test
fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() {
val generated = HardwareKeyStore.generate(alias)
val reloaded = HardwareKeyStore.load(alias)
assertNotNull(reloaded)
assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963())
}
@Test
fun loadReturnsNullWhenNoKeyExists() {
assertNull(HardwareKeyStore.load("absent-alias-xyz"))
}
}
/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */
private object X963PublicKeys {
fun p256(point: ByteArray): java.security.PublicKey {
val params = java.security.AlgorithmParameters.getInstance("EC").apply {
init(java.security.spec.ECGenParameterSpec("secp256r1"))
}
val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
val x = java.math.BigInteger(1, point.copyOfRange(1, 33))
val y = java.math.BigInteger(1, point.copyOfRange(33, 65))
val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec)
return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec)
}
}
/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */
private object TestDer {
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
val end: Int get() = valueEnd
}
fun read(bytes: ByteArray, start: Int): Element? {
if (start < 0 || start + 1 >= bytes.size) return null
val tag = bytes[start].toInt() and 0xFF
var index = start + 1
val first = bytes[index].toInt() and 0xFF
index += 1
var length = 0
if (first and 0x80 == 0) {
length = first
} else {
val count = first and 0x7F
if (count == 0 || count > 4 || index + count > bytes.size) return null
repeat(count) {
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
index += 1
}
}
val valueEnd = index + length
if (valueEnd > bytes.size) return null
return Element(tag, start, index, valueEnd)
}
fun children(bytes: ByteArray, parent: Element): List<Element> {
val elements = mutableListOf<Element>()
var index = parent.valueStart
while (index < parent.valueEnd) {
val element = read(bytes, index) ?: break
elements.add(element)
index = element.valueEnd
}
return elements
}
}