This commit is contained in:
2026-09-13 12:15:36 -07:00
commit e473d00f4f
104 changed files with 16080 additions and 0 deletions
@@ -0,0 +1,52 @@
package android.util;
import java.nio.charset.StandardCharsets;
/**
* JVM test implementation of android.util.Base64 using java.util.Base64.
* Enables running Android SRP cryptographic tests on standard desktop JVM.
*/
public class Base64 {
public static final int DEFAULT = 0;
public static final int NO_PADDING = 1;
public static final int NO_WRAP = 2;
public static final int CRLF = 4;
public static final int URL_SAFE = 8;
public static final int NO_CLOSE = 16;
public static String encodeToString(byte[] input, int flags) {
return encodeToString(input, 0, input.length, flags);
}
public static String encodeToString(byte[] input, int offset, int len, int flags) {
byte[] slice;
if (offset == 0 && len == input.length) {
slice = input;
} else {
slice = new byte[len];
System.arraycopy(input, offset, slice, 0, len);
}
java.util.Base64.Encoder encoder = ((flags & URL_SAFE) != 0)
? java.util.Base64.getUrlEncoder()
: java.util.Base64.getEncoder();
if ((flags & NO_PADDING) != 0) {
encoder = encoder.withoutPadding();
}
return encoder.encodeToString(slice);
}
public static byte[] encode(byte[] input, int flags) {
return encodeToString(input, flags).getBytes(StandardCharsets.UTF_8);
}
public static byte[] decode(String str, int flags) {
java.util.Base64.Decoder decoder = ((flags & URL_SAFE) != 0)
? java.util.Base64.getUrlDecoder()
: java.util.Base64.getDecoder();
return decoder.decode(str);
}
public static byte[] decode(byte[] input, int flags) {
return decode(new String(input, StandardCharsets.UTF_8), flags);
}
}
@@ -0,0 +1,171 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.security.MessageDigest
import java.security.SecureRandom
class PortalSrpChannelBindingTest : TestSuite("Cryptographic TLS Channel Binding Enforcement") {
init {
val random = SecureRandom()
fun fakeCertHash(identifier: String): ByteArray =
MessageDigest.getInstance("SHA-256").digest(identifier.toByteArray(Charsets.UTF_8))
test("Channel Binding: Legitimate TLS connection succeeds and produces valid M2") {
val pin = "718293"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val legitimateTlsCertHash = fakeCertHash("CN=PortalCam Server Cert V1")
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = legitimateTlsCertHash
)
val result = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = legitimateTlsCertHash
)
assertTrue(result is PortalSrp.VerifyResult.Success, "Legitimate TLS channel binding must succeed")
val success = result as PortalSrp.VerifyResult.Success
val m2Hex = PortalSrp.bytesToHex(success.M2)
assertTrue(client.verifyServerM2(m2Hex), "Client must accept server M2 bound to same TLS cert")
}
test("Channel Binding: Active MITM Proxy attack REJECTED by server") {
// Threat Model:
// An active MITM proxy (e.g. mitmproxy, Charles, or rogue gateway) intercepts
// the HTTPS connection. The proxy creates a separate TLS connection to the client
// using a forged CA, and another TLS connection to the Portal server using the Portal's cert.
// Client sees Cert_MITM; Server sees Cert_Real.
val pin = "998877"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val realServerTlsCertHash = fakeCertHash("CN=PortalCam Real Hardware Cert")
val mitmProxyTlsCertHash = fakeCertHash("CN=mitmproxy Fake Interceptor Cert")
// Client computes M1 bound to the MITM's TLS certificate
val m1FromClientUnderMitm = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = mitmProxyTlsCertHash
)
// MITM forwards client's (A, M1) to real server
val serverResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1FromClientUnderMitm,
tlsHash = realServerTlsCertHash // Server uses its genuine TLS cert hash
)
// Server MUST reject because M1 is bound to MITM's cert, not the server's cert!
assertTrue(
serverResult is PortalSrp.VerifyResult.Failed,
"Server MUST reject M1 generated under an active MITM proxy"
)
val failed = serverResult as PortalSrp.VerifyResult.Failed
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter")
}
test("Channel Binding: Single bit flip in TLS cert hash triggers authentication failure") {
val pin = "456123"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val originalCertHash = fakeCertHash("CN=PortalCam Genuine Cert")
val tamperedCertHash = originalCertHash.clone().also {
it[0] = (it[0].toInt() xor 0x01).toByte() // Flip 1 bit
}
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tamperedCertHash
)
val result = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = originalCertHash
)
assertTrue(result is PortalSrp.VerifyResult.Failed, "1-bit altered TLS hash must fail")
val failed = result as PortalSrp.VerifyResult.Failed
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
}
test("Channel Binding: Client rejects server M2 if TLS channel binding is altered") {
val pin = "654987"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val clientCertHash = fakeCertHash("CN=Client Observed Cert")
val serverCertHash = fakeCertHash("CN=Client Observed Cert")
val attackerAlteredCertHash = fakeCertHash("CN=Attacker Injected Cert")
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = clientCertHash
)
val result = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, serverCertHash)
assertTrue(result is PortalSrp.VerifyResult.Success)
val success = result as PortalSrp.VerifyResult.Success
// Attacker tries to forge M2 with altered cert hash
val forgedM2 = PortalSrp.sha256(
PortalSrp.toPadded256(client.A),
client.M1!!,
client.K!!,
attackerAlteredCertHash
)
val accepted = client.verifyServerM2(PortalSrp.bytesToHex(forgedM2))
assertFalse(accepted, "Client must reject M2 bound to an altered TLS cert hash")
}
test("Channel Binding: Truncated or empty TLS cert hash fails verification") {
val pin = "123123"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val fullCertHash = fakeCertHash("CN=PortalCam Cert Full")
val emptyCertHash = ByteArray(0)
val truncatedCertHash = ByteArray(16) { fullCertHash[it] }
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = fullCertHash
)
// Server receives empty or truncated TLS hash
val resultEmpty = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, emptyCertHash)
assertTrue(resultEmpty is PortalSrp.VerifyResult.Failed)
val resultTruncated = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, truncatedCertHash)
assertTrue(resultTruncated is PortalSrp.VerifyResult.Failed)
}
}
}
@@ -0,0 +1,94 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
class PortalSrpConstantTimeTest : TestSuite("Constant-Time Comparison constantTimeEquals") {
init {
test("Constant-time: Empty byte arrays match") {
assertTrue(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(0)))
}
test("Constant-time: Single-byte matching and non-matching arrays") {
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x00)))
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7F)))
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0xFF.toByte()), byteArrayOf(0xFF.toByte())))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x01)))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7E)))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x80.toByte()), byteArrayOf(0x00)))
}
test("Constant-time: 32-byte SHA-256 matching and non-matching arrays") {
val a = ByteArray(32) { (it * 7).toByte() }
val b = a.clone()
assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 32-byte arrays must match")
// Differing at index 0
val diffFirst = a.clone().also { it[0] = (it[0].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffFirst), "Difference at first byte must not match")
// Differing at middle index 16
val diffMid = a.clone().also { it[16] = (it[16].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffMid), "Difference at middle byte must not match")
// Differing at last index 31
val diffLast = a.clone().also { it[31] = (it[31].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffLast), "Difference at last byte must not match")
}
test("Constant-time: 256-byte MODP key matching and non-matching arrays") {
val a = ByteArray(256) { (it xor 0x5A).toByte() }
val b = a.clone()
assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 256-byte arrays must match")
val diff = a.clone().also { it[128] = (it[128].toInt() xor 0x80).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diff), "Differing 256-byte arrays must not match")
}
test("Constant-time: Different length byte arrays must be rejected") {
val base = ByteArray(32) { 0xAA.toByte() }
assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(31) { 0xAA.toByte() }))
assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(33) { 0xAA.toByte() }))
assertFalse(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(1)))
assertFalse(PortalSrp.constantTimeEquals(ByteArray(1), ByteArray(0)))
// Shared prefix but different length
val prefix = byteArrayOf(1, 2, 3)
val longer = byteArrayOf(1, 2, 3, 4)
assertFalse(PortalSrp.constantTimeEquals(prefix, longer))
assertFalse(PortalSrp.constantTimeEquals(longer, prefix))
}
test("Constant-time: Negative signed byte value edge cases") {
// In Java, byte is signed (-128 to 127).
// Verify bitwise operations properly handle negative bytes (0x80..0xFF) without sign extension bugs.
val a = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte())
val b = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte())
assertTrue(PortalSrp.constantTimeEquals(a, b))
val c = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFD.toByte())
assertFalse(PortalSrp.constantTimeEquals(a, c))
val d = byteArrayOf(0x00, 0xFF.toByte(), 0xFE.toByte())
assertFalse(PortalSrp.constantTimeEquals(a, d))
}
test("Constant-time: Single bit divergence test across all 256 bits of SHA-256 hash") {
val original = ByteArray(32) { 0x55.toByte() }
for (byteIdx in 0 until 32) {
for (bitIdx in 0 until 8) {
val mutated = original.clone()
mutated[byteIdx] = (mutated[byteIdx].toInt() xor (1 shl bitIdx)).toByte()
assertFalse(
PortalSrp.constantTimeEquals(original, mutated),
"Must detect 1-bit difference at byte $byteIdx, bit $bitIdx"
)
}
}
}
}
}
@@ -0,0 +1,151 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertNotNull
import com.portaltv.capability.test.Assert.assertTrue
import java.security.MessageDigest
import java.security.SecureRandom
class PortalSrpIntegrationTest : TestSuite("Portal SRP-6a End-to-End & Protocol Integration") {
init {
val random = SecureRandom()
fun sha256Hex(s: String): String =
MessageDigest.getInstance("SHA-256")
.digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
test("E2E Integration: Full PortalStreamingService SRP Handshake & Bearer Auth Flow") {
// Emulates PortalStreamingService state machine in pure Kotlin/JVM
val authTokens = mutableSetOf<String>()
val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val serverPin = "654321"
// 1. Client fetches server cert hash (GET /auth/cert)
val clientSeenCertSha256 = serverTlsCertSha256.clone()
// 2. Server initiates SRP pairing (POST /auth/srp/init)
val pairing = PortalSrp.newPairing(serverPin)
val saltHex = PortalSrp.bytesToHex(pairing.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB))
// 3. Client initializes SRP client and computes A, M1
val client = PortalSrpClient()
val m1Hex = client.computeM1(
saltHex = saltHex,
pubBHex = bHex,
pin = serverPin,
tlsCertSha256 = clientSeenCertSha256
)
// 4. Client sends verification request (POST /auth/srp/verify)
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = serverTlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Verify client must succeed")
val success = verifyResult as PortalSrp.VerifyResult.Success
// Server records token hash
val tokenHash = sha256Hex(success.token)
authTokens.add(tokenHash)
// 5. Client verifies server M2
val m2Hex = PortalSrp.bytesToHex(success.M2)
assertTrue(client.verifyServerM2(m2Hex), "Client must accept server evidence M2")
// 6. Client uses bearer token on protected endpoint (GET /video.h264)
fun checkAuth(header: String?): Boolean {
if (header == null || !header.startsWith("Bearer ")) return false
val token = header.substring(7)
return authTokens.contains(sha256Hex(token))
}
assertTrue(checkAuth("Bearer ${success.token}"), "Legitimate token must grant access")
Assert.assertFalse(checkAuth("Bearer bogus-token"), "Forged token must be rejected")
Assert.assertFalse(checkAuth(null), "Missing authorization must be rejected")
}
test("E2E Integration: MITM Proxy intercepted handshake rejected at HTTP layer") {
val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val mitmProxyCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val pin = "889900"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
// Client computes M1 bound to MITM proxy's certificate
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = mitmProxyCertSha256
)
// Proxy relays M1 to server; Server checks against its real TLS cert
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = serverTlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Failed)
val failed = verifyResult as PortalSrp.VerifyResult.Failed
assertEquals(2, failed.attemptsLeft)
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
}
test("E2E Integration: Brute-force PIN attack exhausts 3 attempts and invalidates pairing") {
val correctPin = "987654"
var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing(correctPin)
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
// Attacker makes 3 wrong guesses
val badGuesses = listOf("000000", "111111", "222222")
for ((index, guess) in badGuesses.withIndex()) {
val currentPairing = activePairing
assertNotNull(currentPairing, "Pairing must exist for attempt ${index + 1}")
val attackerClient = PortalSrpClient()
val attackerM1 = attackerClient.computeM1(
saltHex = PortalSrp.bytesToHex(currentPairing!!.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(currentPairing.pubB)),
pin = guess,
tlsCertSha256 = tlsCertSha256
)
val result = PortalSrp.verifyClient(
pairing = currentPairing,
A_hex = attackerClient.pubAHex,
M1_hex = attackerM1,
tlsHash = tlsCertSha256
)
assertTrue(result is PortalSrp.VerifyResult.Failed)
val failed = result as PortalSrp.VerifyResult.Failed
val expectedRemaining = 2 - index
assertEquals(expectedRemaining, failed.attemptsLeft)
if (failed.attemptsLeft <= 0) {
activePairing = null // Emulate PortalStreamingService wipeout
}
}
// Session is wiped
Assert.assertNull(activePairing, "Pairing session must be wiped after 3 failed attempts")
// 4th attempt: Even with the correct PIN, request fails because session no longer exists
val legitimateClient = PortalSrpClient()
val hasSession = activePairing != null
Assert.assertFalse(hasSession, "Cannot authenticate against wiped pairing session")
}
}
}
@@ -0,0 +1,149 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertArrayEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertNotNull
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpMathTest : TestSuite("2048-bit RFC 5054 SRP-6a Group Parameters & Key Agreement") {
init {
test("RFC 5054 2048-bit Prime N bit-length and primality verification") {
assertEquals(2048, PortalSrp.N.bitLength(), "N must be exactly 2048 bits")
assertTrue(PortalSrp.N.testBit(0), "N must be odd")
assertTrue(PortalSrp.N.isProbablePrime(100), "N must pass Miller-Rabin primality check with certainty 100")
// Safe prime check: (N - 1) / 2 is also prime
val q = PortalSrp.N.subtract(BigInteger.ONE).divide(BigInteger.valueOf(2))
assertTrue(q.isProbablePrime(80), "Sophie Germain / safe prime (N-1)/2 must be probable prime")
}
test("RFC 5054 Generator g and Multiplier k verification") {
assertEquals(BigInteger.valueOf(2), PortalSrp.g, "Generator g must be 2")
val expectedKBytes = PortalSrp.sha256(
PortalSrp.toPadded256(PortalSrp.N),
PortalSrp.toPadded256(PortalSrp.g)
)
val expectedK = BigInteger(1, expectedKBytes)
assertEquals(expectedK, PortalSrp.k, "PortalSrp.k must equal SHA256(PAD256(N) || PAD256(g))")
}
test("SRP-6a 2048-bit mutual key agreement K_client == K_server, M1 and M2 verification") {
val random = SecureRandom()
val pin = "849201"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient(random = random)
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB))
val saltHex = PortalSrp.bytesToHex(pairing.salt)
// Client computes M1
val m1Hex = client.computeM1(
saltHex = saltHex,
pubBHex = bHex,
pin = pin,
tlsCertSha256 = tlsCertSha256
)
// Verify Client S and Server S mathematical agreement
val A_bytes = PortalSrp.toPadded256(client.A)
val B_bytes = PortalSrp.toPadded256(pairing.pubB)
val uBytes = PortalSrp.sha256(A_bytes, B_bytes)
val u = BigInteger(1, uBytes)
val vu = pairing.v.modPow(u, PortalSrp.N)
val sServer = client.A.multiply(vu).mod(PortalSrp.N).modPow(pairing.privB, PortalSrp.N)
val kServer = PortalSrp.sha256(PortalSrp.toPadded256(sServer))
assertNotNull(client.K, "Client session key K must not be null")
assertArrayEquals(kServer, client.K!!, "Cryptographic key agreement failed: K_client != K_server")
// Server verifies M1
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = tlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Server must accept valid M1 from client")
val success = verifyResult as PortalSrp.VerifyResult.Success
assertTrue(success.token.isNotEmpty(), "Server must issue bearer token upon successful verification")
// Client verifies M2
val m2Hex = PortalSrp.bytesToHex(success.M2)
val m2Valid = client.verifyServerM2(m2Hex)
assertTrue(m2Valid, "Client must verify server's M2 successfully")
}
test("SRP-6a mathematical agreement across diverse PIN formats (numeric, alphanumeric, utf8)") {
val testPins = listOf(
"000000",
"999999",
"123456",
"PortalPass-2026!#$",
"Secure🔐Pässtöken-12345",
"a",
"SuperLongPINExceedingStandardLengthsForStressTestingTheSha256HashingPipeline1234567890"
)
val random = SecureRandom()
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
for (pin in testPins) {
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient(random = random)
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = tlsCertSha256
)
assertTrue(
verifyResult is PortalSrp.VerifyResult.Success,
"Pairing must succeed for PIN: '$pin'"
)
val success = verifyResult as PortalSrp.VerifyResult.Success
assertTrue(
client.verifyServerM2(PortalSrp.bytesToHex(success.M2)),
"M2 verification must succeed for PIN: '$pin'"
)
}
}
test("Uniqueness and entropy of pairing parameters across multiple sessions") {
val pin = "555123"
val sessionCount = 50
val ids = mutableSetOf<String>()
val salts = mutableSetOf<String>()
val pubBs = mutableSetOf<BigInteger>()
for (i in 0 until sessionCount) {
val pairing = PortalSrp.newPairing(pin)
ids.add(pairing.id)
salts.add(PortalSrp.bytesToHex(pairing.salt))
pubBs.add(pairing.pubB)
}
assertEquals(sessionCount, ids.size, "All pairing IDs must be unique (sufficient entropy)")
assertEquals(sessionCount, salts.size, "All pairing salts must be unique (sufficient entropy)")
assertEquals(sessionCount, pubBs.size, "All server public keys B must be unique")
}
}
}
@@ -0,0 +1,115 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertArrayEquals
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpPaddingTest : TestSuite("BigInteger toPadded256 Edge Cases") {
init {
test("Padding: BigInteger.ZERO produces 256 zero bytes") {
val padded = PortalSrp.toPadded256(BigInteger.ZERO)
assertEquals(256, padded.size, "Output must be exactly 256 bytes")
val expected = ByteArray(256)
assertArrayEquals(expected, padded, "ZERO must produce all zero bytes")
}
test("Padding: BigInteger.ONE produces 255 zeros followed by 0x01") {
val padded = PortalSrp.toPadded256(BigInteger.ONE)
assertEquals(256, padded.size)
for (i in 0 until 255) {
assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i")
}
assertEquals(1.toByte(), padded[255], "Last byte must be 1")
}
test("Padding: Small BigInteger value (g = 2) produces 255 leading zeros") {
val padded = PortalSrp.toPadded256(PortalSrp.g)
assertEquals(256, padded.size)
for (i in 0 until 255) {
assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i")
}
assertEquals(2.toByte(), padded[255], "Last byte must be 2")
}
test("Padding: 128-bit (16-byte) BigInteger produces 240 leading zeros followed by 16 bytes") {
val raw16 = ByteArray(16) { (it + 1).toByte() }
val bi = BigInteger(1, raw16)
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size)
for (i in 0 until 240) {
assertEquals(0.toByte(), padded[i], "Must have 240 leading zeros")
}
for (i in 0 until 16) {
assertEquals((i + 1).toByte(), padded[240 + i])
}
}
test("Padding: Exact 256-byte positive BigInteger with MSB 0 (raw.size == 256)") {
// High byte 0x7F ensures sign bit is 0, so raw.size == 256
val raw256 = ByteArray(256) { it.toByte() }
raw256[0] = 0x7F.toByte()
val bi = BigInteger(1, raw256)
assertEquals(256, bi.toByteArray().size, "BigInteger.toByteArray() should be 256 bytes")
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size)
assertArrayEquals(raw256, padded, "Exact 256-byte number must be preserved without distortion")
}
test("Padding: Exact 2048-bit BigInteger with MSB 1 (raw.size == 257 due to Java sign byte)") {
// When MSB is 1, Java BigInteger.toByteArray() adds a leading 0x00 sign byte (257 bytes total).
// toPadded256 must strip the 0x00 sign byte and return the 256 significant bytes.
val raw256 = ByteArray(256) { 0xFF.toByte() }
val bi = BigInteger(1, raw256) // 2^2048 - 1
assertEquals(257, bi.toByteArray().size, "toByteArray() must contain 257 bytes with sign prefix")
assertEquals(0.toByte(), bi.toByteArray()[0], "First byte of toByteArray() must be sign byte 0x00")
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size, "Output must be exactly 256 bytes")
assertArrayEquals(raw256, padded, "Sign byte 0x00 must be stripped and 256 0xFF bytes retained")
}
test("Padding: RFC 5054 2048-bit prime N padding verification") {
assertEquals(257, PortalSrp.N.toByteArray().size, "N.toByteArray() has 257 bytes due to MSB=1")
val paddedN = PortalSrp.toPadded256(PortalSrp.N)
assertEquals(256, paddedN.size, "Padded N must be 256 bytes")
assertEquals(0xFF.toByte(), paddedN[0], "First byte of padded N must be 0xFF")
assertEquals(0xFF.toByte(), paddedN[255], "Last byte of padded N must be 0xFF")
}
test("Padding: Negative BigInteger representation handling") {
// Negative numbers in Java BigInteger: verify no ArrayIndexOutOfBoundsException or crashing
val negOne = BigInteger.valueOf(-1)
val paddedNegOne = PortalSrp.toPadded256(negOne)
assertEquals(256, paddedNegOne.size, "Padded negative BigInteger must be 256 bytes")
val neg128 = BigInteger.valueOf(-128)
val paddedNeg128 = PortalSrp.toPadded256(neg128)
assertEquals(256, paddedNeg128.size)
val negN = PortalSrp.N.negate()
val paddedNegN = PortalSrp.toPadded256(negN)
assertEquals(256, paddedNegN.size)
}
test("Padding: Oversized BigInteger (> 256 bytes) extracts lowest 256 bytes") {
// 258-byte number (2064 bits)
val raw258 = ByteArray(258) { (it % 256).toByte() }
raw258[0] = 0x01.toByte()
val bi = BigInteger(1, raw258)
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size, "Output must be clamped to 256 bytes")
// Verify it took the last 256 bytes
val expectedSuffix = ByteArray(256)
System.arraycopy(raw258, 2, expectedSuffix, 0, 256)
assertArrayEquals(expectedSuffix, padded, "Must extract lowest 256 bytes")
}
}
}
@@ -0,0 +1,196 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpRateLimitingTest : TestSuite("SRP-6a Rate Limiting & Session Invalidation") {
init {
test("Rate limiting: Exact 3-attempt lifecycle enforcement (3 -> 2 -> 1 -> 0 -> cancelled)") {
val pin = "123456"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x42 }
assertEquals(3, pairing.attemptsLeft, "New pairing must start with exactly 3 attempts left")
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0x99.toByte() })
// Attempt 1: Failed
val res1 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res1 is PortalSrp.VerifyResult.Failed, "Attempt 1 with bogus M1 must fail")
val fail1 = res1 as PortalSrp.VerifyResult.Failed
assertEquals(2, fail1.attemptsLeft, "Attempt 1 failure must leave 2 attempts")
assertEquals(2, pairing.attemptsLeft, "Pairing object state must show 2 attempts left")
// Attempt 2: Failed
val res2 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res2 is PortalSrp.VerifyResult.Failed, "Attempt 2 with bogus M1 must fail")
val fail2 = res2 as PortalSrp.VerifyResult.Failed
assertEquals(1, fail2.attemptsLeft, "Attempt 2 failure must leave 1 attempt")
assertEquals(1, pairing.attemptsLeft, "Pairing object state must show 1 attempt left")
// Attempt 3: Failed -> 0 attempts left
val res3 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res3 is PortalSrp.VerifyResult.Failed, "Attempt 3 with bogus M1 must fail")
val fail3 = res3 as PortalSrp.VerifyResult.Failed
assertEquals(0, fail3.attemptsLeft, "Attempt 3 failure must leave 0 attempts")
assertEquals(0, pairing.attemptsLeft, "Pairing object state must show 0 attempts left")
// Subsequent Attempt 4 on exhausted session: Must be blocked immediately
val res4 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res4 is PortalSrp.VerifyResult.Failed, "Attempt on exhausted session must fail")
val fail4 = res4 as PortalSrp.VerifyResult.Failed
assertEquals(0, fail4.attemptsLeft)
assertEquals("Too many failed attempts; pairing cancelled", fail4.message)
// Even if correct credentials are now provided, exhausted pairing must remain cancelled
val validM1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val resValidOnExhausted = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = validM1Hex,
tlsHash = tlsCertSha256
)
assertTrue(
resValidOnExhausted is PortalSrp.VerifyResult.Failed,
"Exhausted pairing must reject even valid credentials"
)
assertEquals(
"Too many failed attempts; pairing cancelled",
(resValidOnExhausted as PortalSrp.VerifyResult.Failed).message
)
}
test("Rate limiting: Service-level session wipeout on 0 attempts remaining") {
// Simulates PortalStreamingService session lifecycle:
// When res.attemptsLeft <= 0, pairing is wiped (pairing = null).
// A subsequent request detects pairing == null -> rejected (session not found).
var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing("654321")
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x11 }
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0xEE.toByte() })
fun serviceVerify(A: String, M1: String): Pair<Int, String> {
val p = activePairing ?: return Pair(400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}")
val res = PortalSrp.verifyClient(p, A, M1, tlsCertSha256)
return when (res) {
is PortalSrp.VerifyResult.Success -> {
activePairing = null
Pair(200, "{\"token\":\"${res.token}\"}")
}
is PortalSrp.VerifyResult.Failed -> {
if (res.attemptsLeft <= 0) {
activePairing = null // Session wiped!
}
Pair(401, "{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft}}")
}
}
}
// Attempt 1: 401, 2 attempts left
val (status1, body1) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status1)
assertTrue(body1.contains("\"attemptsLeft\":2"))
assertTrue(activePairing != null, "Session must still exist after attempt 1")
// Attempt 2: 401, 1 attempt left
val (status2, body2) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status2)
assertTrue(body2.contains("\"attemptsLeft\":1"))
assertTrue(activePairing != null, "Session must still exist after attempt 2")
// Attempt 3: 401, 0 attempts left, session wiped
val (status3, body3) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status3)
assertTrue(body3.contains("\"attemptsLeft\":0"))
assertTrue(activePairing == null, "Session must be wiped after 3 failed attempts")
// Attempt 4: 400 session not found / no active pairing
val (status4, body4) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(400, status4, "Subsequent attempt after wipeout must return 400")
assertTrue(body4.contains("no_active_pairing"), "Must report session not found")
}
test("Rate limiting: Recovery on valid attempt after prior failed attempt") {
val pin = "345678"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x33 }
// Failed attempt 1
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32))
val res1 = PortalSrp.verifyClient(pairing, client.pubAHex, bogusM1, tlsCertSha256)
assertTrue(res1 is PortalSrp.VerifyResult.Failed)
assertEquals(2, (res1 as PortalSrp.VerifyResult.Failed).attemptsLeft)
// Successful attempt 2 with correct PIN and M1
val validM1 = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val res2 = PortalSrp.verifyClient(pairing, client.pubAHex, validM1, tlsCertSha256)
assertTrue(res2 is PortalSrp.VerifyResult.Success, "Valid attempt 2 after 1 failure must succeed")
}
test("Rate limiting: Session expiration blocks verification") {
val pin = "112233"
val expiredPairing = PortalSrp.ActivePairing(
id = "expired-session-id",
pin = pin,
salt = ByteArray(16),
v = BigInteger.valueOf(3),
privB = BigInteger.valueOf(4),
pubB = BigInteger.valueOf(5),
expiresAt = System.currentTimeMillis() - 5000L, // Expired 5 seconds ago
attemptsLeft = 3
)
val client = PortalSrpClient()
val dummyTls = ByteArray(32)
val res = PortalSrp.verifyClient(
pairing = expiredPairing,
A_hex = client.pubAHex,
M1_hex = PortalSrp.bytesToHex(ByteArray(32)),
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed)
val fail = res as PortalSrp.VerifyResult.Failed
assertEquals(0, fail.attemptsLeft)
assertEquals("Pairing session expired", fail.message)
}
}
}
@@ -0,0 +1,190 @@
package com.portaltv.capability.test
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.MessageDigest
class PortalSrpRfc5054Test : TestSuite("RFC 5054 Appendix B Test Vectors") {
companion object {
private fun cleanHex(s: String) = s.replace("\\s+".toRegex(), "").lowercase()
// 1024-bit prime from RFC 5054 Appendix A
val N_HEX = cleanHex(
"""
EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C
9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4
8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29
7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A
FD5138FE 8376435B 9FC61D2F C0EB06E3
"""
)
val N = BigInteger(N_HEX, 16)
val g = BigInteger.valueOf(2)
const val I = "alice"
const val P = "password123"
val SALT_HEX = cleanHex("BEB25379 D1A8581E B5A72767 3A2441EE")
val K_EXPECTED = cleanHex("7556AA04 5AEF2CDD 07ABAF0F 665C3E81 8913186F")
val X_EXPECTED = cleanHex("94B7555A ABE9127C C58CCF49 93DB6CF8 4D16C124")
val V_EXPECTED = cleanHex(
"""
7E273DE8 696FFC4F 4E337D05 B4B375BE B0DDE156 9E8FA00A 9886D812
9BADA1F1 822223CA 1A605B53 0E379BA4 729FDC59 F105B478 7E5186F5
C671085A 1447B52A 48CF1970 B4FB6F84 00BBF4CE BFBB1681 52E08AB5
EA53D15C 1AFF87B2 B9DA6E04 E058AD51 CC72BFC9 033B564E 26480D78
E955A5E2 9E7AB245 DB2BE315 E2099AFB
"""
)
val A_PRIV_HEX = cleanHex("60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393")
val B_PRIV_HEX = cleanHex("E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20")
val A_PUB_EXPECTED = cleanHex(
"""
61D5E490 F6F1B795 47B0704C 436F523D D0E560F0 C64115BB 72557EC4
4352E890 3211C046 92272D8B 2D1A5358 A2CF1B6E 0BFCF99F 921530EC
8E393561 79EAE45E 42BA92AE ACED8251 71E1E8B9 AF6D9C03 E1327F44
BE087EF0 6530E69F 66615261 EEF54073 CA11CF58 58F0EDFD FE15EFEA
B349EF5D 76988A36 72FAC47B 0769447B
"""
)
val B_PUB_EXPECTED = cleanHex(
"""
BD0C6151 2C692C0C B6D041FA 01BB152D 4916A1E7 7AF46AE1 05393011
BAF38964 DC46A067 0DD125B9 5A981652 236F99D9 B681CBF8 7837EC99
6C6DA044 53728610 D0C6DDB5 8B318885 D7D82C7F 8DEB75CE 7BD4FBAA
37089E6F 9C6059F3 88838E7A 00030B33 1EB76840 910440B1 B27AAEAE
EB4012B7 D7665238 A8E3FB00 4B117B58
"""
)
val U_EXPECTED = cleanHex("CE38B959 3487DA98 554ED47D 70A7AE5F 462EF019")
val S_EXPECTED = cleanHex(
"""
B0DC82BA BCF30674 AE450C02 87745E79 90A3381F 63B387AA F271A10D
233861E3 59B48220 F7C4693C 9AE12B0A 6F67809F 0876E2D0 13800D6C
41BB59B6 D5979B5C 00A172B4 A2A5903A 0BDCAF8A 709585EB 2AFAFA8F
3499B200 210DCC1F 10EB3394 3CD67FC8 8A2F39A4 BE5BEC4E C0A3212D
C346D7E4 74B29EDE 8A469FFE CA686E5A
"""
)
fun sha1(vararg parts: ByteArray): ByteArray {
val md = MessageDigest.getInstance("SHA-1")
for (p in parts) md.update(p)
return md.digest()
}
fun toPadded128(bi: BigInteger): ByteArray {
val raw = bi.toByteArray()
val result = ByteArray(128)
if (raw.size > 128) {
System.arraycopy(raw, raw.size - 128, result, 0, 128)
} else {
System.arraycopy(raw, 0, result, 128 - raw.size, raw.size)
}
return result
}
fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return data
}
}
init {
test("RFC 5054 - Multiplier k = H(PAD(N) || PAD(g)) verification") {
val nBytes = toPadded128(N)
val gBytes = toPadded128(g)
val kBytes = sha1(nBytes, gBytes)
val kHex = bytesToHex(kBytes)
assertEquals(K_EXPECTED, kHex, "Multiplier k must match RFC 5054 test vector")
}
test("RFC 5054 - Password hash x = H(s || H(I || ':' || P)) verification") {
val inner = sha1("$I:$P".toByteArray(Charsets.UTF_8))
val salt = hexToBytes(SALT_HEX)
val xBytes = sha1(salt, inner)
val xHex = bytesToHex(xBytes)
assertEquals(X_EXPECTED, xHex, "Password hash x must match RFC 5054 test vector")
}
test("RFC 5054 - Verifier v = g^x mod N verification") {
val x = BigInteger(X_EXPECTED, 16)
val v = g.modPow(x, N)
val vHex = bytesToHex(toPadded128(v))
assertEquals(V_EXPECTED, vHex, "Verifier v must match RFC 5054 test vector")
}
test("RFC 5054 - Client public key A = g^a mod N verification") {
val a = BigInteger(A_PRIV_HEX, 16)
val A = g.modPow(a, N)
val aHex = bytesToHex(toPadded128(A))
assertEquals(A_PUB_EXPECTED, aHex, "Public key A must match RFC 5054 test vector")
}
test("RFC 5054 - Server public key B = (k*v + g^b) mod N verification") {
val k = BigInteger(1, hexToBytes(K_EXPECTED))
val v = BigInteger(1, hexToBytes(V_EXPECTED))
val b = BigInteger(B_PRIV_HEX, 16)
val gb = g.modPow(b, N)
val B = k.multiply(v).add(gb).mod(N)
val bHex = bytesToHex(toPadded128(B))
assertEquals(B_PUB_EXPECTED, bHex, "Public key B must match RFC 5054 test vector")
}
test("RFC 5054 - Scrambler u = H(PAD(A) || PAD(B)) verification") {
val A = BigInteger(A_PUB_EXPECTED, 16)
val B = BigInteger(B_PUB_EXPECTED, 16)
val uBytes = sha1(toPadded128(A), toPadded128(B))
val uHex = bytesToHex(uBytes)
assertEquals(U_EXPECTED, uHex, "Scrambler u must match RFC 5054 test vector")
}
test("RFC 5054 - Premaster secret S client/server agreement and test vector match") {
val k = BigInteger(1, hexToBytes(K_EXPECTED))
val v = BigInteger(1, hexToBytes(V_EXPECTED))
val x = BigInteger(1, hexToBytes(X_EXPECTED))
val a = BigInteger(A_PRIV_HEX, 16)
val b = BigInteger(B_PRIV_HEX, 16)
val A = BigInteger(A_PUB_EXPECTED, 16)
val B = BigInteger(B_PUB_EXPECTED, 16)
val u = BigInteger(1, hexToBytes(U_EXPECTED))
// Client S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
val gx = g.modPow(x, N)
val kgx = k.multiply(gx).mod(N)
val clientBase = B.subtract(kgx).mod(N)
val clientExp = a.add(u.multiply(x))
val sClient = clientBase.modPow(clientExp, N)
// Server S = (A * v^u mod N) ^ b mod N
val vu = v.modPow(u, N)
val serverBase = A.multiply(vu).mod(N)
val sServer = serverBase.modPow(b, N)
val sClientHex = bytesToHex(toPadded128(sClient))
val sServerHex = bytesToHex(toPadded128(sServer))
assertEquals(S_EXPECTED, sClientHex, "Client S must match RFC 5054 premaster secret")
assertEquals(S_EXPECTED, sServerHex, "Server S must match RFC 5054 premaster secret")
assertEquals(sClient, sServer, "Client and Server premaster secret S must be identical")
}
}
}
@@ -0,0 +1,146 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFailsWith
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
class PortalSrpSafetyTest : TestSuite("SRP-6a Safety Checks (A mod N != 0, B mod N != 0, u != 0)") {
init {
test("Safety: Reject A mod N == 0 in validator helper") {
assertFalse(PortalSrp.isValidPublicA(BigInteger.ZERO), "A = 0 must be rejected")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N), "A = N must be rejected (A mod N == 0)")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(2))), "A = 2N must be rejected")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(99))), "A = 99N must be rejected")
assertTrue(PortalSrp.isValidPublicA(BigInteger.valueOf(2)), "A = 2 is valid")
assertTrue(PortalSrp.isValidPublicA(PortalSrp.N.subtract(BigInteger.ONE)), "A = N-1 is valid")
}
test("Safety: Server verifyClient rejects A = 0") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = "00",
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 0")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter")
}
test("Safety: Server verifyClient rejects A == N (A mod N == 0)") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val nHex = PortalSrp.N.toString(16)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = nHex,
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = N")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
assertEquals(2, failed.attemptsLeft)
}
test("Safety: Server verifyClient rejects A == 2N (A mod N == 0)") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val twoNHex = PortalSrp.N.multiply(BigInteger.valueOf(2)).toString(16)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = twoNHex,
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 2N")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
}
test("Safety: Server verifyClient rejects malformed or non-hex A") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val res1 = PortalSrp.verifyClient(pairing, "not-valid-hex", dummyM1, dummyTls)
assertTrue(res1 is PortalSrp.VerifyResult.Failed)
assertEquals("Invalid client public key format", (res1 as PortalSrp.VerifyResult.Failed).message)
val res2 = PortalSrp.verifyClient(pairing, "", dummyM1, dummyTls)
assertTrue(res2 is PortalSrp.VerifyResult.Failed)
assertEquals("Invalid client public key format", (res2 as PortalSrp.VerifyResult.Failed).message)
}
test("Safety: Reject B mod N == 0 in validator helper") {
assertFalse(PortalSrp.isValidPublicB(BigInteger.ZERO), "B = 0 must be rejected")
assertFalse(PortalSrp.isValidPublicB(PortalSrp.N), "B = N must be rejected (B mod N == 0)")
assertFalse(PortalSrp.isValidPublicB(PortalSrp.N.multiply(BigInteger.valueOf(3))), "B = 3N must be rejected")
assertTrue(PortalSrp.isValidPublicB(BigInteger.valueOf(2)), "B = 2 is valid")
assertTrue(PortalSrp.isValidPublicB(PortalSrp.N.subtract(BigInteger.ONE)), "B = N-1 is valid")
}
test("Safety: Client rejects server B == 0 (B mod N == 0)") {
val client = PortalSrpClient()
val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16))
val dummyTls = ByteArray(32)
val ex = assertFailsWith<IllegalArgumentException>("Client must reject B = 0") {
client.computeM1(
saltHex = dummySaltHex,
pubBHex = "00",
pin = "123456",
tlsCertSha256 = dummyTls
)
}
assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection")
}
test("Safety: Client rejects server B == N (B mod N == 0)") {
val client = PortalSrpClient()
val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16))
val dummyTls = ByteArray(32)
val nHex = PortalSrp.N.toString(16)
val ex = assertFailsWith<IllegalArgumentException>("Client must reject B = N") {
client.computeM1(
saltHex = dummySaltHex,
pubBHex = nHex,
pin = "123456",
tlsCertSha256 = dummyTls
)
}
assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection")
}
test("Safety: Server newPairing always generates valid B mod N != 0") {
for (i in 0 until 20) {
val p = PortalSrp.newPairing("777888")
assertTrue(PortalSrp.isValidPublicB(p.pubB), "Server generated B must satisfy B mod N != 0")
}
}
test("Safety: Reject u == 0 in validator helper") {
assertFalse(PortalSrp.isValidScrambler(BigInteger.ZERO), "u = 0 must be rejected")
assertTrue(PortalSrp.isValidScrambler(BigInteger.ONE), "u = 1 is valid")
assertTrue(PortalSrp.isValidScrambler(BigInteger.valueOf(42)), "u = 42 is valid")
}
}
}
@@ -0,0 +1,65 @@
package com.portaltv.capability.test
class AssertionException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
object Assert {
fun assertTrue(condition: Boolean, message: String = "Expected condition to be true") {
if (!condition) throw AssertionException(message)
}
fun assertFalse(condition: Boolean, message: String = "Expected condition to be false") {
if (condition) throw AssertionException(message)
}
fun assertEquals(expected: Any?, actual: Any?, message: String = "") {
if (expected != actual) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected <$expected> but got <$actual>")
}
}
fun assertNotEquals(unexpected: Any?, actual: Any?, message: String = "") {
if (unexpected == actual) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected value to differ from <$unexpected>")
}
}
fun assertArrayEquals(expected: ByteArray, actual: ByteArray, message: String = "") {
if (!expected.contentEquals(actual)) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
val expHex = expected.joinToString("") { "%02x".format(it) }
val actHex = actual.joinToString("") { "%02x".format(it) }
throw AssertionException("${prefix}Byte arrays do not match.\nExpected: $expHex\nActual: $actHex")
}
}
fun assertNotNull(actual: Any?, message: String = "Expected non-null value") {
if (actual == null) throw AssertionException(message)
}
fun assertNull(actual: Any?, message: String = "Expected null value") {
if (actual != null) throw AssertionException("$message (was: $actual)")
}
inline fun <reified T : Throwable> assertFailsWith(message: String = "", block: () -> Unit): T {
try {
block()
} catch (t: Throwable) {
if (t is T) return t
throw AssertionException("Expected exception ${T::class.java.simpleName} but caught ${t::class.java.simpleName}: ${t.message}", t)
}
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected exception ${T::class.java.simpleName} was not thrown")
}
}
data class TestCase(val name: String, val block: () -> Unit)
abstract class TestSuite(val name: String) {
val cases = mutableListOf<TestCase>()
fun test(name: String, block: () -> Unit) {
cases += TestCase(name, block)
}
}
@@ -0,0 +1,80 @@
package com.portaltv.capability.test
fun main() {
val suites = listOf(
PortalSrpRfc5054Test(),
PortalSrpMathTest(),
PortalSrpSafetyTest(),
PortalSrpRateLimitingTest(),
PortalSrpChannelBindingTest(),
PortalSrpPaddingTest(),
PortalSrpConstantTimeTest(),
PortalSrpIntegrationTest()
)
val green = "\u001B[32m"
val red = "\u001B[31m"
val cyan = "\u001B[36m"
val yellow = "\u001B[33m"
val bold = "\u001B[1m"
val reset = "\u001B[0m"
println("$bold============================================================$reset")
println("$bold$cyan Portal SRP-6a & Android Security Test Suite Runner$reset")
println("$bold============================================================$reset")
var totalTests = 0
var passedTests = 0
var failedTests = 0
val failures = mutableListOf<Triple<String, String, Throwable>>()
val startTime = System.currentTimeMillis()
for (suite in suites) {
println("\n$bold$yellow=== Suite: ${suite.name} ===$reset")
for (case in suite.cases) {
totalTests++
val caseStart = System.currentTimeMillis()
try {
case.block()
val duration = System.currentTimeMillis() - caseStart
println(" $green[PASS]$reset ${case.name} (${duration}ms)")
passedTests++
} catch (t: Throwable) {
val duration = System.currentTimeMillis() - caseStart
println(" $red[FAIL]$reset ${case.name} (${duration}ms)")
println(" $red-> ${t.message}$reset")
failures += Triple(suite.name, case.name, t)
failedTests++
}
}
}
val totalDuration = System.currentTimeMillis() - startTime
println("\n$bold============================================================$reset")
println("$bold Test Execution Summary$reset")
println("$bold============================================================$reset")
println(" Total Suites: ${suites.size}")
println(" Total Tests: $totalTests")
println(" $green Passed: $passedTests$reset")
if (failedTests > 0) {
println(" $red Failed: $failedTests$reset")
} else {
println(" Failed: 0")
}
println(" Total Time: ${totalDuration}ms")
println("============================================================")
if (failures.isNotEmpty()) {
println("\n$bold$red--- FAILURE DETAILS ---$reset")
for ((suiteName, caseName, error) in failures) {
println("\n$red[$suiteName] $caseName:$reset")
error.printStackTrace(System.out)
}
System.exit(1)
} else {
println("\n$bold$green*** ALL $passedTests SRP-6a TESTS PASSED WITH 0 ERRORS ***$reset\n")
System.exit(0)
}
}