Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы

This commit is contained in:
2026-03-31 16:36:58 +05:00
parent e5179b11ea
commit 464fae37a9
11 changed files with 1037 additions and 53 deletions

View 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-1UTF-8), both decoders match
// This is the ACTUAL scenario for DesktopiOS 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)
}
}

View File

@@ -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")
}
}