Init
This commit is contained in:
+171
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user