test: add JVM unit tests for Shadowsocks AEAD SIP004 vectors and link parser

This commit is contained in:
heaven 2026-06-16 12:30:53 +03:00
parent 40adf31985
commit 0b3f4adc77
3 changed files with 220 additions and 1 deletions

View file

@ -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")
}

View file

@ -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"))
}
}

View file

@ -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)
}
}