230 lines
10 KiB
Swift
230 lines
10 KiB
Swift
import XCTest
|
|
@testable import Rosetta
|
|
|
|
/// Tests that iOS crypto primitives produce byte-identical output
|
|
/// to Android/Desktop for call E2EE.
|
|
final class CallCryptoParityTests: XCTestCase {
|
|
|
|
// MARK: - HSalsa20 (nacl.box.before parity)
|
|
|
|
func testHSalsa20ProducesConsistentOutput() throws {
|
|
// HSalsa20 with zero nonce should produce deterministic output from any key
|
|
let key = Data(repeating: 0x42, count: 32)
|
|
let result1 = NativeCryptoBridge.naclSharedKey(fromRawDH: key)
|
|
let result2 = NativeCryptoBridge.naclSharedKey(fromRawDH: key)
|
|
|
|
XCTAssertNotNil(result1)
|
|
XCTAssertNotNil(result2)
|
|
XCTAssertEqual(result1!.count, 32)
|
|
XCTAssertEqual(result1, result2, "HSalsa20 must be deterministic")
|
|
}
|
|
|
|
func testHSalsa20OutputDiffersForDifferentKeys() throws {
|
|
let key1 = Data(repeating: 0x01, count: 32)
|
|
let key2 = Data(repeating: 0x02, count: 32)
|
|
|
|
let result1 = NativeCryptoBridge.naclSharedKey(fromRawDH: key1)
|
|
let result2 = NativeCryptoBridge.naclSharedKey(fromRawDH: key2)
|
|
|
|
XCTAssertNotNil(result1)
|
|
XCTAssertNotNil(result2)
|
|
XCTAssertNotEqual(result1, result2, "Different keys must produce different shared secrets")
|
|
}
|
|
|
|
func testHSalsa20RejectsWrongKeyLength() {
|
|
let shortKey = Data(repeating: 0x00, count: 16)
|
|
let result = NativeCryptoBridge.naclSharedKey(fromRawDH: shortKey)
|
|
XCTAssertNil(result, "HSalsa20 must reject keys != 32 bytes")
|
|
}
|
|
|
|
// MARK: - XChaCha20 stream cipher
|
|
|
|
func testXChaCha20EncryptDecryptRoundTrip() throws {
|
|
let key = Data(repeating: 0xAB, count: 32)
|
|
let nonce = Data(repeating: 0xCD, count: 24)
|
|
let plaintext = Data("Hello Rosetta E2EE call frame!".utf8)
|
|
|
|
let encrypted = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonce)
|
|
XCTAssertNotNil(encrypted)
|
|
XCTAssertEqual(encrypted!.count, plaintext.count, "Encrypted size must match plaintext size")
|
|
XCTAssertNotEqual(encrypted, plaintext, "Encrypted data must differ from plaintext")
|
|
|
|
// XOR cipher: encrypt again = decrypt
|
|
let decrypted = NativeCryptoBridge.xChaCha20Xor(encrypted!, key: key, nonce: nonce)
|
|
XCTAssertNotNil(decrypted)
|
|
XCTAssertEqual(decrypted, plaintext, "XChaCha20 XOR decrypt must recover original plaintext")
|
|
}
|
|
|
|
func testXChaCha20DifferentNonceProducesDifferentCiphertext() throws {
|
|
let key = Data(repeating: 0x11, count: 32)
|
|
let nonce1 = Data(repeating: 0x01, count: 24)
|
|
let nonce2 = Data(repeating: 0x02, count: 24)
|
|
let plaintext = Data(repeating: 0xFF, count: 64)
|
|
|
|
let enc1 = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonce1)
|
|
let enc2 = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonce2)
|
|
|
|
XCTAssertNotNil(enc1)
|
|
XCTAssertNotNil(enc2)
|
|
XCTAssertNotEqual(enc1, enc2, "Different nonces must produce different ciphertext")
|
|
}
|
|
|
|
func testXChaCha20DifferentKeyProducesDifferentCiphertext() throws {
|
|
let key1 = Data(repeating: 0x11, count: 32)
|
|
let key2 = Data(repeating: 0x22, count: 32)
|
|
let nonce = Data(repeating: 0x00, count: 24)
|
|
let plaintext = Data(repeating: 0xFF, count: 64)
|
|
|
|
let enc1 = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key1, nonce: nonce)
|
|
let enc2 = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key2, nonce: nonce)
|
|
|
|
XCTAssertNotNil(enc1)
|
|
XCTAssertNotNil(enc2)
|
|
XCTAssertNotEqual(enc1, enc2, "Different keys must produce different ciphertext")
|
|
}
|
|
|
|
func testXChaCha20OutputSizeMatchesInput() throws {
|
|
let key = Data(repeating: 0x33, count: 32)
|
|
let nonce = Data(repeating: 0x44, count: 24)
|
|
|
|
for size in [0, 1, 16, 63, 64, 65, 128, 960, 1024] {
|
|
let plaintext = Data(repeating: 0xAA, count: size)
|
|
let encrypted = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonce)
|
|
XCTAssertNotNil(encrypted, "XChaCha20 must handle size \(size)")
|
|
XCTAssertEqual(encrypted!.count, size, "Output size must equal input size for \(size) bytes")
|
|
}
|
|
}
|
|
|
|
func testXChaCha20RejectsWrongKeyOrNonceLength() {
|
|
let plaintext = Data(repeating: 0x00, count: 16)
|
|
|
|
let badKey = NativeCryptoBridge.xChaCha20Xor(plaintext, key: Data(repeating: 0, count: 16), nonce: Data(repeating: 0, count: 24))
|
|
XCTAssertNil(badKey, "Must reject key != 32 bytes")
|
|
|
|
let badNonce = NativeCryptoBridge.xChaCha20Xor(plaintext, key: Data(repeating: 0, count: 32), nonce: Data(repeating: 0, count: 12))
|
|
XCTAssertNil(badNonce, "Must reject nonce != 24 bytes")
|
|
}
|
|
|
|
// MARK: - Nonce construction parity (Desktop BigInt encoding)
|
|
|
|
/// Verifies that a nonce built from an 8-byte big-endian timestamp
|
|
/// matches the Desktop audioE2EE.ts fillNonceFromTimestamp output.
|
|
func testNonceFromTimestampMatchesDesktopFormat() throws {
|
|
let key = Data(repeating: 0x55, count: 32)
|
|
let plaintext = Data(repeating: 0xEE, count: 48)
|
|
|
|
// Desktop encodes timestamp 12345 as big-endian 8 bytes:
|
|
// [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x39]
|
|
// Then nonce[8:24] = zeros
|
|
let timestamp: UInt64 = 12345
|
|
var nonce = Data(repeating: 0, count: 24)
|
|
nonce[0] = UInt8((timestamp >> 56) & 0xFF)
|
|
nonce[1] = UInt8((timestamp >> 48) & 0xFF)
|
|
nonce[2] = UInt8((timestamp >> 40) & 0xFF)
|
|
nonce[3] = UInt8((timestamp >> 32) & 0xFF)
|
|
nonce[4] = UInt8((timestamp >> 24) & 0xFF)
|
|
nonce[5] = UInt8((timestamp >> 16) & 0xFF)
|
|
nonce[6] = UInt8((timestamp >> 8) & 0xFF)
|
|
nonce[7] = UInt8(timestamp & 0xFF)
|
|
|
|
// Encrypt with manually constructed nonce
|
|
let enc1 = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonce)
|
|
XCTAssertNotNil(enc1)
|
|
|
|
// Same nonce built differently (raw bytes = big-endian 12345)
|
|
var nonce2 = Data(repeating: 0, count: 24)
|
|
var tsBE = timestamp.bigEndian
|
|
nonce2.replaceSubrange(0..<8, with: Data(bytes: &tsBE, count: 8))
|
|
|
|
let enc2 = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonce2)
|
|
XCTAssertNotNil(enc2)
|
|
|
|
XCTAssertEqual(enc1, enc2, "Both nonce construction methods must produce identical ciphertext")
|
|
}
|
|
|
|
/// Verifies that 32-bit RTP timestamp at nonce[4:8] matches
|
|
/// the 64-bit encoding when value fits in 32 bits.
|
|
func testRtpTimestamp32MatchesDesktop64ForSmallValues() throws {
|
|
let key = Data(repeating: 0x66, count: 32)
|
|
let plaintext = Data(repeating: 0xDD, count: 32)
|
|
|
|
let rtpTs: UInt32 = 48000 // 1 second at 48kHz
|
|
|
|
// Desktop-style: 64-bit big-endian in nonce[0:8]
|
|
var nonceDesktop = Data(repeating: 0, count: 24)
|
|
let ts64 = UInt64(rtpTs)
|
|
nonceDesktop[0] = UInt8((ts64 >> 56) & 0xFF) // 0
|
|
nonceDesktop[1] = UInt8((ts64 >> 48) & 0xFF) // 0
|
|
nonceDesktop[2] = UInt8((ts64 >> 40) & 0xFF) // 0
|
|
nonceDesktop[3] = UInt8((ts64 >> 32) & 0xFF) // 0
|
|
nonceDesktop[4] = UInt8((ts64 >> 24) & 0xFF) // 0
|
|
nonceDesktop[5] = UInt8((ts64 >> 16) & 0xFF) // 0
|
|
nonceDesktop[6] = UInt8((ts64 >> 8) & 0xFF) // 0xBB
|
|
nonceDesktop[7] = UInt8(ts64 & 0xFF) // 0x80
|
|
|
|
// RTP-style: 32-bit big-endian at nonce[4:8]
|
|
var nonceRtp = Data(repeating: 0, count: 24)
|
|
nonceRtp[4] = UInt8((rtpTs >> 24) & 0xFF) // 0
|
|
nonceRtp[5] = UInt8((rtpTs >> 16) & 0xFF) // 0
|
|
nonceRtp[6] = UInt8((rtpTs >> 8) & 0xFF) // 0xBB
|
|
nonceRtp[7] = UInt8(rtpTs & 0xFF) // 0x80
|
|
|
|
let encDesktop = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonceDesktop)
|
|
let encRtp = NativeCryptoBridge.xChaCha20Xor(plaintext, key: key, nonce: nonceRtp)
|
|
|
|
XCTAssertNotNil(encDesktop)
|
|
XCTAssertNotNil(encRtp)
|
|
XCTAssertEqual(encDesktop, encRtp,
|
|
"32-bit RTP timestamp at nonce[4:8] must match 64-bit encoding for values < 2^32")
|
|
}
|
|
|
|
// MARK: - Full E2EE round-trip (simulate iOS encrypt → Desktop decrypt)
|
|
|
|
func testFullE2EERoundTripWithDesktopNonce() throws {
|
|
// Simulate: iOS encrypts a frame, Desktop decrypts with same key and nonce
|
|
let sharedKey = Data(repeating: 0x77, count: 32)
|
|
|
|
// Simulated Opus frame (TOC byte 0xFC = CELT, 20ms, 1 frame)
|
|
var opusFrame = Data([0xFC]) // TOC: config=31, code=0
|
|
opusFrame.append(Data(repeating: 0x42, count: 80)) // payload
|
|
|
|
// Desktop-style timestamp nonce
|
|
let timestamp: UInt64 = 96000 // 2 seconds at 48kHz
|
|
var nonce = Data(repeating: 0, count: 24)
|
|
for i in 0..<8 {
|
|
nonce[i] = UInt8((timestamp >> UInt64((7 - i) * 8)) & 0xFF)
|
|
}
|
|
|
|
// Encrypt (iOS side)
|
|
let encrypted = NativeCryptoBridge.xChaCha20Xor(opusFrame, key: sharedKey, nonce: nonce)
|
|
XCTAssertNotNil(encrypted)
|
|
XCTAssertEqual(encrypted!.count, opusFrame.count)
|
|
XCTAssertNotEqual(encrypted, opusFrame)
|
|
|
|
// Decrypt (simulating Desktop side with same key+nonce)
|
|
let decrypted = NativeCryptoBridge.xChaCha20Xor(encrypted!, key: sharedKey, nonce: nonce)
|
|
XCTAssertNotNil(decrypted)
|
|
XCTAssertEqual(decrypted, opusFrame, "Desktop must recover exact Opus frame")
|
|
}
|
|
|
|
// MARK: - HSalsa20 + XChaCha20 full chain
|
|
|
|
func testFullKeyDerivationAndEncryptionChain() throws {
|
|
// Simulate two peers with fixed "DH results"
|
|
let rawDH = Data((0..<32).map { UInt8($0) })
|
|
|
|
let sharedKey = NativeCryptoBridge.naclSharedKey(fromRawDH: rawDH)
|
|
XCTAssertNotNil(sharedKey)
|
|
XCTAssertEqual(sharedKey!.count, 32)
|
|
|
|
let plaintext = Data(repeating: 0xAA, count: 160) // typical Opus frame
|
|
let nonce = Data(repeating: 0, count: 24)
|
|
|
|
let encrypted = NativeCryptoBridge.xChaCha20Xor(plaintext, key: sharedKey!, nonce: nonce)
|
|
XCTAssertNotNil(encrypted)
|
|
|
|
let decrypted = NativeCryptoBridge.xChaCha20Xor(encrypted!, key: sharedKey!, nonce: nonce)
|
|
XCTAssertEqual(decrypted, plaintext, "Full chain: HSalsa20 key derivation + XChaCha20 must round-trip")
|
|
}
|
|
}
|