feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View File

@@ -0,0 +1,73 @@
package wang.yaojia.webterm.tlsandroid
import java.security.UnrecoverableKeyException
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import androidx.test.ext.junit.runners.AndroidJUnit4
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
import wang.yaojia.webterm.clienttls.NoClientIdentityException
/**
* Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) tests for the framework import path.
* COMPILES in CI here; RUNS on a device/emulator during device QA.
*/
@RunWith(AndroidJUnit4::class)
class AndroidKeyStoreImporterTest {
private val alias = "test-device-identity"
private val importer = AndroidKeyStoreImporter(alias = alias)
@Before
fun clean() = importer.remove()
@After
fun tearDown() = importer.remove()
@Test
fun importHappyPath_importsRetrievableNonExportableKey() {
val parsed = importer.import(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertEquals("RSA", parsed.keyAlgorithm)
assertEquals(Fixtures.LEAF_SUBJECT_CN, parsed.summary().subjectCommonName)
assertEquals(Fixtures.LEAF_ISSUER_CN, parsed.summary().issuerCommonName)
assertTrue("key entry present after import", importer.hasInstalledKey())
assertNotNull("AndroidKeyStore key handle readable", importer.loadPrivateKey())
val chain = importer.loadCertificateChain()
assertNotNull("cert chain stored alongside key", chain)
assertTrue("chain has at least the leaf", chain!!.isNotEmpty())
}
@Test
fun certsOnlyP12_mapsToNoClientIdentity_andImportsNothing() {
assertThrows(NoClientIdentityException::class.java) {
importer.import(Fixtures.trustP12(), Fixtures.PASSPHRASE)
}
assertFalse("nothing persisted for a certs-only .p12", importer.hasInstalledKey())
}
@Test
fun importValidatesBeforePersist_badPassphraseCannotClobberPriorIdentity() {
// Install a good identity first.
importer.import(Fixtures.leafP12(), Fixtures.PASSPHRASE)
// A wrong passphrase must throw during PARSE (before any AndroidKeyStore mutation).
assertThrows(UnrecoverableKeyException::class.java) {
importer.import(Fixtures.leafP12(), Fixtures.WRONG_PASSPHRASE)
}
// The prior identity must still be present AND unchanged after the failed import.
assertTrue("prior key survives a failed import", importer.hasInstalledKey())
val leafCnAfter = importer.loadCertificateChain()!!.first().let {
CertificateSummaryReader.summarize(it).subjectCommonName
}
assertEquals(Fixtures.LEAF_SUBJECT_CN, leafCnAfter)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,209 @@
package wang.yaojia.webterm.tlsandroid
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.clienttls.Pkcs12Parse
/**
* Instrumented (real AndroidKeyStore + Tink — plan §7) tests for the re-reading mTLS seam.
* COMPILES here; RUNS on a device/emulator during device QA.
*/
@RunWith(AndroidJUnit4::class)
class IdentityRepositoryTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
private val alias = "test-repo-identity"
private val prefFile = "webterm_client_tls_test_prefs"
private val keysetName = "webterm_cert_test_keyset"
private val masterKeyUri = "android-keystore://webterm_cert_test_master_key"
private lateinit var importer: AndroidKeyStoreImporter
private lateinit var certStore: TinkCertStore
private lateinit var client: OkHttpClient
@Before
fun setUp() {
importer = AndroidKeyStoreImporter(alias = alias)
certStore = TinkCertStore(context, keysetName, prefFile, masterKeyUri)
client = OkHttpClient()
wipe()
}
@After
fun tearDown() = wipe()
/** Both ping-pong slots + the pointer, so no state leaks across tests. */
private fun wipe() {
importer.remove(importer.primarySlot)
importer.remove(importer.secondarySlot)
certStore.clear()
}
private fun newRepository(store: CertStore = certStore): AndroidIdentityRepository =
AndroidIdentityRepository(importer, store, client)
@Test
fun reReadingKeyManager_presentsMidRunImportedCertOnNextHandshake() {
// A mutable holder stands in for the repository's live identity source.
var installed: InstalledIdentity? = null
val keyManager = ReReadingX509KeyManager { installed }
// No identity → present nothing (a clean, classifiable handshake failure, never a wrong cert).
assertNull(keyManager.chooseClientAlias(arrayOf("RSA"), null, null))
assertNull(keyManager.getPrivateKey(alias))
// Simulate a mid-run import (no relaunch, no factory rebuild).
val parsed = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
installed = InstalledIdentity(
alias = parsed.alias,
keyAlgorithm = parsed.keyAlgorithm,
privateKey = parsed.privateKey,
leafCertificate = parsed.leafCertificate,
issuerCertificates = parsed.issuerCertificates,
)
// The very next lookup re-reads the source and presents the freshly-imported identity.
assertEquals(parsed.alias, keyManager.chooseClientAlias(arrayOf("RSA"), null, null))
assertEquals(parsed.alias, keyManager.chooseEngineClientAlias(arrayOf("RSA"), null, null))
assertNotNull(keyManager.getPrivateKey(parsed.alias))
assertNotNull(keyManager.getCertificateChain(parsed.alias))
// A foreign alias never leaks our key material.
assertNull(keyManager.getPrivateKey("someone-else"))
}
@Test
fun importPublishesIdentityAndSummary() = runBlocking {
val repository = newRepository()
assertFalse(repository.hasInstalledIdentity())
assertNotNull("SSL material installed even with no cert", repository.sslMaterial().sslSocketFactory)
val summary = repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertEquals(Fixtures.LEAF_SUBJECT_CN, summary.subjectCommonName)
assertEquals(Fixtures.LEAF_ISSUER_CN, summary.issuerCommonName)
assertTrue(repository.hasInstalledIdentity())
assertEquals(Fixtures.LEAF_SUBJECT_CN, repository.currentSummary()?.subjectCommonName)
// First install lands on the primary slot; the pointer names it (single source of truth).
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
}
@Test
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
val repository = newRepository()
repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertTrue(repository.hasInstalledIdentity())
// Rotation stages into the OTHER slot and evicts pooled/resumed connections.
repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertTrue(repository.hasInstalledIdentity())
assertEquals(importer.secondarySlot, certStore.load()?.keyStoreAlias)
assertEquals(0, client.connectionPool.connectionCount())
// Removal clears both stores and evicts again so nothing reuses the old identity.
// (A populated pool needs a live TLS server → the no-reuse proof is the A34 E2E; here we
// assert eviction ran and left the pool empty + the identity cleared.)
repository.remove()
assertFalse(repository.hasInstalledIdentity())
assertNull(repository.currentSummary())
assertNull(certStore.load())
assertEquals(0, client.connectionPool.connectionCount())
}
/**
* The single-commit invariant (plan §8, A11): a rotation that fails AT the commit step must leave
* the prior identity fully live and the two stores consistent (no half-applied new identity), and
* a later successful install must fully replace it.
*/
@Test
fun failedCommit_keepsPriorIdentityLive_thenSuccessfulInstallReplacesIt() = runBlocking {
val failing = FailingOnceCertStore(certStore)
val repository = newRepository(failing)
// Install identity A (lands on the primary slot; pointer names it).
repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
val slotA = certStore.load()!!.keyStoreAlias
assertEquals(importer.primarySlot, slotA)
assertNotNull("A's key present after install", importer.loadPrivateKey(slotA))
// Attempt to rotate to B, injecting a failure at the COMMIT (metadata save) step.
failing.failNextSave = true
assertThrows(InjectedCommitFailure::class.java) {
runBlocking { repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE) }
}
// (a) prior intact → the re-reading KeyManager (reads the same live view) still presents A.
assertTrue("prior identity still live after a failed rotation", repository.hasInstalledIdentity())
assertEquals(Fixtures.LEAF_SUBJECT_CN, repository.currentSummary()?.subjectCommonName)
// (b) stores consistent: the pointer still names A's slot; the staged slot holds NO half-B key.
assertEquals(slotA, certStore.load()!!.keyStoreAlias)
assertNotNull("A's key untouched by the failed rotation", importer.loadPrivateKey(slotA))
assertNull("no half-written B key in the staging slot", importer.loadPrivateKey(importer.secondarySlot))
// (c) a subsequent successful install of B fully replaces A.
failing.failNextSave = false
repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertTrue(repository.hasInstalledIdentity())
assertEquals("pointer flipped to the other slot", importer.secondarySlot, certStore.load()!!.keyStoreAlias)
assertNotNull("B's key now present", importer.loadPrivateKey(importer.secondarySlot))
assertNull("A's superseded key GC'd", importer.loadPrivateKey(slotA))
}
/**
* Regression for the `currentLive()` single-source-of-truth bug: after [remove], the repository
* must report NO identity even when the lazily-loaded `initialIdentity` was already resolved to a
* real value at startup (the normal relaunch-then-remove flow). The old `liveOverride?.value ?:
* initialIdentity` collapsed the post-remove `Box(null)` back to the stale cached identity, so a
* removed cert kept being reported live and would be presented in a handshake with a dangling key.
*/
@Test
fun remove_afterStartupTouch_reportsNoIdentity_notStaleCached() = runBlocking {
// Persist identity A, then simulate an app relaunch: a FRESH repository over the same stores.
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
val relaunched = newRepository()
// Startup touch: resolve the lazy `initialIdentity` to the real installed identity A.
assertTrue("identity restored from storage on relaunch", relaunched.hasInstalledIdentity())
assertNotNull(relaunched.currentSummary())
// Remove — must win over the already-cached initialIdentity.
relaunched.remove()
assertFalse("removed identity must NOT still report as installed", relaunched.hasInstalledIdentity())
assertNull("removed identity must have no summary", relaunched.currentSummary())
assertNull("pointer cleared", certStore.load())
// The KeyManager reads the same live view → presents nothing after removal.
assertNull(ReReadingX509KeyManager { null }.chooseClientAlias(arrayOf("RSA"), null, null))
}
/** Wraps a real [CertStore], failing the next [save] exactly once so the commit step can be forced to throw. */
private class FailingOnceCertStore(private val delegate: CertStore) : CertStore {
@Volatile
var failNextSave: Boolean = false
override fun save(metadata: StoredIdentityMetadata) {
if (failNextSave) {
failNextSave = false
throw InjectedCommitFailure()
}
delegate.save(metadata)
}
override fun load(): StoredIdentityMetadata? = delegate.load()
override fun clear(): Unit = delegate.clear()
}
private class InjectedCommitFailure : RuntimeException("injected commit failure")
}

View File

@@ -0,0 +1,130 @@
package wang.yaojia.webterm.tlsandroid
import android.security.keystore.KeyProperties
import android.security.keystore.KeyProtection
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.X509Certificate
import wang.yaojia.webterm.clienttls.ParsedClientIdentity
import wang.yaojia.webterm.clienttls.Pkcs12Parse
/**
* Imports a device `.p12` into AndroidKeyStore — the ONE runtime key home (plan §2/§8, R4).
*
* Two-step, validate-before-persist:
* 1. PARSE + VALIDATE with the pure [Pkcs12Parse] (a transient `KeyStore("PKCS12")` used ONLY to read
* the import file). This throws on a wrong passphrase / corrupt blob / certs-only file BEFORE any
* AndroidKeyStore mutation — so a bad import can NEVER clobber a previously installed identity.
* 2. IMPORT the private key NON-EXPORTABLE into AndroidKeyStore via `setEntry(..., KeyProtection)`.
* AndroidKeyStore keys are non-exportable by construction (there is no key-material getter); the
* private key never leaves secure hardware/OS custody once imported.
*
* No `.p12` bytes and no passphrase are ever persisted here (the cert chain + metadata is persisted
* separately, encrypted, by [TinkCertStore]).
*/
public class AndroidKeyStoreImporter(
public val alias: String = DEFAULT_ALIAS,
) {
/**
* The two physical AndroidKeyStore slots this importer ping-pongs between so a rotation can import
* the NEW key WITHOUT overwriting the still-live OLD key (single-commit rotation, see
* [AndroidIdentityRepository]). Neither is inherently "live" — the Tink-encrypted live pointer
* ([StoredIdentityMetadata.keyStoreAlias]) names whichever slot currently holds the live key. Each
* `setEntry` is per-alias atomic, so importing into one slot never disturbs the other.
*/
public val primarySlot: String get() = alias
public val secondarySlot: String get() = alias + STAGING_SUFFIX
/**
* Parse+validate then import into [slot]. Returns the [ParsedClientIdentity] so the caller can
* persist the chain/metadata and derive a display summary. Throws (leaving [slot] — and every other
* slot — untouched, validate-before-persist) on:
* - wrong passphrase → `UnrecoverableKeyException`,
* - corrupt / not-a-p12 → `Pkcs12DecodeException`,
* - certs-only (no key) → `NoClientIdentityException`.
*/
public fun import(p12Bytes: ByteArray, passphrase: String, slot: String = alias): ParsedClientIdentity {
// Step 1 — validate. Throws before we touch AndroidKeyStore (validate-before-persist).
val parsed = Pkcs12Parse.parse(p12Bytes, passphrase)
// Step 2 — persist the key non-exportably into `slot` (per-alias atomic; other slots untouched).
persist(parsed, slot)
return parsed
}
/** The AndroidKeyStore-backed (opaque, non-exportable) private key handle in [slot], or null. */
public fun loadPrivateKey(slot: String = alias): PrivateKey? =
androidKeyStore().getKey(slot, null) as? PrivateKey
/** The cert chain AndroidKeyStore stored alongside the key entry in [slot]; null if none. */
public fun loadCertificateChain(slot: String = alias): List<X509Certificate>? =
androidKeyStore().getCertificateChain(slot)
?.filterIsInstance<X509Certificate>()
?.takeIf { it.isNotEmpty() }
/** Cheap existence check for [slot] (does NOT read key material). */
public fun hasInstalledKey(slot: String = alias): Boolean = androidKeyStore().isKeyEntry(slot)
/** Delete the imported key entry in [slot]. Idempotent (a missing alias is a no-op). */
public fun remove(slot: String = alias) {
val keyStore = androidKeyStore()
if (keyStore.containsAlias(slot)) {
keyStore.deleteEntry(slot)
}
}
private fun persist(parsed: ParsedClientIdentity, slot: String) {
val keyStore = androidKeyStore()
keyStore.setEntry(
slot,
KeyStore.PrivateKeyEntry(parsed.privateKey, parsed.fullChain.toTypedArray()),
keyProtection(parsed.keyAlgorithm),
)
}
private fun androidKeyStore(): KeyStore =
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
/**
* Protection for the imported client-auth key. TLS client authentication signs the
* `CertificateVerify`, so SIGN is always required; RSA additionally needs DECRYPT (RSA key
* transport in TLS ≤1.2) and its padding set. EC keys reject paddings, so only SIGN is granted.
* A broad digest set keeps TLS 1.2/1.3 signature negotiation working. The key stays
* non-exportable (AndroidKeyStore has no export path).
*/
private fun keyProtection(keyAlgorithm: String): KeyProtection {
val isRsa = keyAlgorithm.equals(KeyProperties.KEY_ALGORITHM_RSA, ignoreCase = true)
val purposes = if (isRsa) {
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_DECRYPT
} else {
KeyProperties.PURPOSE_SIGN
}
val builder = KeyProtection.Builder(purposes)
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512,
)
if (isRsa) {
builder
.setSignaturePaddings(
KeyProperties.SIGNATURE_PADDING_RSA_PKCS1,
KeyProperties.SIGNATURE_PADDING_RSA_PSS,
)
.setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1,
KeyProperties.ENCRYPTION_PADDING_RSA_OAEP,
)
}
return builder.build()
}
public companion object {
/** The single device-identity alias this app presents (one pinned identity, plan §8). */
public const val DEFAULT_ALIAS: String = "webterm-device-identity"
/** Suffix of the second ping-pong slot ([secondarySlot]) used for single-commit rotation. */
public const val STAGING_SUFFIX: String = ".staging"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
}
}

View File

@@ -0,0 +1,252 @@
package wang.yaojia.webterm.tlsandroid
import android.util.Log
import java.security.KeyStore
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
/**
* The `(SSLSocketFactory, X509TrustManager)` pair the shared `OkHttpClient` is built with. The
* factory wraps the re-reading KeyManager (so it is STABLE across cert rotations — rotation is
* handled inside the KeyManager, not by swapping the factory), and the trust manager is the default
* system trust (tunnel/Tailscale present real LE certs — plan §6.9). This is the clean seam the `:app`
* wiring (A15) bridges to `:transport-okhttp`'s `ClientIdentityProvider`; A11 never depends on that
* module.
*/
public class ClientSslMaterial(
public val sslSocketFactory: SSLSocketFactory,
public val trustManager: X509TrustManager,
)
/**
* The current device client identity, exposed as a re-reading mTLS seam (plan §2 mTLS row, §8, R4).
*
* The [sslMaterial] pair is built ONCE and installed on the shared client for its whole lifetime; a
* mid-run [importIdentity]/[rotate] is presented on the NEXT handshake (re-reading KeyManager) and
* [rotate]/[remove] additionally evict pooled/resumed connections so they can't reuse the old
* identity. No relaunch, no client rebuild.
*/
public interface IdentityRepository {
/** The stable SSL material to install on the shared `OkHttpClient` (always present — presents no
* client cert when no identity is installed). */
public fun sslMaterial(): ClientSslMaterial
/** Summary of the currently installed identity for the cert screen, or null if none installed. */
public fun currentSummary(): CertificateSummary?
/** Cheap gating check — is a device identity installed? */
public fun hasInstalledIdentity(): Boolean
/** Import a `.p12` (validates before persisting), then evict pooled connections. Returns the summary. */
public suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary
/** Replace the installed identity with a new `.p12` (rotation — single-commit, prior stays live on failure). */
public suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary
/** Remove the installed identity, then evict pooled connections so none reuse it. Idempotent. */
public suspend fun remove()
}
/**
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
*
* ### Single-commit rotation (the security invariant, plan §8, A11)
* An import/rotation that fails at ANY point leaves the PRIOR working identity fully live and NEVER
* leaves the two stores diverged. This is achieved by staging, then committing with one atomic write:
* 1. Import the new key into the ping-pong slot that is NOT currently live
* ([AndroidKeyStoreImporter] `primary`/`secondary`) — the live key is never overwritten.
* 2. COMMIT by persisting one blob to the [CertStore] whose [StoredIdentityMetadata.keyStoreAlias]
* names the staged slot. That single write is the source-of-truth "live pointer" flip.
* - Any failure BEFORE the flip → the prior pointer + key are untouched → prior identity fully live
* (the half-written staging key is best-effort deleted and is never read as live).
* - Any failure AFTER the flip → the NEW identity is fully live (the superseded key is best-effort
* GC'd; a stray unreferenced key is harmless and gets overwritten on the next rotation).
* Startup and the re-reading KeyManager resolve the live identity SOLELY via that pointer.
* Validate-before-persist still holds (a bad passphrase throws before any keystore/store mutation).
*
* ### Concurrency & lazy init
* The mutating methods are `suspend` and serialized by a [Mutex] so overlapping calls (double-tap)
* can never interleave the two-store commit. The initial on-disk identity is loaded LAZILY on first
* access (not in the constructor), so this repository can be constructed on any thread with no I/O.
*
* A15 wiring note: the FIRST touch of the identity (e.g. [currentSummary]/[hasInstalledIdentity], a
* mutator, or the first TLS handshake) performs synchronous AndroidKeyStore + Tink I/O — trigger that
* first access off `Dispatchers.Main` (a background warm-up), never on the UI thread.
*
* The cached runtime identity always holds the AndroidKeyStore-backed (non-exportable) private-key
* handle, never the transient parsed software key.
*/
public class AndroidIdentityRepository(
private val importer: AndroidKeyStoreImporter,
private val certStore: CertStore,
private val sharedClient: OkHttpClient,
) : IdentityRepository {
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
/** Distinguishes "not yet mutated" (null) from "explicitly set, possibly to null" ([Box]). */
private class Box<T>(val value: T)
// Serializes the two-store commit so a double-tap can't interleave two rotations (FIX 2).
private val commitMutex = Mutex()
// Lazily loaded on FIRST access (never in the constructor) — no slow I/O to construct (FIX 3).
private val initialIdentity: LiveIdentity? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadInstalledOrNull()
}
// Post-mutation state shadows the lazily-loaded initial value; volatile read on the handshake path.
@Volatile
private var liveOverride: Box<LiveIdentity?>? = null
// Resolve via the Box PRESENCE, not the unwrapped value's null-ness: after remove() the override
// is Box(null) (explicitly "no identity"), which MUST win over a still-cached non-null
// initialIdentity — otherwise a removed cert (whose key/pointer are already deleted) would keep
// being reported live and presented in a handshake with a dangling key handle. `?: initialIdentity`
// would collapse Box(null) back to the stale identity, so this cannot use the Elvis operator.
private fun currentLive(): LiveIdentity? {
val override = liveOverride
return if (override != null) override.value else initialIdentity
}
// Default system trust: tunnel/Tailscale present real LE certs (plan §6.9). Built once.
private val systemTrustManager: X509TrustManager = systemDefaultTrustManager()
// Stable factory wrapping the re-reading KeyManager over `currentLive()`. Built once.
private val socketFactory: SSLSocketFactory = buildSocketFactory()
override fun sslMaterial(): ClientSslMaterial =
ClientSslMaterial(sslSocketFactory = socketFactory, trustManager = systemTrustManager)
override fun currentSummary(): CertificateSummary? =
currentLive()?.let { CertificateSummaryReader.summarize(it.installed.leafCertificate) }
override fun hasInstalledIdentity(): Boolean = currentLive() != null
override suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
install(p12Bytes, passphrase)
override suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
install(p12Bytes, passphrase)
override suspend fun remove(): Unit = commitMutex.withLock {
// Clear the pointer FIRST (no live identity even if a key delete lags), then both key slots.
certStore.clear()
importer.remove(importer.primarySlot)
importer.remove(importer.secondarySlot)
liveOverride = Box(null)
// Drop pooled/resumed connections so nothing reuses the removed identity (R4/§8).
sharedClient.connectionPool.evictAll()
}
/**
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that
* flips the live pointer. Only after the flip is the new identity published and the superseded
* key GC'd. Any earlier failure leaves the prior identity fully live and the stores consistent.
*/
private suspend fun install(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
commitMutex.withLock {
val liveAlias = currentLive()?.keyStoreAlias
val stagingAlias = stagingSlotFor(liveAlias)
// Stage into the NON-live slot then COMMIT. Everything up to and including the atomic
// certStore.save() flip is wrapped so ANY pre-flip failure (import, key readback, encode,
// or the commit write) best-effort drops the half-written staging key and rethrows —
// leaving the prior pointer + live key untouched (validate-before-persist still applies:
// a bad passphrase throws inside importer.import before any keystore write).
val (parsed, keyHandle) = try {
val p = importer.import(p12Bytes, passphrase, stagingAlias)
val k = importer.loadPrivateKey(stagingAlias)
?: error("Imported key not found in AndroidKeyStore slot '$stagingAlias'")
val metadata = StoredIdentityMetadata(
alias = p.alias,
keyAlgorithm = p.keyAlgorithm,
keyStoreAlias = stagingAlias,
certificateChain = p.fullChain,
)
certStore.save(metadata) // COMMIT — the single atomic durable live-pointer flip.
p to k
} catch (e: Exception) {
runCatching { importer.remove(stagingAlias) } // best-effort: drop any half-written key
throw e
}
// Committed: publish the new identity (AndroidKeyStore handle, non-exportable) and GC the old.
liveOverride = Box(
LiveIdentity(
InstalledIdentity(
alias = parsed.alias,
keyAlgorithm = parsed.keyAlgorithm,
privateKey = keyHandle,
leafCertificate = parsed.leafCertificate,
issuerCertificates = parsed.issuerCertificates,
),
stagingAlias,
),
)
liveAlias?.let { old -> runCatching { importer.remove(old) } } // best-effort GC of old slot
// Next handshake uses the new identity; drop pooled/resumed connections holding the old one.
sharedClient.connectionPool.evictAll()
parsed.summary()
}
/** The ping-pong slot to stage into: whichever physical slot is NOT the current live one. */
private fun stagingSlotFor(liveAlias: String?): String =
if (liveAlias == importer.primarySlot) importer.secondarySlot else importer.primarySlot
/**
* Reconstruct the installed identity from storage at startup, resolving the live key SOLELY via
* the persisted pointer ([StoredIdentityMetadata.keyStoreAlias]). A storage/decrypt fault must not
* crash launch — it degrades to "no identity" and is logged (never silently dropped), mirroring
* iOS `loadedIdentityOrNil`.
*/
private fun loadInstalledOrNull(): LiveIdentity? {
return try {
val metadata = certStore.load() ?: return null
val keyHandle = importer.loadPrivateKey(metadata.keyStoreAlias) ?: return null
LiveIdentity(
InstalledIdentity(
alias = metadata.alias,
keyAlgorithm = metadata.keyAlgorithm,
privateKey = keyHandle,
leafCertificate = metadata.leafCertificate,
issuerCertificates = metadata.issuerCertificates,
),
metadata.keyStoreAlias,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to load stored device identity; continuing without a client cert", e)
null
}
}
private fun buildSocketFactory(): SSLSocketFactory {
val keyManager = ReReadingX509KeyManager { currentLive()?.installed }
val context = SSLContext.getInstance("TLS")
context.init(arrayOf<KeyManager>(keyManager), arrayOf<TrustManager>(systemTrustManager), null)
return context.socketFactory
}
private fun systemDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
?: error("No system X509TrustManager available")
}
private companion object {
private const val TAG = "IdentityRepository"
}
}

View File

@@ -0,0 +1,93 @@
package wang.yaojia.webterm.tlsandroid
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.SSLEngine
import javax.net.ssl.X509ExtendedKeyManager
import wang.yaojia.webterm.clienttls.ClientKeyManagerLogic
import wang.yaojia.webterm.clienttls.KeyManagerIdentity
/**
* The live, non-exportable device identity as the KeyManager sees it: the AndroidKeyStore-backed
* [privateKey] handle (opaque — signing happens inside the keystore) plus the cert chain to present.
*/
public class InstalledIdentity(
public val alias: String,
public val keyAlgorithm: String,
public val privateKey: PrivateKey,
public val leafCertificate: X509Certificate,
public val issuerCertificates: List<X509Certificate>,
) {
/** Full chain, leaf at index 0, as presented to the server. */
public val chain: List<X509Certificate> get() = listOf(leafCertificate) + issuerCertificates
internal val keyManagerIdentity: KeyManagerIdentity
get() = KeyManagerIdentity(alias = alias, keyAlgorithm = keyAlgorithm)
}
/**
* A **re-reading** `X509KeyManager` (R4): every alias/key/chain lookup consults [identitySource]
* afresh, so a cert imported or rotated mid-run is presented on the NEXT handshake with no app
* relaunch and no rebuild of the shared `OkHttpClient`. The alias truth table is delegated to the
* pure [ClientKeyManagerLogic] (present iff an identity is installed AND its key type is acceptable),
* keeping the security-load-bearing decision unit-tested at JVM speed in `:client-tls`.
*
* Client-only: the server-side methods return null. Key material is gated by `ownsAlias` so a foreign
* alias never leaks the device identity.
*/
internal class ReReadingX509KeyManager(
private val identitySource: () -> InstalledIdentity?,
) : X509ExtendedKeyManager() {
override fun chooseClientAlias(
keyType: Array<String>?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = logicOrNull()?.chooseClientAlias(keyType)
override fun chooseEngineClientAlias(
keyType: Array<String>?,
issuers: Array<out Principal>?,
engine: SSLEngine?,
): String? = logicOrNull()?.chooseClientAlias(keyType)
override fun getClientAliases(
keyType: String?,
issuers: Array<out Principal>?,
): Array<String>? = logicOrNull()?.clientAliases(keyType)
override fun getPrivateKey(alias: String?): PrivateKey? {
val identity = identitySource() ?: return null
return if (ClientKeyManagerLogic(identity.keyManagerIdentity).ownsAlias(alias)) {
identity.privateKey
} else {
null
}
}
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
val identity = identitySource() ?: return null
return if (ClientKeyManagerLogic(identity.keyManagerIdentity).ownsAlias(alias)) {
identity.chain.toTypedArray()
} else {
null
}
}
// Client-only: never act as a TLS server.
override fun chooseServerAlias(
keyType: String?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = null
override fun getServerAliases(
keyType: String?,
issuers: Array<out Principal>?,
): Array<String>? = null
private fun logicOrNull(): ClientKeyManagerLogic? =
identitySource()?.let { ClientKeyManagerLogic(it.keyManagerIdentity) }
}

View File

@@ -0,0 +1,108 @@
package wang.yaojia.webterm.tlsandroid
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
/**
* The at-rest description of the installed device identity — and the single source-of-truth "live
* pointer" for the whole repository. It carries the [alias] + [keyAlgorithm] the pure
* `ClientKeyManagerLogic` needs for alias selection, the DER cert [certificateChain] (leaf at index 0)
* presented in the TLS handshake, AND the [keyStoreAlias] naming which physical AndroidKeyStore slot
* holds the live private key. Persisting a new blob (one atomic [TinkCertStore.save]) is the COMMIT
* that flips which identity is live; startup and the re-reading KeyManager resolve the live identity
* SOLELY through this pointer (see [AndroidIdentityRepository]).
*
* The PRIVATE KEY is deliberately NOT here — it lives non-exportable in AndroidKeyStore (the one key
* home, plan §2/§8). This blob is exactly what [TinkCertStore] AEAD-encrypts at rest; no `.p12` bytes
* and no passphrase are ever persisted.
*/
public data class StoredIdentityMetadata(
val alias: String,
val keyAlgorithm: String,
val keyStoreAlias: String,
val certificateChain: List<X509Certificate>,
) {
init {
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias (live-key slot) must not be blank" }
require(certificateChain.isNotEmpty()) { "certificateChain must contain at least the leaf" }
}
/** The leaf (device) certificate — chain[0]. */
public val leafCertificate: X509Certificate get() = certificateChain.first()
/** Issuer certificates accompanying the leaf (chain minus leaf); may be empty. */
public val issuerCertificates: List<X509Certificate> get() = certificateChain.drop(1)
}
/** The stored identity blob was present but could not be decoded (tamper / version skew / corruption). */
public class CorruptStoredIdentityException(message: String, cause: Throwable? = null) :
GeneralSecurityException(message, cause)
/**
* Length-prefixed binary codec for [StoredIdentityMetadata] (KISS: no serialization framework — the
* cert chain is `X509Certificate`, not a plain data type). Certificates are stored as their DER
* encoding and re-parsed with the JVM `CertificateFactory`.
*
* Layout: `UTF(alias) · UTF(keyAlgorithm) · UTF(keyStoreAlias) · Int(chainCount) · [Int(derLen) · derBytes]*`.
*/
public object IdentityMetadataCodec {
private const val X509 = "X.509"
private const val MAX_CHAIN = 32
private const val MAX_DER_BYTES = 1 shl 20 // 1 MiB per cert — a defensive upper bound.
public fun encode(metadata: StoredIdentityMetadata): ByteArray {
val out = ByteArrayOutputStream()
DataOutputStream(out).use { data ->
data.writeUTF(metadata.alias)
data.writeUTF(metadata.keyAlgorithm)
data.writeUTF(metadata.keyStoreAlias)
data.writeInt(metadata.certificateChain.size)
metadata.certificateChain.forEach { cert ->
val der = cert.encoded
data.writeInt(der.size)
data.write(der)
}
}
return out.toByteArray()
}
/** Decode [bytes]; any structural or certificate-parse failure → [CorruptStoredIdentityException]. */
public fun decode(bytes: ByteArray): StoredIdentityMetadata =
try {
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
val alias = data.readUTF()
val keyAlgorithm = data.readUTF()
val keyStoreAlias = data.readUTF()
val count = data.readInt()
if (count !in 1..MAX_CHAIN) {
throw CorruptStoredIdentityException("Implausible chain length: $count")
}
val factory = CertificateFactory.getInstance(X509)
val chain = (0 until count).map { readCertificate(data, factory) }
StoredIdentityMetadata(alias, keyAlgorithm, keyStoreAlias, chain)
}
} catch (e: CorruptStoredIdentityException) {
throw e
} catch (e: IOException) {
throw CorruptStoredIdentityException("Stored identity blob was truncated/malformed", e)
} catch (e: CertificateException) {
throw CorruptStoredIdentityException("Stored identity certificate could not be parsed", e)
}
private fun readCertificate(data: DataInputStream, factory: CertificateFactory): X509Certificate {
val len = data.readInt()
if (len !in 1..MAX_DER_BYTES) {
throw CorruptStoredIdentityException("Implausible certificate length: $len")
}
val der = ByteArray(len)
data.readFully(der)
return factory.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
}
}

View File

@@ -0,0 +1,135 @@
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
/**
* At-rest storage for the encrypted device-identity "live pointer" ([StoredIdentityMetadata]).
* Abstracted behind an interface so [AndroidIdentityRepository] depends on the operation contract
* (repository pattern) — the storage backend is swappable and a fault can be injected in tests. The
* single [save] is the atomic COMMIT that flips which identity is live (plan §8, A11).
*/
public interface CertStore {
/** Encrypt and persist [metadata], atomically replacing any previously stored blob (the commit). */
public fun save(metadata: StoredIdentityMetadata)
/** Load + decrypt the stored pointer, or null if none is stored. */
public fun load(): StoredIdentityMetadata?
/** Remove the stored pointer. Idempotent. */
public fun clear()
}
/**
* At-rest storage for the device identity's cert-chain + metadata blob ([StoredIdentityMetadata]),
* AEAD-encrypted with Tink and an AndroidKeystore-wrapped master key (plan §2 persistence-secret row,
* §8 cert storage).
*
* What is and is NOT stored here:
* - STORED (encrypted): the cert chain + alias + key algorithm — the material the re-reading
* KeyManager and the summary UI need.
* - NOT stored: the private key (non-exportable in AndroidKeyStore — the one key home), the raw
* `.p12` bytes, and the passphrase.
*
* The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, excluded
* from cloud backup by the app manifest); the Tink keyset itself is encrypted by the AndroidKeystore
* master key so the on-disk keyset is useless off this device.
*/
public class TinkCertStore(
context: Context,
private val keysetName: String = DEFAULT_KEYSET_NAME,
private val prefFileName: String = DEFAULT_PREF_FILE,
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
) : CertStore {
private val appContext: Context = context.applicationContext
// Built on first use: registers AEAD, loads-or-creates the AndroidKeystore-wrapped keyset.
private val aead: Aead by lazy { buildAead() }
/**
* Encrypt and persist [metadata], replacing any previously stored blob. Uses synchronous
* [SharedPreferences.Editor.commit] (NOT `apply()`): the pointer flip is the atomic durable
* commit of a rotation, and `install()` deletes the superseded key immediately after this
* returns — so the write MUST be on disk (and MUST surface a failure) before that delete. A
* non-durable/silently-failed write could leave the on-disk pointer naming an already-deleted
* key after a crash, losing BOTH identities.
*/
override fun save(metadata: StoredIdentityMetadata) {
val plaintext = IdentityMetadataCodec.encode(metadata)
val ciphertext = aead.encrypt(plaintext, 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-identity live pointer")
}
}
/**
* Load + decrypt the stored blob, or null if none is stored. Throws
* [CorruptStoredIdentityException] if a blob is present but fails to decrypt/decode (tamper or
* version skew) — callers treat that as "no usable identity" (see [IdentityRepository]).
*/
override fun load(): StoredIdentityMetadata? {
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
val ciphertext = try {
Base64.decode(encoded, Base64.NO_WRAP)
} catch (e: IllegalArgumentException) {
throw CorruptStoredIdentityException("Stored identity blob was not valid base64", e)
}
val plaintext = try {
aead.decrypt(ciphertext, ASSOCIATED_DATA)
} catch (e: java.security.GeneralSecurityException) {
throw CorruptStoredIdentityException("Stored identity blob failed AEAD decryption", e)
}
return IdentityMetadataCodec.decode(plaintext)
}
/**
* Remove the stored blob. Idempotent. Uses synchronous [SharedPreferences.Editor.commit] and
* throws on failure: `remove()` clears this pointer BEFORE deleting the AndroidKeyStore keys, so
* a silently-failed clear (pointer left naming a soon-to-be-deleted key) would diverge the two
* stores — surfacing the failure lets `remove()` abort with the prior identity still live. The
* Tink keyset (master key) is intentionally left intact.
*/
override fun clear() {
val committed = prefs().edit().remove(BLOB_KEY).commit()
if (!committed) {
throw java.io.IOException("Failed to durably clear the device-identity live pointer")
}
}
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_cert_keyset"
private const val DEFAULT_PREF_FILE = "webterm_client_tls_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_cert_master_key"
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "cert_chain_blob"
/**
* AEAD associated data — binds the ciphertext to this app's identity-blob context so a blob
* lifted into another Tink ciphertext slot fails to decrypt. Not secret.
*/
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.identity".toByteArray()
}
}