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