Init
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// BigUIntPaddingTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of BigUInt 256-byte padding, truncation, and hex conversion edge cases.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class BigUIntPaddingTests: XCTestCase {
|
||||
func testZeroPaddingTo256Bytes() {
|
||||
let zero = BigUInt(0)
|
||||
let padded = zero.toPadded256Data()
|
||||
XCTAssertEqual(padded.count, 256, "Zero must produce exactly 256 bytes")
|
||||
XCTAssertEqual(padded, Data(repeating: 0, count: 256))
|
||||
}
|
||||
|
||||
func testSmallNumberPadding() {
|
||||
let one = BigUInt(1)
|
||||
let padded = one.toPadded256Data()
|
||||
XCTAssertEqual(padded.count, 256)
|
||||
XCTAssertEqual(padded.prefix(255), Data(repeating: 0, count: 255))
|
||||
XCTAssertEqual(padded.last, 1)
|
||||
|
||||
let value = BigUInt(0x12345678)
|
||||
let paddedValue = value.toPadded256Data()
|
||||
XCTAssertEqual(paddedValue.count, 256)
|
||||
XCTAssertEqual(paddedValue.suffix(4), Data([0x12, 0x34, 0x56, 0x78]))
|
||||
}
|
||||
|
||||
func testExact256ByteValue() {
|
||||
// Portal TV 2048-bit prime N is exactly 256 bytes
|
||||
let N = PortalSrpClient.N
|
||||
let paddedN = N.toPadded256Data()
|
||||
XCTAssertEqual(paddedN.count, 256)
|
||||
XCTAssertEqual(paddedN.first, 0xFF)
|
||||
XCTAssertEqual(paddedN.last, 0xFF)
|
||||
|
||||
// Raw serialize count for 2048-bit N starting with 0xFF is 256 bytes
|
||||
XCTAssertEqual(N.serialize().count, 256)
|
||||
XCTAssertEqual(paddedN, N.serialize())
|
||||
}
|
||||
|
||||
func testVariableLengthPadding() {
|
||||
let val = BigUInt(0xAABB)
|
||||
// Pad to 4 bytes: 00 00 AA BB
|
||||
let p4 = val.toPaddedData(byteCount: 4)
|
||||
XCTAssertEqual(p4, Data([0x00, 0x00, 0xAA, 0xBB]))
|
||||
|
||||
// Pad to 2 bytes: AA BB
|
||||
let p2 = val.toPaddedData(byteCount: 2)
|
||||
XCTAssertEqual(p2, Data([0xAA, 0xBB]))
|
||||
|
||||
// Truncate to 1 byte: BB (suffix)
|
||||
let p1 = val.toPaddedData(byteCount: 1)
|
||||
XCTAssertEqual(p1, Data([0xBB]))
|
||||
}
|
||||
|
||||
func testHexConversionEdgeCases() {
|
||||
// Empty data
|
||||
XCTAssertEqual(SrpFormat.bytesToHex(Data()), "")
|
||||
XCTAssertEqual(SrpFormat.hexToBytes(""), Data())
|
||||
|
||||
// Leading zero single byte
|
||||
XCTAssertEqual(SrpFormat.bytesToHex(Data([0x05])), "05")
|
||||
XCTAssertEqual(SrpFormat.hexToBytes("05"), Data([0x05]))
|
||||
|
||||
// High nybble / low nybble
|
||||
XCTAssertEqual(SrpFormat.bytesToHex(Data([0xF0, 0x0F])), "f00f")
|
||||
XCTAssertEqual(SrpFormat.hexToBytes("f00f"), Data([0xF0, 0x0F]))
|
||||
XCTAssertEqual(SrpFormat.hexToBytes("F00F"), Data([0xF0, 0x0F])) // case insensitive
|
||||
|
||||
// Whitespace and newline tolerance
|
||||
let spacedHex = " f0 0f \n 12\t 34 \r\n"
|
||||
XCTAssertEqual(SrpFormat.hexToBytes(spacedHex), Data([0xF0, 0x0F, 0x12, 0x34]))
|
||||
|
||||
// Invalid: odd length
|
||||
XCTAssertNil(SrpFormat.hexToBytes("123"))
|
||||
XCTAssertNil(SrpFormat.hexToBytes("f"))
|
||||
|
||||
// Invalid: non-hex characters
|
||||
XCTAssertNil(SrpFormat.hexToBytes("123g"))
|
||||
XCTAssertNil(SrpFormat.hexToBytes("zz"))
|
||||
XCTAssertNil(SrpFormat.hexToBytes("!!"))
|
||||
}
|
||||
|
||||
func testConstantTimeEqualsEdgeCases() {
|
||||
let a = Data([0x01, 0x02, 0x03, 0x04])
|
||||
let b = Data([0x01, 0x02, 0x03, 0x04])
|
||||
let diffStart = Data([0xFF, 0x02, 0x03, 0x04])
|
||||
let diffMid = Data([0x01, 0xFF, 0x03, 0x04])
|
||||
let diffEnd = Data([0x01, 0x02, 0x03, 0xFF])
|
||||
let diffLength = Data([0x01, 0x02, 0x03])
|
||||
|
||||
XCTAssertTrue(SrpFormat.constantTimeEquals(a, b))
|
||||
XCTAssertTrue(SrpFormat.constantTimeEquals(Data(), Data()))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffStart))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffMid))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffEnd))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffLength))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// ChannelBindingTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of cryptographic TLS channel binding in SRP-6a M1 and M2.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class ChannelBindingTests: XCTestCase {
|
||||
let pin = "481516"
|
||||
let genuineCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("GENUINE_PORTAL_CERT_DER".data(using: .utf8)!)
|
||||
let rogueCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("ROGUE_PROXY_CERT_DER".data(using: .utf8)!)
|
||||
|
||||
func testMatchingCertHashSucceeds() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Both use the same genuine TLS cert hash
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
XCTAssertTrue(serverRes.success)
|
||||
XCTAssertNotNil(serverRes.M2Hex)
|
||||
XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: serverRes.M2Hex!))
|
||||
}
|
||||
|
||||
func testAlteredCertHashFailsServerM1Verification() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client is tricked into connecting through a MITM proxy presenting rogue cert
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: rogueCertHash // Client uses rogue proxy cert hash
|
||||
)
|
||||
|
||||
// Portal TV verifies against its real local certificate hash
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: genuineCertHash // Server uses genuine cert hash
|
||||
)
|
||||
|
||||
XCTAssertFalse(serverRes.success, "Server must reject client M1 when TLS cert hashes differ")
|
||||
XCTAssertNil(serverRes.token)
|
||||
XCTAssertNil(serverRes.M2Hex)
|
||||
}
|
||||
|
||||
func testAlteredCertHashFailsClientM2Verification() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client computes M1 bound to genuine cert
|
||||
_ = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
// Attacker attempts to forge M2 or server computes M2 bound to a different cert hash
|
||||
let aPadded = client.A.toPadded256Data()
|
||||
let fakeM2 = SrpGroup.rfc5054_2048.hashAlgorithm.hash(
|
||||
aPadded,
|
||||
client.clientM1!,
|
||||
client.sessionKey!,
|
||||
rogueCertHash // Mismatched cert hash in M2
|
||||
)
|
||||
let fakeM2Hex = SrpFormat.bytesToHex(fakeM2)
|
||||
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: fakeM2Hex)) { error in
|
||||
guard case SrpError.verificationFailed(let msg) = error else {
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(msg.contains("Server evidence M2 does not match"))
|
||||
}
|
||||
}
|
||||
|
||||
func testSingleBitFlipInCertHashFails() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Flip 1 bit in the genuine cert hash
|
||||
var tamperedCertHash = genuineCertHash
|
||||
tamperedCertHash[0] ^= 0x01
|
||||
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: tamperedCertHash
|
||||
)
|
||||
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
XCTAssertFalse(serverRes.success, "Single-bit flip in TLS cert hash must fail verification")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// ClientAndAuthTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of CredentialStorage abstractions, PortalAuth, and PortalClient models.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class ClientAndAuthTests: XCTestCase {
|
||||
func testInMemoryCredentialStorage() {
|
||||
let storage = InMemoryCredentialStorage()
|
||||
XCTAssertNil(storage.getAuthToken())
|
||||
XCTAssertNil(storage.getPinnedCertSha256())
|
||||
|
||||
storage.save(token: "test_token_123", certSha256: "AABBCCDDEEFF")
|
||||
XCTAssertEqual(storage.getAuthToken(), "test_token_123")
|
||||
XCTAssertEqual(storage.getPinnedCertSha256(), "aabbccddeeff") // lowercased
|
||||
|
||||
storage.clear()
|
||||
XCTAssertNil(storage.getAuthToken())
|
||||
XCTAssertNil(storage.getPinnedCertSha256())
|
||||
}
|
||||
|
||||
func testFileCredentialStorage() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let tempFile = tempDir.appendingPathComponent("test_credentials_\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: tempFile) }
|
||||
|
||||
let storage = FileCredentialStorage(fileURL: tempFile)
|
||||
XCTAssertNil(storage.getAuthToken())
|
||||
|
||||
storage.save(token: "tok_abc", certSha256: "010203040506")
|
||||
XCTAssertEqual(storage.getAuthToken(), "tok_abc")
|
||||
XCTAssertEqual(storage.getPinnedCertSha256(), "010203040506")
|
||||
|
||||
// Create fresh instance pointing to same file
|
||||
let reloadStorage = FileCredentialStorage(fileURL: tempFile)
|
||||
XCTAssertEqual(reloadStorage.getAuthToken(), "tok_abc")
|
||||
XCTAssertEqual(reloadStorage.getPinnedCertSha256(), "010203040506")
|
||||
|
||||
reloadStorage.clear()
|
||||
XCTAssertNil(reloadStorage.getAuthToken())
|
||||
}
|
||||
|
||||
func testPortalAuthFacade() {
|
||||
let original = PortalAuth.defaultStorage
|
||||
defer { PortalAuth.defaultStorage = original }
|
||||
|
||||
let mockStorage = InMemoryCredentialStorage()
|
||||
PortalAuth.defaultStorage = mockStorage
|
||||
|
||||
PortalAuth.save(token: "facade_token", certSha256: "CAFEBABE")
|
||||
XCTAssertEqual(PortalAuth.token, "facade_token")
|
||||
XCTAssertEqual(PortalAuth.pinnedCertSha256, "cafebabe")
|
||||
|
||||
PortalAuth.clear()
|
||||
XCTAssertNil(PortalAuth.token)
|
||||
XCTAssertNil(PortalAuth.pinnedCertSha256)
|
||||
}
|
||||
|
||||
func testPortalClientHostNormalization() {
|
||||
let c1 = PortalClient(host: "10.0.0.10")
|
||||
XCTAssertEqual(c1.host, "10.0.0.10:\(PortalEndpoints.port)")
|
||||
|
||||
let c2 = PortalClient(host: "https://10.0.0.10:9000/")
|
||||
XCTAssertEqual(c2.host, "10.0.0.10:9000")
|
||||
|
||||
let c3 = PortalClient(host: "http://myportal.local:\(PortalEndpoints.port)")
|
||||
XCTAssertEqual(c3.host, "myportal.local:\(PortalEndpoints.port)")
|
||||
}
|
||||
|
||||
func testPortalClientControlRequiresPairing() async {
|
||||
let storage = InMemoryCredentialStorage()
|
||||
let client = PortalClient(host: "127.0.0.1", credentialStorage: storage)
|
||||
|
||||
do {
|
||||
_ = try await client.control(command: "mode Desk")
|
||||
XCTFail("Should throw notPaired error")
|
||||
} catch {
|
||||
guard case PortalClientError.notPaired = error else {
|
||||
XCTFail("Expected notPaired error, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testTlsPinningChallengeEvaluationNonServerTrust() {
|
||||
let space = URLProtectionSpace(
|
||||
host: "localhost",
|
||||
port: PortalEndpoints.port,
|
||||
protocol: "https",
|
||||
realm: nil,
|
||||
authenticationMethod: NSURLAuthenticationMethodHTTPBasic
|
||||
)
|
||||
let challenge = URLAuthenticationChallenge(protectionSpace: space, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: DummyChallengeSender())
|
||||
|
||||
let exp = expectation(description: "Challenge evaluated")
|
||||
PortalTlsPinning.evaluate(challenge: challenge, pinnedFingerprint: "somehash") { disposition, credential in
|
||||
XCTAssertEqual(disposition, .cancelAuthenticationChallenge)
|
||||
XCTAssertNil(credential)
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private final class DummyChallengeSender: NSObject, URLAuthenticationChallengeSender {
|
||||
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||
func performDefaultHandling(for challenge: URLAuthenticationChallenge) {}
|
||||
func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// Rfc5054VectorsTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of RFC 5054 Appendix B test vectors (1024-bit group with SHA-1).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class Rfc5054VectorsTests: XCTestCase {
|
||||
let group = SrpGroup.rfc5054_1024
|
||||
|
||||
// RFC 5054 Appendix B inputs
|
||||
let I = "alice"
|
||||
let P = "password123"
|
||||
let sHex = "BEB25379D1A8581EB5A727673A2441EE"
|
||||
|
||||
// RFC 5054 Appendix B expected values
|
||||
let expectedKHex = "7556AA045AEF2CDD07ABAF0F665C3E818913186F"
|
||||
let expectedXHex = "94B7555AABE9127CC58CCF4993DB6CF84D16C124"
|
||||
let expectedVHex = """
|
||||
7E273DE8696FFC4F4E337D05B4B375BEB0DDE1569E8FA00A9886D8129BADA1F1\
|
||||
822223CA1A605B530E379BA4729FDC59F105B4787E5186F5C671085A1447B52A\
|
||||
48CF1970B4FB6F8400BBF4CEBFBB168152E08AB5EA53D15C1AFF87B2B9DA6E04\
|
||||
E058AD51CC72BFC9033B564E26480D78E955A5E29E7AB245DB2BE315E2099AFB
|
||||
"""
|
||||
let expectedAHex = "60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393"
|
||||
let expectedBHex = "E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20"
|
||||
let expectedPubAHex = """
|
||||
61D5E490F6F1B79547B0704C436F523DD0E560F0C64115BB72557EC44352E890\
|
||||
3211C04692272D8B2D1A5358A2CF1B6E0BFCF99F921530EC8E39356179EAE45E\
|
||||
42BA92AEACED825171E1E8B9AF6D9C03E1327F44BE087EF06530E69F66615261\
|
||||
EEF54073CA11CF5858F0EDFDFE15EFEAB349EF5D76988A3672FAC47B0769447B
|
||||
"""
|
||||
let expectedPubBHex = """
|
||||
BD0C61512C692C0CB6D041FA01BB152D4916A1E77AF46AE105393011BAF38964\
|
||||
DC46A0670DD125B95A981652236F99D9B681CBF87837EC996C6DA04453728610\
|
||||
D0C6DDB58B318885D7D82C7F8DEB75CE7BD4FBAA37089E6F9C6059F388838E7A\
|
||||
00030B331EB76840910440B1B27AAEAEEB4012B7D7665238A8E3FB004B117B58
|
||||
"""
|
||||
let expectedUHex = "CE38B9593487DA98554ED47D70A7AE5F462EF019"
|
||||
let expectedSHex = """
|
||||
B0DC82BABCF30674AE450C0287745E7990A3381F63B387AAF271A10D233861E3\
|
||||
59B48220F7C4693C9AE12B0A6F67809F0876E2D013800D6C41BB59B6D5979B5C\
|
||||
00A172B4A2A5903A0BDCAF8A709585EB2AFAFA8F3499B200210DCC1F10EB3394\
|
||||
3CD67FC88A2F39A4BE5BEC4EC0A3212DC346D7E474B29EDE8A469FFECA686E5A
|
||||
"""
|
||||
|
||||
func testMultiplierK() {
|
||||
let computedK = group.k
|
||||
let computedKHex = SrpFormat.bytesToHex(computedK.serialize())
|
||||
XCTAssertEqual(computedKHex.lowercased(), expectedKHex.lowercased(), "RFC 5054 multiplier k mismatch")
|
||||
}
|
||||
|
||||
func testPrivateKeyX() {
|
||||
guard let sData = SrpFormat.hexToBytes(sHex) else {
|
||||
XCTFail("Failed to decode salt hex")
|
||||
return
|
||||
}
|
||||
let x = SrpMath.computeRfc5054X(identity: I, password: P, salt: sData, hashAlgorithm: group.hashAlgorithm)
|
||||
let xHex = SrpFormat.bytesToHex(x.serialize())
|
||||
XCTAssertEqual(xHex.lowercased(), expectedXHex.lowercased(), "RFC 5054 private key x mismatch")
|
||||
}
|
||||
|
||||
func testVerifierV() {
|
||||
guard let xVal = BigUInt(expectedXHex, radix: 16) else {
|
||||
XCTFail("Failed to parse expected x")
|
||||
return
|
||||
}
|
||||
let v = SrpMath.computeVerifier(g: group.g, x: xVal, N: group.N)
|
||||
let vHex = SrpFormat.bytesToHex(v.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(vHex.lowercased(), expectedVHex.lowercased(), "RFC 5054 verifier v mismatch")
|
||||
}
|
||||
|
||||
func testPublicA() {
|
||||
guard let aVal = BigUInt(expectedAHex, radix: 16) else {
|
||||
XCTFail("Failed to parse expected a")
|
||||
return
|
||||
}
|
||||
let A = SrpMath.computeA(g: group.g, a: aVal, N: group.N)
|
||||
let aHex = SrpFormat.bytesToHex(A.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(aHex.lowercased(), expectedPubAHex.lowercased(), "RFC 5054 public key A mismatch")
|
||||
}
|
||||
|
||||
func testPublicB() {
|
||||
guard let bVal = BigUInt(expectedBHex, radix: 16),
|
||||
let vVal = BigUInt(expectedVHex, radix: 16) else {
|
||||
XCTFail("Failed to parse b or v")
|
||||
return
|
||||
}
|
||||
let B = SrpMath.computeB(k: group.k, v: vVal, g: group.g, b: bVal, N: group.N)
|
||||
let bHex = SrpFormat.bytesToHex(B.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(bHex.lowercased(), expectedPubBHex.lowercased(), "RFC 5054 public key B mismatch")
|
||||
}
|
||||
|
||||
func testScramblerU() {
|
||||
guard let aVal = BigUInt(expectedPubAHex, radix: 16),
|
||||
let bVal = BigUInt(expectedPubBHex, radix: 16) else {
|
||||
XCTFail("Failed to parse A or B")
|
||||
return
|
||||
}
|
||||
let u = SrpMath.computeU(A: aVal, B: bVal, padLength: 128, hashAlgorithm: group.hashAlgorithm)
|
||||
let uHex = SrpFormat.bytesToHex(u.serialize())
|
||||
XCTAssertEqual(uHex.lowercased(), expectedUHex.lowercased(), "RFC 5054 scrambler u mismatch")
|
||||
}
|
||||
|
||||
func testPremasterSecretS() {
|
||||
guard let aVal = BigUInt(expectedAHex, radix: 16),
|
||||
let bVal = BigUInt(expectedBHex, radix: 16),
|
||||
let pubA = BigUInt(expectedPubAHex, radix: 16),
|
||||
let pubB = BigUInt(expectedPubBHex, radix: 16),
|
||||
let xVal = BigUInt(expectedXHex, radix: 16),
|
||||
let vVal = BigUInt(expectedVHex, radix: 16),
|
||||
let uVal = BigUInt(expectedUHex, radix: 16) else {
|
||||
XCTFail("Failed to parse test parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Client computation: S = (B - k * (g^x mod N)) ^ (a + u * x) mod N
|
||||
let clientS = SrpMath.computeClientS(
|
||||
B: pubB,
|
||||
k: group.k,
|
||||
g: group.g,
|
||||
x: xVal,
|
||||
a: aVal,
|
||||
u: uVal,
|
||||
N: group.N
|
||||
)
|
||||
let clientSHex = SrpFormat.bytesToHex(clientS.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(clientSHex.lowercased(), expectedSHex.lowercased(), "Client premaster secret S mismatch")
|
||||
|
||||
// Server computation: S = (A * (v^u mod N)) ^ b mod N
|
||||
let serverS = SrpMath.computeServerS(
|
||||
A: pubA,
|
||||
v: vVal,
|
||||
u: uVal,
|
||||
b: bVal,
|
||||
N: group.N
|
||||
)
|
||||
let serverSHex = SrpFormat.bytesToHex(serverS.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(serverSHex.lowercased(), expectedSHex.lowercased(), "Server premaster secret S mismatch")
|
||||
|
||||
// Mutual agreement
|
||||
XCTAssertEqual(clientS, serverS, "Client and Server premaster secrets do not match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// SafetyChecksTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of SRP parameter validation and safety guards (RFC 5054).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class SafetyChecksTests: XCTestCase {
|
||||
let validCertHash = Data(repeating: 0xaa, count: 32)
|
||||
let validSaltHex = "0102030405060708090a0b0c0d0e0f10"
|
||||
let validPubBHex = SrpFormat.bytesToHex(BigUInt(123456789).toPadded256Data())
|
||||
let validPin = "123456"
|
||||
|
||||
func testInvalidSaltThrows() {
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Empty salt
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: "", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("salt"))
|
||||
}
|
||||
|
||||
// Odd length hex salt
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: "abc", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
|
||||
// Non-hex characters in salt
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: "invalid-hex-characters!!", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
}
|
||||
|
||||
func testInvalidBModuloNThrows() {
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// B = 0 -> B % N == 0
|
||||
let zeroBHex = SrpFormat.bytesToHex(BigUInt(0).toPadded256Data())
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: zeroBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("B % N == 0"))
|
||||
}
|
||||
|
||||
// B = N -> B % N == 0
|
||||
let nBHex = SrpFormat.bytesToHex(PortalSrpClient.N.toPadded256Data())
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: nBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("B % N == 0"))
|
||||
}
|
||||
|
||||
// B = 2*N -> B % N == 0
|
||||
let twoNBHex = SrpFormat.bytesToHex((PortalSrpClient.N * 2).serialize())
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: twoNBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("B % N == 0"))
|
||||
}
|
||||
|
||||
// Empty pubBHex
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: "", pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
|
||||
// Non-hex pubBHex
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: "not_hex", pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyPinThrows() {
|
||||
let client = PortalSrpClient()
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: "", tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("PIN"))
|
||||
}
|
||||
}
|
||||
|
||||
func testEmptyTlsCertHashThrows() {
|
||||
let client = PortalSrpClient()
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: Data())
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("certificate"))
|
||||
}
|
||||
}
|
||||
|
||||
func testInvalidUZeroCheck() {
|
||||
let _ = SrpServerMock(pin: validPin)
|
||||
// If a server or client encounters u == 0, it must be rejected as an invalid scrambler.
|
||||
let uZero = BigUInt(0)
|
||||
XCTAssertEqual(uZero, 0)
|
||||
|
||||
// Verify SrpError formatting for u == 0
|
||||
let err = SrpError.invalidParameter("Computed u == 0")
|
||||
XCTAssertEqual(err.localizedDescription, "SRP Parameter Error: Computed u == 0")
|
||||
}
|
||||
|
||||
func testInvalidM2Throws() throws {
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Verify before computeM1 must fail
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "123456")) { error in
|
||||
guard case SrpError.verificationFailed(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("not initialized"))
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
_ = try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
|
||||
// Invalid hex
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "invalid-hex"))
|
||||
|
||||
// Odd length hex
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "abc"))
|
||||
|
||||
// Wrong M2
|
||||
let wrongM2 = SrpFormat.bytesToHex(Data(repeating: 0x99, count: 32))
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: wrongM2)) { error in
|
||||
guard case SrpError.verificationFailed(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("Server evidence M2 does not match"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class SmokeTests: XCTestCase {
|
||||
func testVersion() {
|
||||
XCTAssertEqual(PortalKitVersion.version, "1.0.0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Srp6aExchangeTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of 2048-bit SRP-6a end-to-end key exchange with mock server parameters.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class Srp6aExchangeTests: XCTestCase {
|
||||
func testEndToEndExchangeSuccess() throws {
|
||||
let pin = "654321"
|
||||
let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("MOCK_CERT_LEAF_DER".data(using: .utf8)!)
|
||||
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client computes M1 bound to mock TLS cert hash
|
||||
let clientM1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertFalse(clientM1Hex.isEmpty)
|
||||
XCTAssertNotNil(client.sessionKey)
|
||||
|
||||
// Server verifies client's A and M1
|
||||
let verifyResult = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: clientM1Hex,
|
||||
serverTlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertTrue(verifyResult.success, "Server rejected valid client proof")
|
||||
XCTAssertNotNil(verifyResult.M2Hex)
|
||||
XCTAssertNotNil(verifyResult.token)
|
||||
|
||||
// Client verifies server M2
|
||||
XCTAssertNoThrow(
|
||||
try client.verifyServerM2(serverM2Hex: verifyResult.M2Hex!),
|
||||
"Client failed to verify genuine server M2"
|
||||
)
|
||||
}
|
||||
|
||||
func testWrongPinFailsExchange() throws {
|
||||
let actualPin = "123456"
|
||||
let wrongPin = "999999"
|
||||
let mockCertHash = Data(repeating: 0x42, count: 32)
|
||||
|
||||
let server = SrpServerMock(pin: actualPin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client computes M1 with wrong PIN
|
||||
let clientM1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: wrongPin,
|
||||
tlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
// Server verification MUST fail
|
||||
let verifyResult = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: clientM1Hex,
|
||||
serverTlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertFalse(verifyResult.success)
|
||||
XCTAssertNil(verifyResult.token)
|
||||
XCTAssertNil(verifyResult.M2Hex)
|
||||
XCTAssertTrue(verifyResult.errorMessage?.contains("M1 verification failed") ?? false)
|
||||
}
|
||||
|
||||
func testMultipleRandomExchanges() throws {
|
||||
// Run 5 randomized rounds to verify no modular arithmetic edge cases
|
||||
for round in 1...5 {
|
||||
let pin = String(format: "%06d", round * 111111 % 1000000)
|
||||
let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("CERT_ROUND_\(round)".data(using: .utf8)!)
|
||||
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertTrue(serverRes.success, "Round \(round) failed server verification")
|
||||
guard let m2 = serverRes.M2Hex else {
|
||||
XCTFail("Round \(round) missing M2")
|
||||
continue
|
||||
}
|
||||
|
||||
XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: m2), "Round \(round) failed client M2 verification")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user