Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы
This commit is contained in:
624
RosettaTests/CryptoParityTests.swift
Normal file
624
RosettaTests/CryptoParityTests.swift
Normal file
@@ -0,0 +1,624 @@
|
||||
import XCTest
|
||||
@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.
|
||||
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 encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data(latin1.utf8), password: privateKeyHex
|
||||
)
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try CryptoManager.shared.decryptWithPassword(
|
||||
encrypted, password: wrongKeyHex, requireCompression: true
|
||||
),
|
||||
"Decryption with wrong password must fail"
|
||||
)
|
||||
}
|
||||
|
||||
// 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, 1, "Legacy format returns single candidate")
|
||||
XCTAssertEqual(candidates[0], stored, "Legacy candidate is the stored value itself")
|
||||
}
|
||||
|
||||
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 = "deadbeefcafebabe01020304050607080910111213141516171819202122232425262728292a2b2c2d2e2f30"
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try CryptoManager.shared.decryptWithPassword(
|
||||
encrypted, password: "wrong_password", requireCompression: true
|
||||
),
|
||||
"Wrong password with requireCompression must fail"
|
||||
)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
extension MessageRepository {
|
||||
/// Exposes isProbablyEncryptedPayload for testing.
|
||||
static func testIsProbablyEncrypted(_ value: String) -> Bool {
|
||||
isProbablyEncryptedPayload(value)
|
||||
}
|
||||
}
|
||||
@@ -301,4 +301,71 @@ final class ReadEligibilityTests: XCTestCase {
|
||||
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
}
|
||||
|
||||
// MARK: - Idle detection (Desktop/Android parity: 20s)
|
||||
|
||||
func testIdleTimer_clearsEligibilityAfterTimeout() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
|
||||
// Simulate idle timeout (call clearAllReadEligibility directly,
|
||||
// since we can't wait 20s in a unit test)
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
XCTAssertFalse(SessionManager.shared.isUserIdle, "User should not be idle right after reset")
|
||||
|
||||
// Simulate what the idle timer callback does
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer),
|
||||
"After idle timeout, eligibility must be cleared")
|
||||
|
||||
// Messages arriving during idle should be unread
|
||||
try await ctx.runScenario(FixtureScenario(name: "idle msg", events: [
|
||||
.incoming(opponent: peer, messageId: "idle-1", timestamp: 7000, text: "idle msg"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
let msg = snapshot.messages.first(where: { $0.messageId == "idle-1" })
|
||||
XCTAssertEqual(msg?.read, false, "Message during idle must be unread")
|
||||
|
||||
let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer })
|
||||
XCTAssertEqual(dialog?.unreadCount, 1, "Unread count must be 1 during idle")
|
||||
|
||||
// Cleanup
|
||||
SessionManager.shared.stopIdleTimer()
|
||||
}
|
||||
|
||||
func testIdleTimer_resetRestoresEligibility() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
MessageRepository.shared.setDialogActive(peer, isActive: true)
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
// Simulate idle → clear eligibility
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer))
|
||||
|
||||
// Simulate user interaction → reset timer + re-enable eligibility
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
MessageRepository.shared.setDialogReadEligible(peer, isEligible: true)
|
||||
|
||||
XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer),
|
||||
"After user interaction, eligibility must be restored")
|
||||
|
||||
// Cleanup
|
||||
SessionManager.shared.stopIdleTimer()
|
||||
}
|
||||
|
||||
func testStopIdleTimer_resetsIdleState() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
XCTAssertFalse(SessionManager.shared.isUserIdle)
|
||||
|
||||
SessionManager.shared.stopIdleTimer()
|
||||
XCTAssertFalse(SessionManager.shared.isUserIdle, "stopIdleTimer must clear idle state")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user