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
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:
@@ -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
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user