feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)

Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.

Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol  — frozen wire contract (sealed Client/ServerMessage, enums,
  HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
  byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core   — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
  GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
  KeyByteMap (byte-matches public/keybar.ts).
- :api-client     — all REST routes, tolerant decode, Origin-iff-guarded, prefs
  unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls     — pure half: PKCS#12 parse, X509KeyManager alias logic,
  CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support   — FakeTransport / FakeHttpTransport / virtual-clock fakes.

Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).

Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.

Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
This commit is contained in:
Yaojia Wang
2026-07-08 13:45:07 +02:00
parent cf88e7c588
commit 4ea8f7862a
106 changed files with 7502 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
import kotlin.time.Duration.Companion.days
/** Ports iOS `CertificateSummary` tests — field extraction off a real cert + the isExpired rule. */
class CertificateSummaryTest {
private fun leafSummary(): CertificateSummary =
Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE).summary()
@Test
fun extractsSubjectAndIssuerCommonNamesFromRealCertificate() {
val summary = leafSummary()
assertEquals(Fixtures.LEAF_SUBJECT_CN, summary.subjectCommonName)
assertEquals(Fixtures.LEAF_ISSUER_CN, summary.issuerCommonName)
}
@Test
fun extractsNotAfterFromRealCertificate() {
val summary = leafSummary()
assertNotNull(summary.notAfter, "notAfter should parse off the fixture cert")
// Fixture was minted with a ~100-year validity, so it is not expired now.
assertFalse(summary.isExpired(Instant.now()))
}
@Test
fun isExpiredIsTrueStrictlyAfterNotAfter() {
val notAfter = Instant.parse("2030-01-01T00:00:00Z")
val summary = CertificateSummary(subjectCommonName = "x", issuerCommonName = "y", notAfter = notAfter)
assertFalse(summary.isExpired(notAfter.minusMillis(1)), "just before expiry")
assertFalse(summary.isExpired(notAfter), "exactly at notAfter is not yet expired")
assertTrue(summary.isExpired(notAfter.plusMillis(1)), "just after expiry")
assertTrue(summary.isExpired(notAfter.plusSeconds(365.days.inWholeSeconds)), "well after expiry")
}
@Test
fun unknownExpiryFailsOpenAsNotExpired() {
val summary = CertificateSummary(subjectCommonName = null, issuerCommonName = null, notAfter = null)
assertFalse(summary.isExpired(Instant.now()), "null notAfter is display-only fail-open → not expired")
}
@Test
fun realCertificateNotAfterMatchesLeafExpiry() {
// Derive isExpired boundaries from the parsed notAfter so the test stays deterministic
// regardless of the fixture generation date.
val summary = leafSummary()
val notAfter = requireNotNull(summary.notAfter)
assertFalse(summary.isExpired(notAfter.minusSeconds(1)))
assertTrue(summary.isExpired(notAfter.plusSeconds(1)))
}
@Test
fun summaryFromCertificateReaderMatchesParsedLeaf() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
val direct = CertificateSummaryReader.summarize(identity.leafCertificate)
assertEquals(identity.summary(), direct)
}
}

View File

@@ -0,0 +1,59 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertArrayEquals
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
/**
* Truth table for the pure client X509KeyManager alias selection (iOS `MutualTLSChallengeResponder`
* analogue): present iff an identity is installed AND its key type is acceptable.
*/
class ClientKeyManagerLogicTest {
private val alias = "device"
private val installed = ClientKeyManagerLogic(KeyManagerIdentity(alias = alias, keyAlgorithm = "RSA"))
private val empty = ClientKeyManagerLogic(identity = null)
@Test
fun presentsAliasWhenServerAcceptsOurKeyType() {
assertEquals(alias, installed.chooseClientAlias(arrayOf("RSA")))
assertEquals(alias, installed.chooseClientAlias(arrayOf("EC", "RSA")))
assertEquals(alias, installed.chooseClientAlias(arrayOf("rsa")), "match is case-insensitive")
}
@Test
fun failsOpenWhenServerExpressesNoKeyTypeConstraint() {
assertEquals(alias, installed.chooseClientAlias(null))
assertEquals(alias, installed.chooseClientAlias(emptyArray()))
}
@Test
fun presentsNothingOnKeyTypeMismatch() {
assertNull(installed.chooseClientAlias(arrayOf("EC")))
assertNull(installed.chooseClientAlias(arrayOf("DSA", "EC")))
}
@Test
fun presentsNothingWhenNoIdentityInstalled() {
assertNull(empty.chooseClientAlias(arrayOf("RSA")))
assertNull(empty.chooseClientAlias(null))
assertNull(empty.clientAliases("RSA"))
assertFalse(empty.ownsAlias(alias))
}
@Test
fun clientAliasesReturnsSingletonWhenEligible() {
assertArrayEquals(arrayOf(alias), installed.clientAliases("RSA"))
assertArrayEquals(arrayOf(alias), installed.clientAliases(null))
assertNull(installed.clientAliases("EC"))
}
@Test
fun ownsAliasGatesKeyMaterialToThePinnedAlias() {
assertTrue(installed.ownsAlias(alias))
assertFalse(installed.ownsAlias("someone-else"))
assertFalse(installed.ownsAlias(null))
}
}

View File

@@ -0,0 +1,80 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
/** Ports iOS `HostClassificationTests` — the four §5.4 warning tiers + the fail-safe default. */
class HostClassifierTest {
@Test
fun loopbackTierMatchesLocalTargets() {
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]")
for (host in hosts) {
assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(host), "$host should be loopback")
}
}
@Test
fun privateLanTierMatchesRfc1918AndLinkLocal() {
val hosts = listOf(
"10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255",
"169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL",
)
for (host in hosts) {
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(host), "$host should be privateLAN")
}
}
@Test
fun tailscaleTierMatchesCgnatAndMagicDns() {
val hosts = listOf(
"100.64.0.1", "100.100.1.1", "100.127.255.255",
"mac.tailnet.ts.net", "foo.TS.NET",
)
for (host in hosts) {
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(host), "$host should be tailscale")
}
}
@Test
fun publicTierIsTheFailSafeDefault() {
// Out of range: 172.32 exceeds 172.16/12; 100.128 & 100.63 exceed 100.64/10; malformed IPv4.
val hosts = listOf(
"8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com",
"172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "",
)
for (host in hosts) {
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(host), "$host should be public")
}
}
@Test
fun classifyEndpointMatchesHostStringVersion() {
val vectors = listOf(
"http://127.0.0.1:3000" to HostNetworkTier.LOOPBACK,
"http://192.168.1.5:3000" to HostNetworkTier.PRIVATE_LAN,
"http://100.100.1.1:3000" to HostNetworkTier.TAILSCALE,
"https://mac.tailnet.ts.net" to HostNetworkTier.TAILSCALE,
"https://example.com" to HostNetworkTier.PUBLIC,
)
for ((url, tier) in vectors) {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(url)) { "endpoint for $url" }
assertEquals(tier, HostClassifier.classify(endpoint), url)
}
}
@Test
fun ipv4OctetsStrictParseRejectsNonDottedQuads() {
assertNull(HostClassifier.ipv4Octets(""))
assertNull(HostClassifier.ipv4Octets("1.2.3"))
assertNull(HostClassifier.ipv4Octets("1.2.3.4.5"))
assertNull(HostClassifier.ipv4Octets("256.1.1.1"))
assertNull(HostClassifier.ipv4Octets("-1.2.3.4"))
assertNull(HostClassifier.ipv4Octets("a.b.c.d"))
assertNull(HostClassifier.ipv4Octets(".1.2.3"))
assertEquals(listOf(10, 0, 0, 255), HostClassifier.ipv4Octets("10.0.0.255")?.toList())
}
}

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.clienttls
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.IOException
import java.security.UnrecoverableKeyException
import javax.crypto.BadPaddingException
/** Ports iOS `PKCS12Importer` tests against a real keytool-minted `.p12` fixture (pure JVM). */
class Pkcs12ParseTest {
@Test
fun parsesLeafIdentityWithChainAndKey() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertEquals("device", identity.alias, "must select the key entry, not the trusted-cert entry")
assertEquals("RSA", identity.keyAlgorithm)
assertEquals(Fixtures.LEAF_SUBJECT_CN, identity.summary().subjectCommonName)
}
@Test
fun issuerChainExcludesTheLeafAndKeepsTheCa() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
// Full chain is leaf → ca; issuerCertificates drops the leaf (mirrors iOS issuerChain).
assertEquals(2, identity.fullChain.size)
assertEquals(1, identity.issuerCertificates.size)
assertEquals(identity.leafCertificate, identity.fullChain.first())
val caSummary = CertificateSummaryReader.summarize(identity.issuerCertificates.first())
assertEquals(Fixtures.LEAF_ISSUER_CN, caSummary.subjectCommonName, "issuer cert is the device CA")
}
@Test
fun wrongPassphraseThrowsUnrecoverableKeyException() {
assertThrows<UnrecoverableKeyException> {
Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.WRONG_PASSPHRASE)
}
}
@Test
fun corruptBlobThrowsDecodeException() {
val garbage = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8)
assertThrows<Pkcs12DecodeException> {
Pkcs12Parse.parse(garbage, Fixtures.PASSPHRASE)
}
}
@Test
fun bouncyCastleBadPaddingIsClassifiedAsWrongPassphraseNotCorrupt() {
// On-device (BouncyCastle) a wrong passphrase surfaces as an IOException caused by
// BadPaddingException, NOT UnrecoverableKeyException — it must still map to wrong-passphrase.
val ioe = IOException("exception unwrapping private key", BadPaddingException("pad block corrupted"))
assertInstanceOf(UnrecoverableKeyException::class.java, Pkcs12Parse.classifyLoadFailure(ioe))
}
@Test
fun wrongPasswordMessageIsClassifiedAsWrongPassphrase() {
// Some providers only report the failure in the message (no typed cause).
assertInstanceOf(
UnrecoverableKeyException::class.java,
Pkcs12Parse.classifyLoadFailure(IOException("keystore password was incorrect")),
)
}
@Test
fun genuinelyUndecodableBlobStaysDecodeException() {
// A structural DER failure carries no wrong-passphrase signal → stays a decode error.
assertInstanceOf(
Pkcs12DecodeException::class.java,
Pkcs12Parse.classifyLoadFailure(IOException("DerInputStream.getLength(): lengthTag=127, too big")),
)
}
@Test
fun certsOnlyStoreThrowsNoClientIdentity() {
assertThrows<NoClientIdentityException> {
Pkcs12Parse.parse(Fixtures.trustP12(), Fixtures.PASSPHRASE)
}
}
@Test
fun privateKeyIsRecoverableWithCorrectPassphrase() {
val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
assertTrue(identity.privateKey.encoded.isNotEmpty(), "private key material is present")
}
}

File diff suppressed because one or more lines are too long