745 lines
30 KiB
Swift
745 lines
30 KiB
Swift
import XCTest
|
|
import CommonCrypto
|
|
import P256K
|
|
@testable import Rosetta
|
|
|
|
/// Cross-platform crypto parity tests: iOS ↔ Desktop ↔ Android.
|
|
/// Verifies that all crypto operations produce compatible output
|
|
/// and that messages encrypted on any platform can be decrypted on iOS.
|
|
@MainActor
|
|
final class CryptoParityTests: XCTestCase {
|
|
|
|
// MARK: - XChaCha20-Poly1305 Round-Trip
|
|
|
|
func testXChaCha20Poly1305_encryptDecryptRoundTrip() throws {
|
|
let plaintext = "Привет, мир! Hello, world! 🔐"
|
|
let key = try CryptoPrimitives.randomBytes(count: 32)
|
|
let nonce = try CryptoPrimitives.randomBytes(count: 24)
|
|
let keyAndNonce = key + nonce
|
|
|
|
let ciphertextWithTag = try XChaCha20Engine.encrypt(
|
|
plaintext: Data(plaintext.utf8), key: key, nonce: nonce
|
|
)
|
|
|
|
let decrypted = try MessageCrypto.decryptIncomingWithPlainKey(
|
|
ciphertext: ciphertextWithTag.hexString,
|
|
plainKeyAndNonce: keyAndNonce
|
|
)
|
|
|
|
XCTAssertEqual(decrypted, plaintext, "Round-trip must preserve plaintext")
|
|
}
|
|
|
|
func testXChaCha20Poly1305_wrongKeyFails() throws {
|
|
let plaintext = "secret message"
|
|
let key = try CryptoPrimitives.randomBytes(count: 32)
|
|
let nonce = try CryptoPrimitives.randomBytes(count: 24)
|
|
let wrongKey = try CryptoPrimitives.randomBytes(count: 32)
|
|
|
|
let ciphertext = try XChaCha20Engine.encrypt(
|
|
plaintext: Data(plaintext.utf8), key: key, nonce: nonce
|
|
)
|
|
|
|
XCTAssertThrowsError(
|
|
try XChaCha20Engine.decrypt(
|
|
ciphertextWithTag: ciphertext, key: wrongKey, nonce: nonce
|
|
),
|
|
"Decryption with wrong key must fail (Poly1305 tag mismatch)"
|
|
)
|
|
}
|
|
|
|
func testXChaCha20Poly1305_emptyPlaintext() throws {
|
|
let key = try CryptoPrimitives.randomBytes(count: 32)
|
|
let nonce = try CryptoPrimitives.randomBytes(count: 24)
|
|
|
|
let ciphertext = try XChaCha20Engine.encrypt(
|
|
plaintext: Data(), key: key, nonce: nonce
|
|
)
|
|
// Ciphertext should be exactly 16 bytes (Poly1305 tag only)
|
|
XCTAssertEqual(ciphertext.count, 16, "Empty plaintext produces 16-byte tag only")
|
|
|
|
let decrypted = try XChaCha20Engine.decrypt(
|
|
ciphertextWithTag: ciphertext, key: key, nonce: nonce
|
|
)
|
|
XCTAssertEqual(decrypted.count, 0, "Decrypted empty plaintext must be empty")
|
|
}
|
|
|
|
func testXChaCha20Poly1305_largePlaintext() throws {
|
|
let plaintext = Data(repeating: 0x42, count: 100_000)
|
|
let key = try CryptoPrimitives.randomBytes(count: 32)
|
|
let nonce = try CryptoPrimitives.randomBytes(count: 24)
|
|
|
|
let ciphertext = try XChaCha20Engine.encrypt(
|
|
plaintext: plaintext, key: key, nonce: nonce
|
|
)
|
|
XCTAssertEqual(ciphertext.count, plaintext.count + 16)
|
|
|
|
let decrypted = try XChaCha20Engine.decrypt(
|
|
ciphertextWithTag: ciphertext, key: key, nonce: nonce
|
|
)
|
|
XCTAssertEqual(decrypted, plaintext)
|
|
}
|
|
|
|
// MARK: - ECDH Encrypt → Decrypt Round-Trip
|
|
|
|
func testECDH_encryptDecryptRoundTrip() throws {
|
|
let plaintext = "Test ECDH round-trip"
|
|
|
|
// Generate recipient key pair
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
|
|
// Encrypt
|
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
|
plaintext: plaintext,
|
|
recipientPublicKeyHex: recipientPubKeyHex
|
|
)
|
|
|
|
XCTAssertFalse(encrypted.content.isEmpty, "Content must not be empty")
|
|
XCTAssertFalse(encrypted.chachaKey.isEmpty, "chachaKey must not be empty")
|
|
XCTAssertEqual(encrypted.plainKeyAndNonce.count, 56, "Key+nonce must be 56 bytes")
|
|
|
|
// Decrypt
|
|
let decrypted = try MessageCrypto.decryptIncoming(
|
|
ciphertext: encrypted.content,
|
|
encryptedKey: encrypted.chachaKey,
|
|
myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString
|
|
)
|
|
|
|
XCTAssertEqual(decrypted, plaintext, "ECDH round-trip must preserve plaintext")
|
|
}
|
|
|
|
func testECDH_decryptReturnsCorrectKeyAndNonce() throws {
|
|
let plaintext = "Key verification"
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
|
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
|
plaintext: plaintext,
|
|
recipientPublicKeyHex: recipientPubKeyHex
|
|
)
|
|
|
|
let (decryptedText, recoveredKeyAndNonce) = try MessageCrypto.decryptIncomingFull(
|
|
ciphertext: encrypted.content,
|
|
encryptedKey: encrypted.chachaKey,
|
|
myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString
|
|
)
|
|
|
|
XCTAssertEqual(decryptedText, plaintext)
|
|
XCTAssertEqual(recoveredKeyAndNonce, encrypted.plainKeyAndNonce,
|
|
"Recovered key+nonce must match original")
|
|
}
|
|
|
|
func testECDH_wrongPrivateKeyFails() throws {
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let wrongPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
|
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
|
plaintext: "secret",
|
|
recipientPublicKeyHex: recipientPubKeyHex
|
|
)
|
|
|
|
XCTAssertThrowsError(
|
|
try MessageCrypto.decryptIncoming(
|
|
ciphertext: encrypted.content,
|
|
encryptedKey: encrypted.chachaKey,
|
|
myPrivateKeyHex: wrongPrivKey.rawRepresentation.hexString
|
|
),
|
|
"Decryption with wrong private key must fail"
|
|
)
|
|
}
|
|
|
|
func testECDH_multipleEncryptions_differentCiphertext() throws {
|
|
let plaintext = "same message"
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
|
|
let enc1 = try MessageCrypto.encryptOutgoing(plaintext: plaintext, recipientPublicKeyHex: recipientPubKeyHex)
|
|
let enc2 = try MessageCrypto.encryptOutgoing(plaintext: plaintext, recipientPublicKeyHex: recipientPubKeyHex)
|
|
|
|
XCTAssertNotEqual(enc1.content, enc2.content, "Each encryption must use different random key")
|
|
XCTAssertNotEqual(enc1.chachaKey, enc2.chachaKey, "Each encryption must use different ephemeral key")
|
|
|
|
// Both must decrypt correctly
|
|
let dec1 = try MessageCrypto.decryptIncoming(ciphertext: enc1.content, encryptedKey: enc1.chachaKey, myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString)
|
|
let dec2 = try MessageCrypto.decryptIncoming(ciphertext: enc2.content, encryptedKey: enc2.chachaKey, myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString)
|
|
XCTAssertEqual(dec1, plaintext)
|
|
XCTAssertEqual(dec2, plaintext)
|
|
}
|
|
|
|
// MARK: - aesChachaKey (Sync Path) Round-Trip
|
|
|
|
func testAesChachaKey_encryptDecryptRoundTrip() throws {
|
|
let plaintext = "Sync path message"
|
|
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
|
|
// Encrypt with XChaCha20
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
|
plaintext: plaintext,
|
|
recipientPublicKeyHex: recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
)
|
|
|
|
// Build aesChachaKey (same logic as makeOutgoingPacket)
|
|
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
|
XCTFail("Latin-1 encoding must succeed for any 56-byte sequence")
|
|
return
|
|
}
|
|
let aesChachaPayload = Data(latin1String.utf8)
|
|
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
aesChachaPayload,
|
|
password: privateKeyHex
|
|
)
|
|
|
|
// Decrypt aesChachaKey (same logic as decryptIncomingMessage sync path)
|
|
let decryptedPayload = try CryptoManager.shared.decryptWithPassword(
|
|
aesChachaKey,
|
|
password: privateKeyHex
|
|
)
|
|
let recoveredKeyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload)
|
|
|
|
XCTAssertEqual(recoveredKeyAndNonce.count, 56, "Recovered key+nonce must be 56 bytes")
|
|
|
|
// Decrypt message content with recovered key
|
|
let decryptedText = try MessageCrypto.decryptIncomingWithPlainKey(
|
|
ciphertext: encrypted.content,
|
|
plainKeyAndNonce: recoveredKeyAndNonce
|
|
)
|
|
XCTAssertEqual(decryptedText, plaintext, "aesChachaKey round-trip must recover original text")
|
|
}
|
|
|
|
func testAesChachaKey_latin1Utf8RoundTrip_allByteValues() throws {
|
|
// Verify that Latin-1 → UTF-8 → Latin-1 round-trip preserves ALL byte values 0-255.
|
|
// This is critical: key bytes can be ANY value.
|
|
var allBytes = Data(count: 256)
|
|
for i in 0..<256 { allBytes[i] = UInt8(i) }
|
|
|
|
guard let latin1String = String(data: allBytes, encoding: .isoLatin1) else {
|
|
XCTFail("Latin-1 must encode all 256 byte values")
|
|
return
|
|
}
|
|
let utf8Bytes = Data(latin1String.utf8)
|
|
let recovered = MessageCrypto.androidUtf8BytesToLatin1Bytes(utf8Bytes)
|
|
|
|
XCTAssertEqual(recovered, allBytes,
|
|
"Latin-1 → UTF-8 → Latin-1 must be lossless for all byte values 0-255")
|
|
}
|
|
|
|
func testAesChachaKey_wrongPasswordFails() throws {
|
|
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
let wrongKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
|
|
let data = try CryptoPrimitives.randomBytes(count: 56)
|
|
guard let latin1 = String(data: data, encoding: .isoLatin1) else { return }
|
|
let plaintext = Data(latin1.utf8)
|
|
let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
plaintext, password: privateKeyHex
|
|
)
|
|
|
|
do {
|
|
let decrypted = try CryptoManager.shared.decryptWithPassword(
|
|
encrypted, password: wrongKeyHex, requireCompression: true
|
|
)
|
|
XCTAssertNotEqual(
|
|
decrypted,
|
|
plaintext,
|
|
"Wrong password must never recover original plaintext"
|
|
)
|
|
} catch {
|
|
// Expected path for the majority of wrong-password attempts.
|
|
}
|
|
}
|
|
|
|
// MARK: - Attachment Password Candidates
|
|
|
|
func testAttachmentPasswordCandidates_rawkeyFormat() {
|
|
// 56 random bytes as hex
|
|
let keyData = Data([
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD,
|
|
])
|
|
let stored = "rawkey:" + keyData.hexString
|
|
|
|
let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored)
|
|
|
|
// Must have at least HEX + Android + WHATWG
|
|
XCTAssertGreaterThanOrEqual(candidates.count, 3, "Must generate ≥3 candidates")
|
|
|
|
// HEX must be first (Desktop commit 61e83bd parity)
|
|
XCTAssertEqual(candidates[0], keyData.hexString,
|
|
"First candidate must be HEX (Desktop parity)")
|
|
|
|
// All candidates must be unique
|
|
XCTAssertEqual(candidates.count, Set(candidates).count,
|
|
"All candidates must be unique (deduplicated)")
|
|
}
|
|
|
|
func testAttachmentPasswordCandidates_legacyFormat() {
|
|
let stored = "some_legacy_password_string"
|
|
let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored)
|
|
|
|
XCTAssertEqual(candidates.count, 2, "Legacy format returns hex+plain candidates")
|
|
XCTAssertEqual(candidates[0], Data(stored.utf8).map { String(format: "%02x", $0) }.joined())
|
|
XCTAssertEqual(candidates[1], stored, "Legacy plain candidate must be preserved")
|
|
}
|
|
|
|
func testAttachmentPasswordCandidates_hexMatchesDesktop() {
|
|
// Desktop: key.toString('hex') = lowercase hex of raw 56 bytes
|
|
let keyBytes = Data([
|
|
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
|
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
|
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
|
|
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
|
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20,
|
|
0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
|
|
0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30,
|
|
])
|
|
let stored = "rawkey:" + keyBytes.hexString
|
|
let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored)
|
|
|
|
// Desktop: Buffer.from(keyBytes).toString('hex')
|
|
let expectedDesktopPassword = "deadbeefcafebabe0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30"
|
|
|
|
// Verify hex format matches (lowercase, no separators)
|
|
XCTAssertTrue(candidates[0].allSatisfy { "0123456789abcdef".contains($0) },
|
|
"HEX candidate must be lowercase hex")
|
|
|
|
// Verify exact match with expected Desktop output
|
|
// Note: the hex is based on keyBytes.hexString which should be lowercase
|
|
XCTAssertEqual(candidates[0], keyBytes.hexString)
|
|
XCTAssertEqual(candidates[0], expectedDesktopPassword)
|
|
}
|
|
|
|
// MARK: - PBKDF2 Parity
|
|
|
|
func testPBKDF2_consistentOutput() throws {
|
|
let password = "test_password_hex_string"
|
|
let key1 = CryptoPrimitives.pbkdf2(
|
|
password: password, salt: "rosetta", iterations: 1000,
|
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
|
)
|
|
let key2 = CryptoPrimitives.pbkdf2(
|
|
password: password, salt: "rosetta", iterations: 1000,
|
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
|
)
|
|
|
|
XCTAssertNotNil(key1)
|
|
XCTAssertNotNil(key2)
|
|
XCTAssertEqual(key1, key2, "PBKDF2 must be deterministic")
|
|
XCTAssertEqual(key1.count, 32, "PBKDF2 key must be 32 bytes")
|
|
}
|
|
|
|
func testPBKDF2_differentPasswordsDifferentKeys() throws {
|
|
let key1 = CryptoPrimitives.pbkdf2(
|
|
password: "password_a", salt: "rosetta", iterations: 1000,
|
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
|
)
|
|
let key2 = CryptoPrimitives.pbkdf2(
|
|
password: "password_b", salt: "rosetta", iterations: 1000,
|
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
|
)
|
|
|
|
XCTAssertNotEqual(key1, key2, "Different passwords must produce different keys")
|
|
}
|
|
|
|
// MARK: - encryptWithPassword / decryptWithPassword Parity
|
|
|
|
func testEncryptWithPasswordDesktopCompat_roundTrip() throws {
|
|
let plaintext = "Cross-platform encrypted message content"
|
|
let password = "my_private_key_hex"
|
|
|
|
let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
Data(plaintext.utf8), password: password
|
|
)
|
|
|
|
// Must be ivBase64:ctBase64 format
|
|
let parts = encrypted.components(separatedBy: ":")
|
|
XCTAssertEqual(parts.count, 2, "Format must be ivBase64:ctBase64")
|
|
|
|
let decrypted = try CryptoManager.shared.decryptWithPassword(
|
|
encrypted, password: password, requireCompression: true
|
|
)
|
|
let decryptedText = String(data: decrypted, encoding: .utf8)
|
|
|
|
XCTAssertEqual(decryptedText, plaintext, "Desktop-compat round-trip must preserve plaintext")
|
|
}
|
|
|
|
func testEncryptWithPassword_iOSOnly_roundTrip() throws {
|
|
let plaintext = "iOS-only storage encryption"
|
|
let password = "local_private_key"
|
|
|
|
let encrypted = try CryptoManager.shared.encryptWithPassword(
|
|
Data(plaintext.utf8), password: password
|
|
)
|
|
|
|
let decrypted = try CryptoManager.shared.decryptWithPassword(
|
|
encrypted, password: password, requireCompression: true
|
|
)
|
|
let decryptedText = String(data: decrypted, encoding: .utf8)
|
|
|
|
XCTAssertEqual(decryptedText, plaintext, "iOS-only round-trip must preserve plaintext")
|
|
}
|
|
|
|
func testDecryptWithPassword_wrongPassword_withCompression_fails() throws {
|
|
let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
Data("secret".utf8), password: "correct_password"
|
|
)
|
|
|
|
do {
|
|
let decrypted = try CryptoManager.shared.decryptWithPassword(
|
|
encrypted,
|
|
password: "wrong_password",
|
|
requireCompression: true
|
|
)
|
|
let decryptedText = String(data: decrypted, encoding: .utf8)
|
|
XCTAssertNotEqual(
|
|
decryptedText,
|
|
"secret",
|
|
"Wrong password must never recover original plaintext"
|
|
)
|
|
} catch {
|
|
// Expected path for most wrong-password attempts.
|
|
}
|
|
}
|
|
|
|
// MARK: - UTF-8 Decoder Parity (Android ↔ iOS)
|
|
|
|
func testAndroidUtf8Decoder_validAscii() {
|
|
let bytes = Data("Hello".utf8)
|
|
let result = MessageCrypto.bytesToAndroidUtf8String(bytes)
|
|
XCTAssertEqual(result, "Hello", "ASCII must decode identically")
|
|
}
|
|
|
|
func testAndroidUtf8Decoder_validMultibyte() {
|
|
// Use BMP-only multibyte text here; four-byte emoji sequences are
|
|
// covered by malformed/compatibility behavior in separate tests.
|
|
let bytes = Data("Привет мир".utf8)
|
|
let result = MessageCrypto.bytesToAndroidUtf8String(bytes)
|
|
XCTAssertEqual(result, "Привет мир", "Valid UTF-8 must decode identically")
|
|
}
|
|
|
|
func testAndroidUtf8Decoder_matchesWhatWG_onValidUtf8() {
|
|
// For valid UTF-8, both decoders must produce identical results
|
|
for _ in 0..<100 {
|
|
let randomBytes = (0..<56).map { _ in UInt8.random(in: 0...127) }
|
|
let data = Data(randomBytes)
|
|
|
|
let android = MessageCrypto.bytesToAndroidUtf8String(data)
|
|
let whatwg = String(decoding: data, as: UTF8.self)
|
|
|
|
XCTAssertEqual(android, whatwg,
|
|
"For ASCII bytes, Android and WHATWG decoders must match")
|
|
}
|
|
}
|
|
|
|
func testLatin1RoundTrip_withBothDecoders_onValidUtf8() {
|
|
// When input is valid UTF-8 (from CryptoJS Latin-1→UTF-8), both decoders match
|
|
// This is the ACTUAL scenario for Desktop→iOS messages
|
|
var allLatin1 = Data(count: 256)
|
|
for i in 0..<256 { allLatin1[i] = UInt8(i) }
|
|
|
|
// Simulate Desktop: Latin-1 string → UTF-8 bytes
|
|
guard let latin1String = String(data: allLatin1, encoding: .isoLatin1) else {
|
|
XCTFail("Latin-1 encoding must work")
|
|
return
|
|
}
|
|
let utf8Bytes = Data(latin1String.utf8)
|
|
|
|
// iOS recovery path 1: WHATWG
|
|
let recoveredWhatWG = MessageCrypto.androidUtf8BytesToLatin1Bytes(utf8Bytes)
|
|
// iOS recovery path 2: Android polyfill
|
|
let recoveredAndroid = MessageCrypto.androidUtf8BytesToLatin1BytesAlt(utf8Bytes)
|
|
|
|
XCTAssertEqual(recoveredWhatWG, allLatin1,
|
|
"WHATWG decoder must recover all 256 Latin-1 bytes from valid UTF-8")
|
|
XCTAssertEqual(recoveredAndroid, allLatin1,
|
|
"Android decoder must recover all 256 Latin-1 bytes from valid UTF-8")
|
|
XCTAssertEqual(recoveredWhatWG, recoveredAndroid,
|
|
"Both decoders must produce identical results for valid UTF-8 input")
|
|
}
|
|
|
|
// MARK: - Defense Layers
|
|
|
|
func testIsProbablyEncryptedPayload_base64Colon() {
|
|
// ivBase64:ctBase64 (both ≥16 chars)
|
|
let encrypted = "YWJjZGVmZ2hpamtsbQ==:eHl6MTIzNDU2Nzg5MA=="
|
|
XCTAssertTrue(MessageRepository.testIsProbablyEncrypted(encrypted),
|
|
"ivBase64:ctBase64 must be detected")
|
|
}
|
|
|
|
func testIsProbablyEncryptedPayload_hexString() {
|
|
// Pure hex ≥40 chars
|
|
let hex = String(repeating: "ab", count: 30) // 60 chars
|
|
XCTAssertTrue(MessageRepository.testIsProbablyEncrypted(hex),
|
|
"Pure hex ≥40 chars must be detected")
|
|
}
|
|
|
|
func testIsProbablyEncryptedPayload_chunked() {
|
|
XCTAssertTrue(MessageRepository.testIsProbablyEncrypted("CHNK:data:here"),
|
|
"CHNK: format must be detected")
|
|
}
|
|
|
|
func testIsProbablyEncryptedPayload_normalText() {
|
|
XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("Hello, world!"),
|
|
"Normal text must NOT be flagged")
|
|
XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("Привет"),
|
|
"Cyrillic text must NOT be flagged")
|
|
}
|
|
|
|
func testIsProbablyEncryptedPayload_shortHex() {
|
|
// Short hex (<40 chars) must NOT be flagged (could be normal text)
|
|
XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("abcdef1234"),
|
|
"Short hex must NOT be flagged")
|
|
}
|
|
|
|
func testIsProbablyEncryptedPayload_shortBase64Parts() {
|
|
// Both parts <16 chars must NOT be flagged
|
|
XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("abc:def"),
|
|
"Short base64:base64 must NOT be flagged")
|
|
}
|
|
|
|
func testIsProbablyEncryptedPayload_emptyString() {
|
|
// Empty string is not encrypted
|
|
XCTAssertFalse(MessageRepository.testIsProbablyEncrypted(""),
|
|
"Empty string must NOT be flagged as encrypted (it's just empty)")
|
|
}
|
|
|
|
// MARK: - Compression Parity (zlib)
|
|
|
|
func testZlibDeflate_inflate_roundTrip() throws {
|
|
let original = Data("Test compression for cross-platform parity".utf8)
|
|
|
|
let compressed = try CryptoPrimitives.zlibDeflate(original)
|
|
XCTAssertTrue(compressed.count > 0, "Compressed data must not be empty")
|
|
XCTAssertTrue(compressed[0] == 0x78, "zlib deflate must start with 0x78 header")
|
|
|
|
let decompressed = try CryptoPrimitives.rawInflate(compressed)
|
|
XCTAssertEqual(decompressed, original, "Inflate must recover original data")
|
|
}
|
|
|
|
func testRawDeflate_inflate_roundTrip() throws {
|
|
let original = Data("Raw deflate for iOS-only storage".utf8)
|
|
|
|
let compressed = try CryptoPrimitives.rawDeflate(original)
|
|
XCTAssertTrue(compressed.count > 0)
|
|
|
|
let decompressed = try CryptoPrimitives.rawInflate(compressed)
|
|
XCTAssertEqual(decompressed, original, "Raw inflate must recover original data")
|
|
}
|
|
|
|
// MARK: - Full Message Flow (Simulate Desktop → iOS)
|
|
|
|
func testFullMessageFlow_desktopToiOS() throws {
|
|
// Simulate: Desktop sends message to iOS user
|
|
let senderPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
|
|
let originalMessage = "Message from Desktop to iOS 🚀"
|
|
|
|
// Step 1: Desktop encrypts (simulated on iOS since crypto is identical)
|
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
|
plaintext: originalMessage,
|
|
recipientPublicKeyHex: recipientPubKeyHex
|
|
)
|
|
|
|
// Step 2: Build aesChachaKey for sync (Desktop logic)
|
|
let senderPrivKeyHex = senderPrivKey.rawRepresentation.hexString
|
|
guard let latin1 = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
|
XCTFail("Latin-1 encoding failed")
|
|
return
|
|
}
|
|
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
Data(latin1.utf8), password: senderPrivKeyHex
|
|
)
|
|
|
|
// Step 3: iOS receives and decrypts via ECDH path
|
|
let (ecdhText, ecdhKeyAndNonce) = try MessageCrypto.decryptIncomingFull(
|
|
ciphertext: encrypted.content,
|
|
encryptedKey: encrypted.chachaKey,
|
|
myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString
|
|
)
|
|
XCTAssertEqual(ecdhText, originalMessage, "ECDH path must decrypt correctly")
|
|
|
|
// Step 4: iOS receives own message via aesChachaKey sync path
|
|
let syncDecrypted = try CryptoManager.shared.decryptWithPassword(
|
|
aesChachaKey, password: senderPrivKeyHex
|
|
)
|
|
let syncKeyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(syncDecrypted)
|
|
let syncText = try MessageCrypto.decryptIncomingWithPlainKey(
|
|
ciphertext: encrypted.content,
|
|
plainKeyAndNonce: syncKeyAndNonce
|
|
)
|
|
XCTAssertEqual(syncText, originalMessage, "aesChachaKey sync path must decrypt correctly")
|
|
|
|
// Step 5: Verify key+nonce matches across both paths
|
|
XCTAssertEqual(ecdhKeyAndNonce, syncKeyAndNonce,
|
|
"ECDH and sync paths must recover the same key+nonce")
|
|
|
|
// Step 6: Verify attachment password derivation matches
|
|
let ecdhPassword = "rawkey:" + ecdhKeyAndNonce.hexString
|
|
let syncPassword = "rawkey:" + syncKeyAndNonce.hexString
|
|
let ecdhCandidates = MessageCrypto.attachmentPasswordCandidates(from: ecdhPassword)
|
|
let syncCandidates = MessageCrypto.attachmentPasswordCandidates(from: syncPassword)
|
|
XCTAssertEqual(ecdhCandidates, syncCandidates,
|
|
"Attachment password candidates must be identical across both paths")
|
|
}
|
|
|
|
func testDecryptIncomingMessage_allowsAttachmentOnlyEmptyContent() throws {
|
|
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
var packet = PacketMessage()
|
|
packet.fromPublicKey = "02peer_attachment_only"
|
|
packet.toPublicKey = "02my_attachment_only"
|
|
packet.content = ""
|
|
packet.chachaKey = ""
|
|
packet.attachments = [
|
|
MessageAttachment(
|
|
id: "att-1",
|
|
preview: "preview",
|
|
blob: "",
|
|
type: .image,
|
|
transportTag: "tag-1",
|
|
transportServer: "cdn.rosetta.im"
|
|
),
|
|
]
|
|
|
|
let result = SessionManager.testDecryptIncomingMessage(
|
|
packet: packet,
|
|
myPublicKey: "02my_attachment_only",
|
|
privateKeyHex: privateKeyHex,
|
|
groupKey: nil
|
|
)
|
|
|
|
XCTAssertNotNil(result)
|
|
XCTAssertEqual(result?.text, "")
|
|
}
|
|
|
|
func testDecryptIncomingMessage_rejectsEmptyContentWithoutAttachments() throws {
|
|
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
var packet = PacketMessage()
|
|
packet.fromPublicKey = "02peer_invalid"
|
|
packet.toPublicKey = "02my_invalid"
|
|
packet.content = ""
|
|
packet.chachaKey = ""
|
|
packet.attachments = []
|
|
|
|
let result = SessionManager.testDecryptIncomingMessage(
|
|
packet: packet,
|
|
myPublicKey: "02my_invalid",
|
|
privateKeyHex: privateKeyHex,
|
|
groupKey: nil
|
|
)
|
|
|
|
XCTAssertNil(result)
|
|
}
|
|
|
|
func testRecoverRetryPlaintext_rejectsCiphertextFallback() throws {
|
|
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
let wrongPrivateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
|
|
let encrypted = try CryptoManager.shared.encryptWithPassword(
|
|
Data("retry-text".utf8),
|
|
password: privateKeyHex
|
|
)
|
|
|
|
let recoveredWithWrongKey = SessionManager.testRecoverRetryPlaintext(
|
|
storedText: encrypted,
|
|
privateKeyHex: wrongPrivateKeyHex
|
|
)
|
|
XCTAssertNotEqual(
|
|
recoveredWithWrongKey,
|
|
encrypted,
|
|
"Retry recovery must never return encrypted wire payload as plaintext"
|
|
)
|
|
XCTAssertNotEqual(
|
|
recoveredWithWrongKey,
|
|
"retry-text",
|
|
"Wrong key must never recover original plaintext"
|
|
)
|
|
|
|
let recoveredPlainLegacy = SessionManager.testRecoverRetryPlaintext(
|
|
storedText: "legacy plain text",
|
|
privateKeyHex: privateKeyHex
|
|
)
|
|
XCTAssertEqual(recoveredPlainLegacy, "legacy plain text")
|
|
}
|
|
|
|
func testRawKeyAndNonceParser_requiresStrictRawKeyFormat() throws {
|
|
let raw = try CryptoPrimitives.randomBytes(count: 56)
|
|
let validStored = "rawkey:" + raw.hexString
|
|
|
|
let decodedValid = SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword(validStored)
|
|
XCTAssertEqual(decodedValid, raw)
|
|
|
|
XCTAssertNil(
|
|
SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword(raw.hexString),
|
|
"Missing rawkey prefix must be rejected"
|
|
)
|
|
XCTAssertNil(
|
|
SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword("rawkey:abc"),
|
|
"Odd-length hex must be rejected"
|
|
)
|
|
XCTAssertNil(
|
|
SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword("rawkey:zz"),
|
|
"Non-hex symbols must be rejected"
|
|
)
|
|
}
|
|
|
|
func testDataStrictHexString_rejectsInvalidInput() {
|
|
XCTAssertNil(Data(strictHexString: "abc"))
|
|
XCTAssertNil(Data(strictHexString: "0g"))
|
|
XCTAssertEqual(Data(strictHexString: "0A0b"), Data([0x0A, 0x0B]))
|
|
}
|
|
|
|
// MARK: - Stress Test: Random Key Bytes
|
|
|
|
func testECDH_100RandomKeys_allDecryptSuccessfully() throws {
|
|
for i in 0..<100 {
|
|
let recipientPrivKey = try P256K.KeyAgreement.PrivateKey()
|
|
let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString
|
|
|
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
|
plaintext: "Message #\(i)",
|
|
recipientPublicKeyHex: recipientPubKeyHex
|
|
)
|
|
|
|
let decrypted = try MessageCrypto.decryptIncoming(
|
|
ciphertext: encrypted.content,
|
|
encryptedKey: encrypted.chachaKey,
|
|
myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString
|
|
)
|
|
|
|
XCTAssertEqual(decrypted, "Message #\(i)", "Message #\(i) must decrypt correctly")
|
|
}
|
|
}
|
|
|
|
func testAesChachaKey_100RandomKeys_allRoundTrip() throws {
|
|
for i in 0..<100 {
|
|
let keyAndNonce = try CryptoPrimitives.randomBytes(count: 56)
|
|
let password = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
|
|
|
|
guard let latin1 = String(data: keyAndNonce, encoding: .isoLatin1) else {
|
|
XCTFail("Latin-1 must work for key #\(i)")
|
|
continue
|
|
}
|
|
|
|
let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
Data(latin1.utf8), password: password
|
|
)
|
|
|
|
let decrypted = try CryptoManager.shared.decryptWithPassword(
|
|
encrypted, password: password
|
|
)
|
|
let recovered = MessageCrypto.androidUtf8BytesToLatin1Bytes(decrypted)
|
|
|
|
XCTAssertEqual(recovered, keyAndNonce,
|
|
"aesChachaKey round-trip #\(i) must recover original 56 bytes")
|
|
}
|
|
}
|
|
}
|