test: add JVM unit tests for Shadowsocks AEAD SIP004 vectors and link parser
This commit is contained in:
parent
40adf31985
commit
0b3f4adc77
3 changed files with 220 additions and 1 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
88
app/src/test/java/chat/vojo/proxy/core/ConfigImportTest.kt
Normal file
88
app/src/test/java/chat/vojo/proxy/core/ConfigImportTest.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue