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:
@@ -29,17 +29,34 @@ android {
|
||||
minSdk = 29
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
// The device-enroll orchestration commit logs via android.util.Log — let the JVM unit
|
||||
// tests stub it (return 0) instead of throwing "not mocked". The security-critical paths
|
||||
// (commit sequencing, error handling) run on the JVM with a software key double.
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest
|
||||
// tasks are `Test` tasks, so opt them into the JUnit Platform.
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
|
||||
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
|
||||
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
|
||||
api(project(":client-tls"))
|
||||
// B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in
|
||||
// :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them.
|
||||
implementation(project(":api-client"))
|
||||
implementation(libs.tink.android)
|
||||
implementation(libs.okhttp)
|
||||
// Mutex serializes the two-store rotation commit (single-commit invariant, A11).
|
||||
@@ -50,4 +67,10 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
|
||||
|
||||
// Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a
|
||||
// software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore).
|
||||
testImplementation(project(":test-support"))
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.util.Log
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||
import wang.yaojia.webterm.api.enroll.EnrollmentResult
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
|
||||
|
||||
/**
|
||||
* B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS
|
||||
* `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces:
|
||||
*
|
||||
* 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE),
|
||||
* 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`),
|
||||
* 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`),
|
||||
* 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the
|
||||
* existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING
|
||||
* re-reading `X509KeyManager` mTLS path with no change to that module, and
|
||||
* 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key.
|
||||
*
|
||||
* The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave
|
||||
* the two-store commit (cert live-pointer + enrollment record).
|
||||
*
|
||||
* ### The commit
|
||||
* The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is
|
||||
* written LAST, after the enrollment record, so a successful cert-store save always means the mTLS
|
||||
* identity is fully live; the pool is then evicted so the next handshake presents the new leaf.
|
||||
*/
|
||||
public class DeviceEnroller(
|
||||
private val client: DeviceEnrollmentClient,
|
||||
private val certStore: CertStore,
|
||||
private val recordStore: EnrollmentRecordStore,
|
||||
private val sharedClient: OkHttpClient,
|
||||
private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS,
|
||||
private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider,
|
||||
) {
|
||||
private val commitMutex = Mutex()
|
||||
|
||||
/** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */
|
||||
public class EnrollmentStateException(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate
|
||||
* a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it.
|
||||
* Returns the installed leaf's display summary. The bearer is held only for this call, never
|
||||
* persisted or logged.
|
||||
*/
|
||||
public suspend fun enroll(
|
||||
password: String,
|
||||
subdomain: String,
|
||||
deviceName: String,
|
||||
): CertificateSummary = commitMutex.withLock {
|
||||
val login = client.login(password)
|
||||
// Generate the hardware key ONLY after a successful login, so a rejected credential never
|
||||
// burns a fresh key slot; overwrites any stale key at the alias.
|
||||
val key = keyProvider.generate(keyAlias)
|
||||
try {
|
||||
val csr = CertificateSigningRequest.der(deviceName, key)
|
||||
val result = client.enroll(login.enrollToken, csr, subdomain, deviceName)
|
||||
commitIdentity(result, deviceName, key.alias)
|
||||
summaryOf(result)
|
||||
} catch (e: Exception) {
|
||||
// The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no
|
||||
// unreferenced key lingers in secure hardware.
|
||||
runCatching { keyProvider.delete(key.alias) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent rotation: re-CSR from the SAME hardware key and replace the leaf via
|
||||
* `POST /device/:id/renew`. [bearerToken] is supplied by the caller (the app-layer rotation
|
||||
* scheduler) — the renew-endpoint auth model (mTLS-with-current-cert vs. a fresh bearer) is the
|
||||
* server's A6 concern, so this method does not bake in a credential policy; it only re-signs and
|
||||
* re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to renew or the key
|
||||
* is gone.
|
||||
*/
|
||||
public suspend fun renew(bearerToken: String): CertificateSummary = commitMutex.withLock {
|
||||
val record = recordStore.load()
|
||||
?: throw EnrollmentStateException("no enrollment record — nothing to renew")
|
||||
val key = keyProvider.load(record.keyStoreAlias)
|
||||
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
|
||||
val csr = CertificateSigningRequest.der(record.deviceName, key)
|
||||
val result = client.renew(bearerToken, record.deviceId, csr)
|
||||
commitIdentity(result, record.deviceName, record.keyStoreAlias)
|
||||
summaryOf(result)
|
||||
}
|
||||
|
||||
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
|
||||
public suspend fun remove(): Unit = commitMutex.withLock {
|
||||
certStore.clear()
|
||||
recordStore.clear()
|
||||
keyProvider.delete(keyAlias)
|
||||
sharedClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS
|
||||
* flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via
|
||||
* the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias];
|
||||
* the private key never enters storage.
|
||||
*/
|
||||
private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) {
|
||||
val leaf = parseCertificate(result.certificate)
|
||||
val issuers = result.caChain.map { parseCertificate(it) }
|
||||
|
||||
recordStore.save(
|
||||
EnrollmentRecord(
|
||||
deviceId = result.deviceId,
|
||||
deviceName = deviceName,
|
||||
keyStoreAlias = alias,
|
||||
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
|
||||
),
|
||||
)
|
||||
certStore.save(
|
||||
StoredIdentityMetadata(
|
||||
alias = alias,
|
||||
keyAlgorithm = KEY_ALGORITHM_EC,
|
||||
keyStoreAlias = alias,
|
||||
certificateChain = listOf(leaf) + issuers,
|
||||
),
|
||||
)
|
||||
sharedClient.connectionPool.evictAll()
|
||||
Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'")
|
||||
}
|
||||
|
||||
private fun summaryOf(result: EnrollmentResult): CertificateSummary =
|
||||
CertificateSummaryReader.summarize(parseCertificate(result.certificate))
|
||||
|
||||
private fun parseCertificate(der: ByteArray): X509Certificate =
|
||||
CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DeviceEnroller"
|
||||
const val X509 = "X.509"
|
||||
|
||||
/** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */
|
||||
const val KEY_ALGORITHM_EC = "EC"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
/**
|
||||
* B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s
|
||||
* enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed
|
||||
* [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the
|
||||
* enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing)
|
||||
* can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this
|
||||
* seam beyond generate/load/delete — the key stays non-exportable in the real implementation.
|
||||
*/
|
||||
public interface DeviceKeyProvider {
|
||||
/** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */
|
||||
public fun generate(alias: String): HardwareBackedKey
|
||||
|
||||
/** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */
|
||||
public fun load(alias: String): HardwareBackedKey?
|
||||
|
||||
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
|
||||
public fun delete(alias: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed
|
||||
* [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor
|
||||
* default without any wiring.
|
||||
*/
|
||||
public object HardwareDeviceKeyProvider : DeviceKeyProvider {
|
||||
override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias)
|
||||
|
||||
override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias)
|
||||
|
||||
override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import com.google.crypto.tink.Aead
|
||||
import com.google.crypto.tink.KeyTemplates
|
||||
import com.google.crypto.tink.RegistryConfiguration
|
||||
import com.google.crypto.tink.aead.AeadConfig
|
||||
import com.google.crypto.tink.integration.android.AndroidKeysetManager
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
|
||||
/**
|
||||
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
|
||||
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
|
||||
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
|
||||
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
|
||||
*
|
||||
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
|
||||
* identity is what the handshake presents; this record only exists so renew can find the device and
|
||||
* its key. The private key is never here — it stays non-exportable in AndroidKeyStore.
|
||||
*/
|
||||
public data class EnrollmentRecord(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val keyStoreAlias: String,
|
||||
val renewAfterEpochSeconds: Long,
|
||||
) {
|
||||
init {
|
||||
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
|
||||
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
|
||||
public object EnrollmentRecordCodec {
|
||||
public fun encode(record: EnrollmentRecord): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
DataOutputStream(out).use { data ->
|
||||
data.writeUTF(record.deviceId)
|
||||
data.writeUTF(record.deviceName)
|
||||
data.writeUTF(record.keyStoreAlias)
|
||||
data.writeLong(record.renewAfterEpochSeconds)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */
|
||||
public fun decode(bytes: ByteArray): EnrollmentRecord =
|
||||
try {
|
||||
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
|
||||
EnrollmentRecord(
|
||||
deviceId = data.readUTF(),
|
||||
deviceName = data.readUTF(),
|
||||
keyStoreAlias = data.readUTF(),
|
||||
renewAfterEpochSeconds = data.readLong(),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on
|
||||
* the operation set and a fault/blank can be injected in tests. Idempotent [clear].
|
||||
*/
|
||||
public interface EnrollmentRecordStore {
|
||||
public fun save(record: EnrollmentRecord)
|
||||
|
||||
public fun load(): EnrollmentRecord?
|
||||
|
||||
public fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring
|
||||
* [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off
|
||||
* this device). Kept in its own key/file namespace so it never collides with the cert live-pointer.
|
||||
*/
|
||||
public class TinkEnrollmentRecordStore(
|
||||
context: Context,
|
||||
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||
) : EnrollmentRecordStore {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val aead: Aead by lazy { buildAead() }
|
||||
|
||||
override fun save(record: EnrollmentRecord) {
|
||||
val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA)
|
||||
val committed = prefs().edit()
|
||||
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
||||
.commit()
|
||||
if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record")
|
||||
}
|
||||
|
||||
override fun load(): EnrollmentRecord? {
|
||||
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
|
||||
val ciphertext = try {
|
||||
Base64.decode(encoded, Base64.NO_WRAP)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e)
|
||||
}
|
||||
val plaintext = try {
|
||||
aead.decrypt(ciphertext, ASSOCIATED_DATA)
|
||||
} catch (e: java.security.GeneralSecurityException) {
|
||||
throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e)
|
||||
}
|
||||
return EnrollmentRecordCodec.decode(plaintext)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
val committed = prefs().edit().remove(BLOB_KEY).commit()
|
||||
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
|
||||
}
|
||||
|
||||
private fun buildAead(): Aead {
|
||||
AeadConfig.register()
|
||||
val keysetHandle = AndroidKeysetManager.Builder()
|
||||
.withSharedPref(appContext, keysetName, prefFileName)
|
||||
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
|
||||
.withMasterKeyUri(masterKeyUri)
|
||||
.build()
|
||||
.keysetHandle
|
||||
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
|
||||
public companion object {
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "enrollment_record_blob"
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.security.keystore.StrongBoxUnavailableException
|
||||
import android.util.Log
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.Signature
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import wang.yaojia.webterm.api.enroll.CsrSigner
|
||||
import wang.yaojia.webterm.api.enroll.EcPointEncoding
|
||||
|
||||
/**
|
||||
* B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by
|
||||
* construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS
|
||||
* `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same
|
||||
* `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers
|
||||
* (`CertificateSigningRequest`) are exercised identically.
|
||||
*
|
||||
* The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading
|
||||
* `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to
|
||||
* emit the CSR's `SubjectPublicKeyInfo`.
|
||||
*/
|
||||
public class HardwareBackedKey internal constructor(
|
||||
public val alias: String,
|
||||
private val privateKey: PrivateKey,
|
||||
private val publicKey: ECPublicKey,
|
||||
) : CsrSigner {
|
||||
|
||||
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey)
|
||||
|
||||
override fun sign(message: ByteArray): ByteArray =
|
||||
Signature.getInstance(SIGNATURE_ALGORITHM).apply {
|
||||
initSign(privateKey)
|
||||
update(message)
|
||||
}.sign()
|
||||
|
||||
/** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */
|
||||
public val keyHandle: PrivateKey get() = privateKey
|
||||
|
||||
public companion object {
|
||||
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore.
|
||||
*
|
||||
* Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the
|
||||
* device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing)
|
||||
* is identical either way; StrongBox is a hardening bonus, not a requirement. The key is
|
||||
* `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client
|
||||
* `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never
|
||||
* blocks on a biometric prompt.
|
||||
*/
|
||||
public object HardwareKeyStore {
|
||||
private const val TAG = "HardwareKeyStore"
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val CURVE = "secp256r1"
|
||||
|
||||
/**
|
||||
* Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there.
|
||||
* StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if
|
||||
* BOTH paths fail (never returns a half-generated key).
|
||||
*/
|
||||
public fun generate(alias: String): HardwareBackedKey {
|
||||
val keyPair = try {
|
||||
generateKeyPair(alias, strongBox = true)
|
||||
} catch (_: StrongBoxUnavailableException) {
|
||||
Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)")
|
||||
delete(alias) // clear any partial StrongBox entry before the TEE retry
|
||||
generateKeyPair(alias, strongBox = false)
|
||||
}
|
||||
return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a previously-generated key by [alias] (renew path, after relaunch). The public key is
|
||||
* recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation.
|
||||
* Returns null if no key entry exists (the normal pre-enroll state).
|
||||
*/
|
||||
public fun load(alias: String): HardwareBackedKey? {
|
||||
val keyStore = androidKeyStore()
|
||||
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null
|
||||
val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey
|
||||
?: return null
|
||||
return HardwareBackedKey(alias, privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
|
||||
public fun delete(alias: String) {
|
||||
val keyStore = androidKeyStore()
|
||||
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||
}
|
||||
|
||||
/** Cheap existence check (does NOT read key material). */
|
||||
public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias)
|
||||
|
||||
private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair {
|
||||
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
|
||||
.setDigests(
|
||||
KeyProperties.DIGEST_NONE,
|
||||
KeyProperties.DIGEST_SHA256,
|
||||
KeyProperties.DIGEST_SHA384,
|
||||
KeyProperties.DIGEST_SHA512,
|
||||
)
|
||||
.setIsStrongBoxBacked(strongBox)
|
||||
.build()
|
||||
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
|
||||
generator.initialize(spec)
|
||||
return generator.generateKeyPair()
|
||||
}
|
||||
|
||||
private fun androidKeyStore(): KeyStore =
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
}
|
||||
@@ -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