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,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"
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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) }
}