From 0b3f4adc774d6ef46df55dc8130e466123ec1fe8 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 16 Jun 2026 12:30:53 +0300 Subject: [PATCH] test: add JVM unit tests for Shadowsocks AEAD SIP004 vectors and link parser --- app/build.gradle.kts | 10 +- .../chat/vojo/proxy/core/ConfigImportTest.kt | 88 +++++++++++++ .../core/crypto/ShadowsocksCryptoTest.kt | 123 ++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/chat/vojo/proxy/core/ConfigImportTest.kt create mode 100644 app/src/test/java/chat/vojo/proxy/core/crypto/ShadowsocksCryptoTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a931711..6392cf2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "chat.vojo.proxy" compileSdk = 35 - ndkVersion = "27.2.12479018" + ndkVersion = "29.0.14206865" defaultConfig { applicationId = "chat.vojo.proxy" @@ -69,6 +69,12 @@ android { jvmTarget = "17" } + testOptions { + // Parser unit tests touch android.util.Base64 only on paths they don't assert on; + // returning defaults (null) keeps those calls from throwing the "Stub!" error. + unitTests.isReturnDefaultValues = true + } + packaging { resources { excludes += setOf( @@ -99,4 +105,6 @@ dependencies { implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + testImplementation("junit:junit:4.13.2") } diff --git a/app/src/test/java/chat/vojo/proxy/core/ConfigImportTest.kt b/app/src/test/java/chat/vojo/proxy/core/ConfigImportTest.kt new file mode 100644 index 0000000..fbf5200 --- /dev/null +++ b/app/src/test/java/chat/vojo/proxy/core/ConfigImportTest.kt @@ -0,0 +1,88 @@ +package chat.vojo.proxy.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit tests for the share-link parser. + * + * The base64 paths (`ss://` SIP002 base64url userinfo and the legacy whole-body base64) depend + * on android.util.Base64, which is a no-op stub on the desktop JVM — those are covered on-device + * and in the interop harness. Here we exercise the platform-independent paths and the specific + * bug fixes: #fragment percent-decoding (literal '+' preserved) and bare-IPv6 rejection. + */ +class ConfigImportTest { + + @Test fun socks5_withCredsAndName() { + val c = parseProxyUri("socks5://user:pass@1.2.3.4:1080#Home")!! + assertEquals(ProxyType.SOCKS5, c.type) + assertEquals("1.2.3.4", c.host) + assertEquals(1080, c.port) + assertEquals("user", c.username) + assertEquals("pass", c.password) + assertEquals("Home", c.name) + } + + @Test fun socks_defaultPort() { + val c = parseProxyUri("socks://proxy.example")!! + assertEquals(ProxyType.SOCKS5, c.type) + assertEquals("proxy.example", c.host) + assertEquals(1080, c.port) + } + + @Test fun http_and_https_defaultPorts() { + assertEquals(8080, parseProxyUri("http://h.example")!!.port) + assertEquals(ProxyType.HTTP, parseProxyUri("http://h.example")!!.type) + assertEquals(443, parseProxyUri("https://h.example")!!.port) + assertEquals(ProxyType.HTTPS, parseProxyUri("https://h.example")!!.type) + assertEquals(8443, parseProxyUri("https://h.example:8443")!!.port) + } + + /** parseUri uses java.net.URI.getFragment(), which percent-decodes but leaves '+' literal. */ + @Test fun http_fragmentName_percentDecoded_plusPreserved() { + assertEquals("C++ box", parseProxyUri("http://1.2.3.4:8080#C++%20box")!!.name) + } + + /** ss:// SIP002 with plain (non-base64) userinfo — base64 decode returns null on the JVM. */ + @Test fun ss_sip002_plainUserinfo() { + val c = parseProxyUri("ss://aes-256-gcm:secret@1.2.3.4:8388#My+Server")!! + assertEquals(ProxyType.SHADOWSOCKS, c.type) + assertEquals(SsCipher.AES_256_GCM, c.cipher) + assertEquals("secret", c.password) + assertEquals("1.2.3.4", c.host) + assertEquals(8388, c.port) + } + + /** Bug fix: the '+' in an ss:// name must stay a '+', not become a space (URLDecoder form-decoding). */ + @Test fun ss_fragmentName_literalPlusPreserved() { + assertEquals("My+Server", parseProxyUri("ss://aes-256-gcm:secret@1.2.3.4:8388#My+Server")!!.name) + } + + @Test fun ss_fragmentName_percentSpaceDecoded() { + assertEquals("My Server", parseProxyUri("ss://aes-256-gcm:secret@1.2.3.4:8388#My%20Server")!!.name) + } + + @Test fun ss_bracketedIpv6() { + val c = parseProxyUri("ss://aes-128-gcm:pw@[2001:db8::1]:8388")!! + assertEquals("2001:db8::1", c.host) + assertEquals(8388, c.port) + assertEquals(SsCipher.AES_128_GCM, c.cipher) + } + + /** Bug fix: a bare (unbracketed) IPv6 literal is ambiguous with the port and must be rejected, + * not mis-split into a wrong host/port. */ + @Test fun ss_bareIpv6_rejected() { + assertNull(parseProxyUri("ss://aes-128-gcm:pw@fe80::1:8388")) + } + + @Test fun ss_pluginLink_rejected() { + assertNull(parseProxyUri("ss://aes-256-gcm:secret@1.2.3.4:8388?plugin=obfs-local#x")) + } + + @Test fun garbage_returnsNull() { + assertNull(parseProxyUri("not a link")) + assertNull(parseProxyUri("")) + assertNull(parseProxyUri("ftp://host:21")) + } +} diff --git a/app/src/test/java/chat/vojo/proxy/core/crypto/ShadowsocksCryptoTest.kt b/app/src/test/java/chat/vojo/proxy/core/crypto/ShadowsocksCryptoTest.kt new file mode 100644 index 0000000..bef57e6 --- /dev/null +++ b/app/src/test/java/chat/vojo/proxy/core/crypto/ShadowsocksCryptoTest.kt @@ -0,0 +1,123 @@ +package chat.vojo.proxy.core.crypto + +import chat.vojo.proxy.core.SsCipher +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** + * SIP004 known-answer + round-trip tests for the Shadowsocks AEAD stack. + * + * The KAT vectors were cross-checked against shadowsocks-rust and sing-shadowsocks and then + * recomputed independently with python `cryptography` (master key, HKDF-SHA1 subkey, and the + * AES-128/256-GCM ciphertexts all matched bit-for-bit). ChaCha20-Poly1305 is intentionally + * NOT KAT-tested here: the desktop JVM's SunJCE has no "ChaCha20/Poly1305" provider — that + * cipher is exercised on-device (Conscrypt) and in the interop harness. + */ +class ShadowsocksCryptoTest { + + private fun hex(s: String) = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } + + @Test fun masterKey_aes128_matchesKat() { + assertEquals("5f4dcc3b5aa765d61d8327deb882cf99", + hex(ShadowsocksCrypto.deriveMasterKey("password", 16))) + } + + @Test fun masterKey_aes256_matchesKat() { + assertEquals("5f4dcc3b5aa765d61d8327deb882cf992b95990a9151374abd8ff8c5a7a0fe08", + hex(ShadowsocksCrypto.deriveMasterKey("password", 32))) + } + + @Test fun subkey_hkdfSha1_matchesKat() { + val mkA = ShadowsocksCrypto.deriveMasterKey("password", 16) + assertEquals("501ffe6120d190cf3d925631a9e704e2", + hex(ShadowsocksCrypto.sessionSubkey(mkA, ByteArray(16), 16))) + val mkC = ShadowsocksCrypto.deriveMasterKey("password", 32) + val saltC = ByteArray(32) { it.toByte() } + assertEquals("ee187aed3f87574907a39db98606f60a526114831288097cac66054b33a9464f", + hex(ShadowsocksCrypto.sessionSubkey(mkC, saltC, 32))) + } + + @Test fun aes128Gcm_seal_matchesKat() { + val aead = Aead.of(SsCipher.AES_128_GCM) + val subkey = hex("501ffe6120d190cf3d925631a9e704e2") + val nonce = ByteArray(12) + // length record (payload length 5) at nonce 0 + assertEquals("cec4816a581f7e487fe825ae7ed6bd47f734", + hex(aead.seal(subkey, nonce, byteArrayOf(0, 5)))) + // payload "hello" at nonce 1 (little-endian +1) + ShadowsocksCrypto.incrementNonce(nonce) + assertEquals("61f44c2747d63f5417a03f1d0acba9cd38d12241ef", + hex(aead.seal(subkey, nonce, "hello".toByteArray()))) + } + + @Test fun aes256Gcm_udp_kat_and_open() { + val aead = Aead.of(SsCipher.AES_256_GCM) + val mkC = ShadowsocksCrypto.deriveMasterKey("password", 32) + val saltC = ByteArray(32) { it.toByte() } + val subkey = ShadowsocksCrypto.sessionSubkey(mkC, saltC, 32) + val ct = aead.seal(subkey, ByteArray(12), "ABCD".toByteArray()) + assertEquals("3fe7d0e7cbe21d26c45a872a214577fbb4dde4c9", hex(ct)) + // ssUdpOpen reconstructs the subkey from the prepended salt and decrypts. + val packet = saltC + ct + assertArrayEquals("ABCD".toByteArray(), ssUdpOpen(aead, mkC, packet, 0, packet.size)) + } + + @Test fun incrementNonce_carriesLittleEndian() { + val n = ByteArray(12) + ShadowsocksCrypto.incrementNonce(n) + assertEquals("010000000000000000000000", hex(n)) + val c = ByteArray(12).also { it[0] = 0xff.toByte() } + ShadowsocksCrypto.incrementNonce(c) + assertEquals("000100000000000000000000", hex(c)) + } + + private fun roundTrip(cipher: SsCipher, payload: ByteArray) { + val mk = ShadowsocksCrypto.deriveMasterKey("pw", cipher.keySize) + val sink = ByteArrayOutputStream() + SsOutputStream(sink, Aead.of(cipher), mk).use { it.write(payload) } + val got = SsInputStream(ByteArrayInputStream(sink.toByteArray()), Aead.of(cipher), mk).readBytes() + assertArrayEquals(payload, got) + } + + @Test fun roundTrip_aes128_small() = roundTrip(SsCipher.AES_128_GCM, "hello world".toByteArray()) + + /** Forces multi-chunk framing (> 2 × MAX_PAYLOAD) so chunking + nonce sequencing is exercised. */ + @Test fun roundTrip_aes256_multichunk() = + roundTrip(SsCipher.AES_256_GCM, ByteArray(0x3FFF * 2 + 100) { (it and 0xff).toByte() }) + + @Test fun wrongKey_open_throws() { + val aead = Aead.of(SsCipher.AES_128_GCM) + val good = ShadowsocksCrypto.sessionSubkey(ShadowsocksCrypto.deriveMasterKey("right", 16), ByteArray(16), 16) + val bad = ShadowsocksCrypto.sessionSubkey(ShadowsocksCrypto.deriveMasterKey("wrong", 16), ByteArray(16), 16) + val ct = aead.seal(good, ByteArray(12), "secret".toByteArray()) + assertThrows(javax.crypto.AEADBadTagException::class.java) { + aead.open(bad, ByteArray(12), ct) + } + } + + /** D1 regression: an empty (tag-only) chunk between data chunks must be accepted, not rejected. */ + @Test fun zeroLengthChunk_isAccepted() { + val aead = Aead.of(SsCipher.AES_128_GCM) + val mk = ShadowsocksCrypto.deriveMasterKey("pw", 16) + val salt = ByteArray(16) { (it + 7).toByte() } + val subkey = ShadowsocksCrypto.sessionSubkey(mk, salt, 16) + val nonce = ByteArray(12) + val out = ByteArrayOutputStream() + out.write(salt) + fun record(data: ByteArray) { + val l = byteArrayOf((data.size ushr 8).toByte(), data.size.toByte()) + out.write(aead.seal(subkey, nonce, l)); ShadowsocksCrypto.incrementNonce(nonce) + out.write(aead.seal(subkey, nonce, data)); ShadowsocksCrypto.incrementNonce(nonce) + } + record("hi".toByteArray()) + record(ByteArray(0)) // empty keep-alive frame — must be skipped, not fatal + record("!".toByteArray()) + val got = SsInputStream(ByteArrayInputStream(out.toByteArray()), aead, mk).readBytes() + assertArrayEquals("hi!".toByteArray(), got) + } +}