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:
26
android/client-tls/build.gradle.kts
Normal file
26
android/client-tls/build.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
// :client-tls (PURE half) — PKCS#12 structural parse (KeyStore("PKCS12"),
|
||||
// import-parse only), X509KeyManager alias/getPrivateKey selection logic,
|
||||
// CertificateSummary parsing, PairingError/HostClassifier warning-tier mapping.
|
||||
// JVM-speed tested and IN the 80% Kover gate. The AndroidKeyStore/Tink framework
|
||||
// half lives in the (SDK-gated) :client-tls-android module.
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":wire-protocol"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package wang.yaojia.webterm.clienttls
|
||||
|
||||
import java.security.cert.X509Certificate
|
||||
import java.time.Instant
|
||||
import javax.naming.ldap.LdapName
|
||||
import javax.security.auth.x500.X500Principal
|
||||
|
||||
/**
|
||||
* Display summary of a device certificate (C-iOS-3), shown on the install / rotation screen so the
|
||||
* user can confirm *which* cert is active and *when* it expires before relying on it. Faithful port
|
||||
* of iOS `ClientCertificateSummary`.
|
||||
*
|
||||
* @property subjectCommonName subject CN — the device/leaf CN (e.g. `t1-android`); null if absent.
|
||||
* @property issuerCommonName issuer CN — the device-CA CN (e.g. `webterm-device-ca`); null if absent.
|
||||
* @property notAfter not-after instant; null if it could not be parsed.
|
||||
*/
|
||||
public data class CertificateSummary(
|
||||
val subjectCommonName: String?,
|
||||
val issuerCommonName: String?,
|
||||
val notAfter: Instant?,
|
||||
) {
|
||||
/**
|
||||
* Expired relative to [now] (defaults to the current instant). Unknown expiry is treated as NOT
|
||||
* expired (fail-open for display only — the TLS stack, not this label, is the real gate),
|
||||
* matching iOS `isExpired`.
|
||||
*/
|
||||
public fun isExpired(now: Instant = Instant.now()): Boolean {
|
||||
val end = notAfter ?: return false
|
||||
return end.isBefore(now)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the display fields off an [X509Certificate].
|
||||
*
|
||||
* Unlike iOS (which hand-walks the DER because `SecCertificate` lacks the accessor on iOS), the JVM
|
||||
* `X509Certificate` already exposes subject/issuer principals and `notAfter`, so this is a thin
|
||||
* extraction. CN is pulled from the RFC2253 DN via `javax.naming.ldap.LdapName` (JVM stdlib), which
|
||||
* handles DN escaping/ordering correctly. Any parse miss degrades to `null` fields (the summary is
|
||||
* display-only; the TLS stack is the gate).
|
||||
*/
|
||||
public object CertificateSummaryReader {
|
||||
private const val COMMON_NAME_TYPE = "CN"
|
||||
|
||||
public fun summarize(certificate: X509Certificate): CertificateSummary =
|
||||
CertificateSummary(
|
||||
subjectCommonName = commonName(certificate.subjectX500Principal),
|
||||
issuerCommonName = commonName(certificate.issuerX500Principal),
|
||||
notAfter = notAfterInstant(certificate),
|
||||
)
|
||||
|
||||
private fun commonName(principal: X500Principal): String? =
|
||||
try {
|
||||
LdapName(principal.getName(X500Principal.RFC2253)).rdns
|
||||
.firstOrNull { it.type.equals(COMMON_NAME_TYPE, ignoreCase = true) }
|
||||
?.value
|
||||
?.toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun notAfterInstant(certificate: X509Certificate): Instant? =
|
||||
try {
|
||||
certificate.notAfter?.toInstant()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package wang.yaojia.webterm.clienttls
|
||||
|
||||
/**
|
||||
* The pinned device identity as seen by the key-manager selection logic: just enough to *decide*
|
||||
* whether to present a client certificate. The key material (private key + chain) lives in the
|
||||
* framework half (A11, AndroidKeyStore-backed) — the pure logic only needs the alias and the key
|
||||
* algorithm to make the selection.
|
||||
*
|
||||
* @property alias the single device-identity alias this app presents.
|
||||
* @property keyAlgorithm the leaf key's algorithm (`RSA` / `EC`), matched against the server's
|
||||
* requested client key types.
|
||||
*/
|
||||
public data class KeyManagerIdentity(
|
||||
val alias: String,
|
||||
val keyAlgorithm: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The pure, synchronous alias-selection heart of a client `X509KeyManager` (the Android analogue of
|
||||
* iOS `MutualTLSChallengeResponder`). Deliberately free of any `SSLEngine` / `Socket` / OkHttp
|
||||
* state so the full truth table is unit-testable without a live handshake; the framework half (A11)
|
||||
* is a thin `X509KeyManager` that delegates alias decisions here and reads the actual
|
||||
* `PrivateKey`/chain from AndroidKeyStore for the [KeyManagerIdentity.alias].
|
||||
*
|
||||
* Truth table (mirrors iOS C-iOS-1):
|
||||
* a. identity present + server accepts our key type → present our [KeyManagerIdentity.alias].
|
||||
* b. identity present + key-type mismatch → present nothing (null) — a clean, classifiable
|
||||
* handshake failure rather than a wrong-type cert.
|
||||
* c. no identity installed → present nothing (null) — never a silent
|
||||
* wrong-cert; the TLS handshake fails and the failure surfaces (iOS `.cancel`).
|
||||
*
|
||||
* @property identity the installed device identity, or null when none is installed.
|
||||
*/
|
||||
public class ClientKeyManagerLogic(
|
||||
private val identity: KeyManagerIdentity?,
|
||||
) {
|
||||
/**
|
||||
* The alias to present for a client-certificate request whose acceptable public-key types are
|
||||
* [keyTypes] (the `keyType` argument of `X509KeyManager.chooseClientAlias`). Returns the pinned
|
||||
* alias iff an identity is installed AND its key algorithm is acceptable; null otherwise. A null
|
||||
* or empty [keyTypes] (server expressed no constraint) is treated as "accept" (fail-open — keep
|
||||
* the handshake working when there is exactly one pinned identity).
|
||||
*/
|
||||
public fun chooseClientAlias(keyTypes: Array<String>?): String? {
|
||||
val id = identity ?: return null
|
||||
return if (accepts(keyTypes)) id.alias else null
|
||||
}
|
||||
|
||||
/**
|
||||
* All aliases eligible for [keyType] (the `X509KeyManager.getClientAliases` shape): a
|
||||
* single-element array of the pinned alias when eligible, else null.
|
||||
*/
|
||||
public fun clientAliases(keyType: String?): Array<String>? {
|
||||
val id = identity ?: return null
|
||||
return if (accepts(if (keyType == null) null else arrayOf(keyType))) arrayOf(id.alias) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff [candidate] is the pinned alias of an installed identity. The framework
|
||||
* `getPrivateKey(alias)` / `getCertificateChain(alias)` MUST gate on this before returning key
|
||||
* material, so a foreign alias never leaks our identity.
|
||||
*/
|
||||
public fun ownsAlias(candidate: String?): Boolean =
|
||||
identity != null && candidate != null && candidate == identity.alias
|
||||
|
||||
private fun accepts(keyTypes: Array<String>?): Boolean {
|
||||
val id = identity ?: return false
|
||||
if (keyTypes == null || keyTypes.isEmpty()) return true
|
||||
return keyTypes.any { it.equals(id.keyAlgorithm, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package wang.yaojia.webterm.clienttls
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.crypto.BadPaddingException
|
||||
|
||||
/**
|
||||
* The structural result of parsing a `.p12` import file — the private key, its leaf certificate,
|
||||
* and the issuer chain that accompanies it in the TLS handshake. Android analogue of iOS
|
||||
* `ClientIdentity`, but holding only the *pure JVM* material.
|
||||
*
|
||||
* @property alias the PKCS#12 key-entry alias the identity was found under.
|
||||
* @property privateKey the leaf private key (device authentication key).
|
||||
* @property keyAlgorithm the private key's algorithm (e.g. `RSA`, `EC`) — drives
|
||||
* [ClientKeyManagerLogic] client-alias selection.
|
||||
* @property leafCertificate the leaf certificate (chain[0]); the device's own cert.
|
||||
* @property issuerCertificates the issuer chain (the CA chain, leaf excluded) — may be empty when
|
||||
* the server already pins the trust anchor. Mirrors iOS `issuerChain` (do not repeat the leaf).
|
||||
*/
|
||||
public data class ParsedClientIdentity(
|
||||
val alias: String,
|
||||
val privateKey: PrivateKey,
|
||||
val keyAlgorithm: String,
|
||||
val leafCertificate: X509Certificate,
|
||||
val issuerCertificates: List<X509Certificate>,
|
||||
) {
|
||||
/** The full chain with the leaf at index 0, as presented to the server. */
|
||||
public val fullChain: List<X509Certificate>
|
||||
get() = listOf(leafCertificate) + issuerCertificates
|
||||
|
||||
/** Display summary of the leaf certificate for the install/rotation UI. */
|
||||
public fun summary(): CertificateSummary = CertificateSummaryReader.summarize(leafCertificate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded, but carried no importable private-key entry (e.g. a certs-only / truststore `.p12`).
|
||||
* The iOS `PKCS12ImportError.noIdentity` analogue.
|
||||
*/
|
||||
public class NoClientIdentityException(message: String) : GeneralSecurityException(message)
|
||||
|
||||
/**
|
||||
* Not a decodable PKCS#12 blob (truncated / not a `.p12` / unsupported algorithms). The iOS
|
||||
* `PKCS12ImportError.corruptFile` / `.unsupported` analogue; retains the underlying cause.
|
||||
*/
|
||||
public class Pkcs12DecodeException(message: String, cause: Throwable? = null) :
|
||||
GeneralSecurityException(message, cause)
|
||||
|
||||
/**
|
||||
* Structural PKCS#12 parse via `KeyStore("PKCS12")` — JVM stdlib, needs NO Android SDK. This is the
|
||||
* pure half of the iOS `PKCS12Importer`: it validates the passphrase and extracts the identity, but
|
||||
* (unlike the framework half A11) never touches AndroidKeyStore/Tink and never persists anything.
|
||||
*
|
||||
* Error surface (matches the A10 verify contract):
|
||||
* - wrong passphrase → [UnrecoverableKeyException] (the JVM idiom; the underlying cause is
|
||||
* unwrapped so callers see the specific security exception, not a generic [IOException]).
|
||||
* - corrupt / not-a-p12 / unsupported → [Pkcs12DecodeException].
|
||||
* - decoded but no key entry → [NoClientIdentityException].
|
||||
*/
|
||||
public object Pkcs12Parse {
|
||||
private const val PKCS12 = "PKCS12"
|
||||
private const val MAX_CAUSE_DEPTH = 8
|
||||
|
||||
/** Case-insensitive message fragments that a JCE provider uses for a rejected PKCS#12 passphrase. */
|
||||
private val WRONG_PASSPHRASE_HINTS = listOf("password", "mac", "integrity")
|
||||
|
||||
/**
|
||||
* Parse [bytes] with [passphrase] and return the first key-entry identity found. The alias is
|
||||
* selected by preferring the first entry that is a *key* entry (a `.p12` may also carry
|
||||
* trusted-cert entries, which are skipped — they hold no private key).
|
||||
*/
|
||||
public fun parse(bytes: ByteArray, passphrase: String): ParsedClientIdentity {
|
||||
val password = passphrase.toCharArray()
|
||||
val keyStore = loadKeyStore(bytes, password)
|
||||
val alias = firstKeyAlias(keyStore)
|
||||
?: throw NoClientIdentityException("PKCS#12 contained no private-key entry")
|
||||
return buildIdentity(keyStore, alias, password)
|
||||
}
|
||||
|
||||
private fun loadKeyStore(bytes: ByteArray, password: CharArray): KeyStore {
|
||||
val keyStore = KeyStore.getInstance(PKCS12)
|
||||
try {
|
||||
ByteArrayInputStream(bytes).use { keyStore.load(it, password) }
|
||||
} catch (e: IOException) {
|
||||
throw classifyLoadFailure(e)
|
||||
}
|
||||
return keyStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `KeyStore.load` [IOException] to the A10 error surface. A wrong passphrase surfaces
|
||||
* DIFFERENTLY per JCE provider: SunJSSE throws an IOException caused by [UnrecoverableKeyException]
|
||||
* (MAC/integrity check), while Android's BouncyCastle throws one caused by a
|
||||
* [BadPaddingException] (or carries a "password"/"mac"/"integrity" message). Treat ALL of those
|
||||
* as the wrong-passphrase signal so a bad password is never misreported on-device as a corrupt
|
||||
* file; only a genuinely undecodable blob stays a [Pkcs12DecodeException]. Never echoes the
|
||||
* passphrase. `internal` so the classification is unit-testable without a provider-specific blob.
|
||||
*/
|
||||
internal fun classifyLoadFailure(error: IOException): GeneralSecurityException =
|
||||
if (isWrongPassphrase(error)) {
|
||||
wrongPassphrase(error)
|
||||
} else {
|
||||
Pkcs12DecodeException("PKCS#12 blob could not be decoded", error)
|
||||
}
|
||||
|
||||
private fun isWrongPassphrase(error: Throwable): Boolean =
|
||||
causeChain(error).any { cause ->
|
||||
cause is UnrecoverableKeyException ||
|
||||
cause is BadPaddingException ||
|
||||
messageHintsWrongPassphrase(cause.message)
|
||||
}
|
||||
|
||||
private fun messageHintsWrongPassphrase(message: String?): Boolean {
|
||||
val lowered = message?.lowercase() ?: return false
|
||||
return WRONG_PASSPHRASE_HINTS.any { it in lowered }
|
||||
}
|
||||
|
||||
/** Prefer a provider-native [UnrecoverableKeyException]; otherwise wrap (never echo the passphrase). */
|
||||
private fun wrongPassphrase(error: Throwable): UnrecoverableKeyException =
|
||||
causeChain(error).filterIsInstance<UnrecoverableKeyException>().firstOrNull()
|
||||
?: UnrecoverableKeyException("PKCS#12 passphrase was rejected (MAC/integrity check failed)")
|
||||
|
||||
/** Bounded cause-chain walk with an identity cycle-guard (never trust an error graph not to cycle). */
|
||||
private fun causeChain(error: Throwable): List<Throwable> {
|
||||
val chain = mutableListOf<Throwable>()
|
||||
var current: Throwable? = error
|
||||
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
|
||||
if (chain.any { it === current }) break
|
||||
chain.add(current)
|
||||
current = current.cause
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/** First alias that holds a private key; trusted-cert-only entries are skipped. */
|
||||
private fun firstKeyAlias(keyStore: KeyStore): String? =
|
||||
keyStore.aliases().toList().firstOrNull { keyStore.isKeyEntry(it) }
|
||||
|
||||
private fun buildIdentity(
|
||||
keyStore: KeyStore,
|
||||
alias: String,
|
||||
password: CharArray,
|
||||
): ParsedClientIdentity {
|
||||
// getKey with a wrong key-password throws UnrecoverableKeyException directly (propagated).
|
||||
val key = keyStore.getKey(alias, password) as? PrivateKey
|
||||
?: throw NoClientIdentityException("Entry '$alias' held no private key")
|
||||
val chain = (keyStore.getCertificateChain(alias) ?: emptyArray())
|
||||
.filterIsInstance<X509Certificate>()
|
||||
val leaf = chain.firstOrNull()
|
||||
?: throw NoClientIdentityException("Entry '$alias' had no certificate chain")
|
||||
return ParsedClientIdentity(
|
||||
alias = alias,
|
||||
privateKey = key,
|
||||
keyAlgorithm = key.algorithm,
|
||||
leafCertificate = leaf,
|
||||
issuerCertificates = chain.drop(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user