Фикс: исправлено исчезновение части уведомлений при открытии пуша

This commit is contained in:
2026-04-06 23:35:29 +05:00
parent 333908a4d9
commit a5945152c0
27 changed files with 2240 additions and 340 deletions

View File

@@ -14,6 +14,8 @@
<string>Rosetta needs access to your microphone for secure voice calls and audio messages.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>

View File

@@ -9,8 +9,10 @@
/* Begin PBXBuildFile section */
3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */; };
3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */; };
B7F1C2D34A5E67890ABCDEF1 /* CryptoParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */; };
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */; };
C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */; };
806C964D76E024430307C151 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
@@ -96,6 +98,7 @@
272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBTestSupport.swift; sourceTree = "<group>"; };
D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CryptoParityTests.swift; sourceTree = "<group>"; };
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RosettaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SchemaParityTests.swift; sourceTree = "<group>"; };
853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -103,6 +106,7 @@
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = "<group>"; };
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = "<group>"; };
EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageDecodeHardeningTests.swift; sourceTree = "<group>"; };
E20000042F8D11110092AD05 /* WebRTC.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = WebRTC.xcframework; path = Frameworks/WebRTC.xcframework; sourceTree = "<group>"; };
LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaLiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallLiveActivity.swift; sourceTree = "<group>"; };
@@ -163,8 +167,10 @@
children = (
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */,
C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */,
D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */,
4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */,
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */,
EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */,
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */,
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */,
);
@@ -411,8 +417,10 @@
files = (
3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */,
3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */,
B7F1C2D34A5E67890ABCDEF1 /* CryptoParityTests.swift in Sources */,
CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */,
EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */,
C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */,
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */,
4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */,
);
@@ -465,7 +473,7 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
@@ -475,7 +483,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.6;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -613,7 +621,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -629,7 +637,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.9;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -653,7 +661,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -669,7 +677,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.9;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -692,7 +700,7 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
@@ -702,7 +710,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.6;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -756,7 +764,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = RosettaLiveActivityWidget/RosettaLiveActivityWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaLiveActivityWidget/Info.plist;
@@ -766,7 +774,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.6;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.LiveActivityWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -783,7 +791,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = RosettaLiveActivityWidget/RosettaLiveActivityWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaLiveActivityWidget/Info.plist;
@@ -793,7 +801,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.6;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.LiveActivityWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -235,4 +235,23 @@ extension Data {
}
self = data
}
/// Initialize from a STRICT hex string.
/// Returns nil if the input contains non-hex characters or has odd length.
nonisolated init?(strictHexString: String) {
let hex = strictHexString.trimmingCharacters(in: .whitespacesAndNewlines)
guard hex.count % 2 == 0 else { return nil }
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}

View File

@@ -290,7 +290,7 @@ enum MessageCrypto {
// MARK: - ECDH Key Exchange
private extension MessageCrypto {
extension MessageCrypto {
/// Decrypts and returns candidate XChaCha20 key+nonce buffers.
/// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex)

View File

@@ -17,6 +17,8 @@ final class AvatarRepository {
/// Android/Desktop parity: fixed password for avatar encryption at rest.
private static let avatarPassword = "rosetta-a"
/// Shared container used by Notification Service Extension.
private static let appGroupID = "group.com.rosetta.dev"
/// Incremented on every avatar save/remove views that read this property
/// will re-render and pick up the latest avatar from cache.
@@ -41,7 +43,7 @@ final class AvatarRepository {
let key = normalizedKey(publicKey)
guard let jpegData = image.jpegData(compressionQuality: compressionQuality) else { return }
let url = avatarURL(for: key)
ensureDirectoryExists()
ensureStorageDirectoriesExist()
// Encrypt with "rosetta-a" (Android/Desktop parity)
if let encrypted = try? CryptoManager.shared.encryptWithPassword(jpegData, password: Self.avatarPassword),
@@ -52,6 +54,7 @@ final class AvatarRepository {
try? jpegData.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
}
cache.setObject(image, forKey: key as NSString, cost: jpegData.count)
syncAvatarToNotificationStoreIfNeeded(image, normalizedKey: key)
avatarVersion += 1
}
@@ -75,6 +78,7 @@ final class AvatarRepository {
}
let key = normalizedKey(publicKey)
if let cached = cache.object(forKey: key as NSString) {
syncAvatarToNotificationStoreIfNeeded(cached, normalizedKey: key)
return cached
}
let url = avatarURL(for: key)
@@ -85,6 +89,7 @@ final class AvatarRepository {
let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: Self.avatarPassword),
let image = UIImage(data: decrypted) {
cache.setObject(image, forKey: key as NSString, cost: decrypted.count)
syncAvatarToNotificationStoreIfNeeded(image, normalizedKey: key)
return image
}
@@ -122,6 +127,9 @@ final class AvatarRepository {
cache.removeObject(forKey: key as NSString)
let url = avatarURL(for: key)
try? FileManager.default.removeItem(at: url)
if let notificationURL = notificationAvatarURL(for: key) {
try? FileManager.default.removeItem(at: notificationURL)
}
avatarVersion += 1
}
@@ -134,6 +142,9 @@ final class AvatarRepository {
if let directory = avatarsDirectory {
try? FileManager.default.removeItem(at: directory)
}
if let directory = notificationAvatarsDirectory {
try? FileManager.default.removeItem(at: directory)
}
avatarVersion += 1
}
@@ -145,24 +156,46 @@ final class AvatarRepository {
.appendingPathComponent("Rosetta/Avatars", isDirectory: true)
}
private var notificationAvatarsDirectory: URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupID)?
.appendingPathComponent("NotificationAvatars", isDirectory: true)
}
private func avatarURL(for normalizedKey: String) -> URL {
avatarsDirectory!
.appendingPathComponent("\(normalizedKey).enc")
}
private func notificationAvatarURL(for normalizedKey: String) -> URL? {
notificationAvatarsDirectory?
.appendingPathComponent("\(normalizedKey).jpg")
}
private func normalizedKey(_ publicKey: String) -> String {
publicKey
.replacingOccurrences(of: "0x", with: "")
.lowercased()
}
private func ensureDirectoryExists() {
guard let directory = avatarsDirectory else { return }
if !FileManager.default.fileExists(atPath: directory.path) {
private func ensureStorageDirectoriesExist() {
ensureDirectoryExists(avatarsDirectory)
ensureDirectoryExists(notificationAvatarsDirectory)
}
private func ensureDirectoryExists(_ directory: URL?) {
guard let directory else { return }
if FileManager.default.fileExists(atPath: directory.path) { return }
try? FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
}
private func syncAvatarToNotificationStoreIfNeeded(_ image: UIImage, normalizedKey: String) {
guard let notificationURL = notificationAvatarURL(for: normalizedKey) else { return }
ensureDirectoryExists(notificationAvatarsDirectory)
guard let jpegData = image.jpegData(compressionQuality: 0.72) else { return }
try? jpegData.write(to: notificationURL, options: [.atomic])
}
}

View File

@@ -545,6 +545,21 @@ final class MessageRepository: ObservableObject {
refreshCacheNow(for: opponentKey)
}
/// Updates attachment password for a specific message (used during retry with re-encryption).
func updateAttachmentPassword(messageId: String, password: String) {
guard !currentAccount.isEmpty else { return }
do {
try db.writeSync { db in
try db.execute(
sql: "UPDATE messages SET attachment_password = ? WHERE account = ? AND message_id = ?",
arguments: [password, currentAccount, messageId]
)
}
} catch {
print("[DB] updateAttachmentPassword error: \(error)")
}
}
// MARK: - Typing
func markTyping(from dialogKey: String, senderKey: String) {
@@ -1093,6 +1108,12 @@ final class MessageRepository: ObservableObject {
return false
}
#if DEBUG
internal static func testIsProbablyEncrypted(_ value: String) -> Bool {
isProbablyEncryptedPayload(value)
}
#endif
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
raw < 1_000_000_000_000 ? raw * 1000 : raw
}

View File

@@ -1,16 +1,19 @@
import Foundation
import os
/// Base protocol for all Rosetta binary packets.
protocol Packet {
static var packetId: Int { get }
func write(to stream: Stream)
mutating func read(from stream: Stream)
mutating func read(from stream: Stream) throws
}
// MARK: - Packet Registry
enum PacketRegistry {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "PacketRegistry")
/// All known packet factories, keyed by packet ID.
private static let factories: [Int: () -> any Packet] = [
0x00: { PacketHandshake() },
@@ -43,15 +46,28 @@ enum PacketRegistry {
/// Deserializes a packet from raw binary data.
static func decode(from data: Data) -> (packetId: Int, packet: any Packet)? {
guard data.count >= 2 else {
logger.error("Rejecting packet: too short payload size=\(data.count)")
return nil
}
let stream = Stream(data: data)
let packetId = stream.readInt16()
guard let factory = factories[packetId] else {
let packetHex = String(format: "0x%02X", packetId)
logger.warning("Rejecting packet: unknown packetId=\(packetHex) size=\(data.count)")
return nil
}
var packet = factory()
packet.read(from: stream)
do {
try packet.read(from: stream)
} catch {
let packetHex = String(format: "0x%02X", packetId)
logger.error("Rejecting packet: parse failure packetId=\(packetHex) size=\(data.count) error=\(String(describing: error))")
return nil
}
return (packetId, packet)
}

View File

@@ -5,10 +5,6 @@ import Foundation
enum HandshakeState: Int {
case completed = 0
case needDeviceVerification = 1
init(value: Int) {
self = HandshakeState(rawValue: value) ?? .completed
}
}
// MARK: - HandshakeDevice
@@ -30,6 +26,8 @@ struct PacketHandshake: Packet {
var heartbeatInterval: Int = 15
var device = HandshakeDevice()
var handshakeState: HandshakeState = .needDeviceVerification
var isMalformed: Bool = false
var malformedFingerprint: String = ""
func write(to stream: Stream) {
stream.writeString(privateKey)
@@ -43,15 +41,62 @@ struct PacketHandshake: Packet {
}
mutating func read(from stream: Stream) {
privateKey = stream.readString()
publicKey = stream.readString()
protocolVersion = stream.readInt8()
heartbeatInterval = stream.readInt8()
do {
let parsedPrivateKey = try stream.readStringStrict()
let parsedPublicKey = try stream.readStringStrict()
let parsedProtocolVersion = try stream.readInt8Strict()
let parsedHeartbeatInterval = try stream.readInt8Strict()
let parsedDeviceId = try stream.readStringStrict()
let parsedDeviceName = try stream.readStringStrict()
let parsedDeviceOs = try stream.readStringStrict()
let rawState = try stream.readInt8Strict()
guard let parsedState = HandshakeState(rawValue: rawState) else {
markMalformed("invalid_state:\(rawState)")
return
}
guard !stream.hasRemainingBits() else {
markMalformed("trailing_bits:\(stream.remainingBits())")
return
}
privateKey = parsedPrivateKey
publicKey = parsedPublicKey
protocolVersion = parsedProtocolVersion
heartbeatInterval = parsedHeartbeatInterval
device = HandshakeDevice(
deviceId: stream.readString(),
deviceName: stream.readString(),
deviceOs: stream.readString()
deviceId: parsedDeviceId,
deviceName: parsedDeviceName,
deviceOs: parsedDeviceOs
)
handshakeState = HandshakeState(value: stream.readInt8())
handshakeState = parsedState
isMalformed = false
malformedFingerprint = ""
} catch {
markMalformed(Self.errorFingerprint(error))
}
}
private mutating func markMalformed(_ fingerprint: String) {
privateKey = ""
publicKey = ""
protocolVersion = 1
heartbeatInterval = 15
device = HandshakeDevice()
handshakeState = .needDeviceVerification
isMalformed = true
malformedFingerprint = fingerprint
}
private static func errorFingerprint(_ error: Error) -> String {
switch error {
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
return "underflow:\(operation):\(neededBits):\(remainingBits)"
case PacketBitStreamError.invalidStringLength(let length):
return "invalid_string_length:\(length)"
default:
return "parse_error"
}
}
}

View File

@@ -13,6 +13,22 @@ struct PacketMessage: Packet {
var messageId: String = ""
var attachments: [MessageAttachment] = []
var aesChachaKey: String = "" // ChaCha key+nonce encrypted by sender
/// True when payload could not be parsed in any known compatibility layout.
var isMalformed: Bool = false
/// Compact parser fingerprint for diagnostics (no sensitive payload data).
var malformedFingerprint: String = ""
private struct ParsedPacketMessage {
let fromPublicKey: String
let toPublicKey: String
let content: String
let chachaKey: String
let timestamp: Int64
let privateKey: String
let messageId: String
let attachments: [MessageAttachment]
let aesChachaKey: String
}
func write(to stream: Stream) {
// Match Android field order exactly
@@ -37,33 +53,126 @@ struct PacketMessage: Packet {
}
mutating func read(from stream: Stream) {
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
content = stream.readString()
chachaKey = stream.readString()
timestamp = stream.readInt64()
privateKey = stream.readString()
messageId = stream.readString()
let startPointer = stream.getReadPointerBits()
var parseErrors: [String] = []
for attachmentMetaFieldCount in [4, 2, 0] {
stream.setReadPointerBits(startPointer)
do {
let parsed = try Self.parseFromStream(
stream,
attachmentMetaFieldCount: attachmentMetaFieldCount
)
if stream.hasRemainingBits() {
parseErrors.append(
"meta\(attachmentMetaFieldCount):trailing_bits=\(stream.remainingBits())"
)
continue
}
fromPublicKey = parsed.fromPublicKey
toPublicKey = parsed.toPublicKey
content = parsed.content
chachaKey = parsed.chachaKey
timestamp = parsed.timestamp
privateKey = parsed.privateKey
messageId = parsed.messageId
attachments = parsed.attachments
aesChachaKey = parsed.aesChachaKey
isMalformed = false
malformedFingerprint = ""
return
} catch {
parseErrors.append("meta\(attachmentMetaFieldCount):\(Self.errorFingerprint(error))")
}
}
// Hard-fail parse: preserve zero/default fields and mark packet malformed.
fromPublicKey = ""
toPublicKey = ""
content = ""
chachaKey = ""
timestamp = 0
privateKey = ""
messageId = ""
attachments = []
aesChachaKey = ""
isMalformed = true
malformedFingerprint = parseErrors.isEmpty
? "packet06_parse_failed"
: parseErrors.joined(separator: "|")
}
private static func parseFromStream(
_ parser: Stream,
attachmentMetaFieldCount: Int
) throws -> ParsedPacketMessage {
let parsedFromPublicKey = try parser.readStringStrict()
let parsedToPublicKey = try parser.readStringStrict()
let parsedContent = try parser.readStringStrict()
let parsedChachaKey = try parser.readStringStrict()
let parsedTimestamp = try parser.readInt64Strict()
let parsedPrivateKey = try parser.readStringStrict()
let parsedMessageId = try parser.readStringStrict()
let attachmentCount = max(try parser.readInt8Strict(), 0)
var parsedAttachments: [MessageAttachment] = []
parsedAttachments.reserveCapacity(attachmentCount)
let attachmentCount = max(stream.readInt8(), 0)
var list: [MessageAttachment] = []
for _ in 0..<attachmentCount {
let attId = stream.readString()
let attPreview = stream.readString()
let attBlob = stream.readString()
let attType = AttachmentType(rawValue: stream.readInt8()) ?? .image
let attTransportTag = stream.readString()
let attTransportServer = stream.readString()
list.append(MessageAttachment(
id: attId,
preview: attPreview,
blob: attBlob,
type: attType,
transportTag: attTransportTag,
transportServer: attTransportServer
let id = try parser.readStringStrict()
let preview = try parser.readStringStrict()
let blob = try parser.readStringStrict()
let type = AttachmentType(rawValue: try parser.readInt8Strict()) ?? .image
let transportTag: String
let transportServer: String
if attachmentMetaFieldCount >= 2 {
transportTag = try parser.readStringStrict()
transportServer = try parser.readStringStrict()
} else {
transportTag = ""
transportServer = ""
}
// Older Android builds may contain extra metadata fields.
if attachmentMetaFieldCount >= 4 {
_ = try parser.readStringStrict() // encodedFor
_ = try parser.readStringStrict() // encoder
}
parsedAttachments.append(MessageAttachment(
id: id,
preview: preview,
blob: blob,
type: type,
transportTag: transportTag,
transportServer: transportServer
))
}
attachments = list
aesChachaKey = stream.readString()
let parsedAesChachaKey = try parser.readStringStrict()
return ParsedPacketMessage(
fromPublicKey: parsedFromPublicKey,
toPublicKey: parsedToPublicKey,
content: parsedContent,
chachaKey: parsedChachaKey,
timestamp: parsedTimestamp,
privateKey: parsedPrivateKey,
messageId: parsedMessageId,
attachments: parsedAttachments,
aesChachaKey: parsedAesChachaKey
)
}
private static func errorFingerprint(_ error: Error) -> String {
switch error {
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
return "underflow:\(operation):\(neededBits):\(remainingBits)"
case PacketBitStreamError.invalidStringLength(let length):
return "invalid_string_length:\(length)"
default:
return "parse_error"
}
}
}

View File

@@ -7,15 +7,14 @@ enum PushNotificationAction: Int {
}
/// Token type for push notification registration.
/// Server parity: im.rosetta.packet.runtime.TokenType
enum PushTokenType: Int {
case fcm = 0 // FCM token (iOS + Android)
case voipApns = 1 // VoIP APNs token (iOS only)
case fcm = 0
case voipApns = 1
}
/// PushNotification packet (0x10) registers or unregisters APNs/FCM token on server.
/// Sent after successful handshake to enable push notifications.
/// Server stores tokens at device level (PushToken entity linked to Device).
/// PushNotification packet (0x10) registers or unregisters push token on server.
/// Cross-platform wire format parity (Server/Android):
/// writeString(token) + writeInt8(action) + writeInt8(tokenType) + writeString(deviceId)
struct PacketPushNotification: Packet {
static let packetId = 0x10

View File

@@ -27,6 +27,8 @@ struct PacketSignalPeer: Packet {
var callId: String = ""
var joinToken: String = ""
var roomId: String = ""
var isMalformed: Bool = false
var malformedFingerprint: String = ""
func write(to stream: Stream) {
stream.writeInt8(signalType.rawValue)
@@ -51,30 +53,57 @@ struct PacketSignalPeer: Packet {
}
mutating func read(from stream: Stream) {
src = ""
dst = ""
sharedPublic = ""
callId = ""
joinToken = ""
roomId = ""
signalType = SignalType(rawValue: stream.readInt8()) ?? .call
if isShortSignal {
do {
let rawSignalType = try stream.readInt8Strict()
guard let parsedSignalType = SignalType(rawValue: rawSignalType) else {
markMalformed("invalid_signal_type:\(rawSignalType)")
return
}
src = stream.readString()
dst = stream.readString()
if signalType == .keyExchange {
sharedPublic = stream.readString()
var parsedSrc = ""
var parsedDst = ""
var parsedSharedPublic = ""
var parsedCallId = ""
var parsedJoinToken = ""
var parsedRoomId = ""
if !Self.isShortSignal(parsedSignalType) {
parsedSrc = try stream.readStringStrict()
parsedDst = try stream.readStringStrict()
if parsedSignalType == .keyExchange {
parsedSharedPublic = try stream.readStringStrict()
}
if hasLegacyCallMetadata {
callId = stream.readString()
joinToken = stream.readString()
if Self.hasLegacyCallMetadata(parsedSignalType) {
parsedCallId = try stream.readStringStrict()
parsedJoinToken = try stream.readStringStrict()
}
// Signal code 4 is mode-aware on read:
// - empty roomId => legacy ACTIVE
// - non-empty roomId => create-room fallback
if signalType == .createRoom {
roomId = stream.readString()
// signalType=4 supports both layouts:
// - legacy ACTIVE: no roomId field
// - create-room fallback: roomId field at tail
if parsedSignalType == .createRoom, stream.hasRemainingBits() {
parsedRoomId = try stream.readStringStrict()
}
}
guard !stream.hasRemainingBits() else {
markMalformed("trailing_bits:\(stream.remainingBits())")
return
}
src = parsedSrc
dst = parsedDst
sharedPublic = parsedSharedPublic
signalType = parsedSignalType
callId = parsedCallId
joinToken = parsedJoinToken
roomId = parsedRoomId
isMalformed = false
malformedFingerprint = ""
} catch {
markMalformed(Self.errorFingerprint(error))
}
}
@@ -87,4 +116,37 @@ struct PacketSignalPeer: Packet {
private var hasLegacyCallMetadata: Bool {
signalType == .call || signalType == .accept || signalType == .endCall
}
private mutating func markMalformed(_ fingerprint: String) {
src = ""
dst = ""
sharedPublic = ""
signalType = .call
callId = ""
joinToken = ""
roomId = ""
isMalformed = true
malformedFingerprint = fingerprint
}
private static func isShortSignal(_ signalType: SignalType) -> Bool {
signalType == .endCallBecauseBusy
|| signalType == .endCallBecausePeerDisconnected
|| signalType == .ringingTimeout
}
private static func hasLegacyCallMetadata(_ signalType: SignalType) -> Bool {
signalType == .call || signalType == .accept || signalType == .endCall
}
private static func errorFingerprint(_ error: Error) -> String {
switch error {
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
return "underflow:\(operation):\(neededBits):\(remainingBits)"
case PacketBitStreamError.invalidStringLength(let length):
return "invalid_string_length:\(length)"
default:
return "parse_error"
}
}
}

View File

@@ -6,10 +6,6 @@ enum SyncStatus: Int {
case notNeeded = 0
case batchStart = 1
case batchEnd = 2
init(value: Int) {
self = SyncStatus(rawValue: value) ?? .notNeeded
}
}
// MARK: - PacketSync (0x19)
@@ -20,6 +16,8 @@ struct PacketSync: Packet {
var status: SyncStatus = .notNeeded
var timestamp: Int64 = 0
var isMalformed: Bool = false
var malformedFingerprint: String = ""
func write(to stream: Stream) {
stream.writeInt8(status.rawValue)
@@ -27,7 +25,44 @@ struct PacketSync: Packet {
}
mutating func read(from stream: Stream) {
status = SyncStatus(value: stream.readInt8())
timestamp = stream.readInt64()
do {
let rawStatus = try stream.readInt8Strict()
guard let parsedStatus = SyncStatus(rawValue: rawStatus) else {
markMalformed("invalid_status:\(rawStatus)")
return
}
let parsedTimestamp = try stream.readInt64Strict()
guard !stream.hasRemainingBits() else {
markMalformed("trailing_bits:\(stream.remainingBits())")
return
}
status = parsedStatus
timestamp = parsedTimestamp
isMalformed = false
malformedFingerprint = ""
} catch {
markMalformed(Self.errorFingerprint(error))
}
}
private mutating func markMalformed(_ fingerprint: String) {
status = .notNeeded
timestamp = 0
isMalformed = true
malformedFingerprint = fingerprint
}
private static func errorFingerprint(_ error: Error) -> String {
switch error {
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
return "underflow:\(operation):\(neededBits):\(remainingBits)"
case PacketBitStreamError.invalidStringLength(let length):
return "invalid_string_length:\(length)"
default:
return "parse_error"
}
}
}

View File

@@ -17,18 +17,123 @@ struct PacketWebRTC: Packet {
var publicKey: String = ""
/// Sender's device ID server checks publicKeydeviceId binding.
var deviceId: String = ""
var isMalformed: Bool = false
var malformedFingerprint: String = ""
func write(to stream: Stream) {
// Canonical wire format: signalType + sdpOrCandidate.
// Keep publicKey/deviceId as in-memory fields for backward compatibility.
stream.writeInt8(signalType.rawValue)
stream.writeString(sdpOrCandidate)
stream.writeString(publicKey)
stream.writeString(deviceId)
}
mutating func read(from stream: Stream) {
signalType = WebRTCSignalType(rawValue: stream.readInt8()) ?? .offer
sdpOrCandidate = stream.readString()
publicKey = stream.readString()
deviceId = stream.readString()
let startPointer = stream.getReadPointerBits()
var parseErrors: [String] = []
do {
let parsed = try Self.parse(from: stream, includeIdentityFields: false)
if stream.hasRemainingBits() {
parseErrors.append("v2:trailing_bits:\(stream.remainingBits())")
} else {
apply(parsed)
isMalformed = false
malformedFingerprint = ""
return
}
} catch {
parseErrors.append("v2:\(Self.errorFingerprint(error))")
}
stream.setReadPointerBits(startPointer)
do {
let parsed = try Self.parse(from: stream, includeIdentityFields: true)
if stream.hasRemainingBits() {
parseErrors.append("v4:trailing_bits:\(stream.remainingBits())")
} else {
apply(parsed)
isMalformed = false
malformedFingerprint = ""
return
}
} catch {
parseErrors.append("v4:\(Self.errorFingerprint(error))")
}
markMalformed(
parseErrors.isEmpty
? "packet1b_parse_failed"
: parseErrors.joined(separator: "|")
)
}
private mutating func apply(_ parsed: ParsedPacketWebRTC) {
signalType = parsed.signalType
sdpOrCandidate = parsed.sdpOrCandidate
publicKey = parsed.publicKey
deviceId = parsed.deviceId
}
private mutating func markMalformed(_ fingerprint: String) {
signalType = .offer
sdpOrCandidate = ""
publicKey = ""
deviceId = ""
isMalformed = true
malformedFingerprint = fingerprint
}
private struct ParsedPacketWebRTC {
let signalType: WebRTCSignalType
let sdpOrCandidate: String
let publicKey: String
let deviceId: String
}
private enum PacketWebRTCParseError: Error {
case invalidSignalType(Int)
}
private static func parse(
from stream: Stream,
includeIdentityFields: Bool
) throws -> ParsedPacketWebRTC {
let rawSignalType = try stream.readInt8Strict()
guard let parsedSignalType = WebRTCSignalType(rawValue: rawSignalType) else {
throw PacketWebRTCParseError.invalidSignalType(rawSignalType)
}
let parsedSdpOrCandidate = try stream.readStringStrict()
let parsedPublicKey: String
let parsedDeviceId: String
if includeIdentityFields {
parsedPublicKey = try stream.readStringStrict()
parsedDeviceId = try stream.readStringStrict()
} else {
parsedPublicKey = ""
parsedDeviceId = ""
}
return ParsedPacketWebRTC(
signalType: parsedSignalType,
sdpOrCandidate: parsedSdpOrCandidate,
publicKey: parsedPublicKey,
deviceId: parsedDeviceId
)
}
private static func errorFingerprint(_ error: Error) -> String {
switch error {
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
return "underflow:\(operation):\(neededBits):\(remainingBits)"
case PacketBitStreamError.invalidStringLength(let length):
return "invalid_string_length:\(length)"
case PacketWebRTCParseError.invalidSignalType(let raw):
return "invalid_signal_type:\(raw)"
default:
return "parse_error"
}
}
}

View File

@@ -14,6 +14,18 @@ enum ConnectionState: String {
case authenticated
}
struct MalformedMessagePacketInfo: Sendable {
let packetSize: Int
let fingerprint: String
let messageIdHint: String
}
struct MalformedCriticalPacketInfo: Sendable {
let packetId: Int
let packetSize: Int
let fingerprint: String
}
// MARK: - ProtocolManager
/// Central networking coordinator. Owns WebSocket, routes packets, manages handshake.
@@ -58,6 +70,8 @@ final class ProtocolManager: @unchecked Sendable {
var onWebRTCReceived: ((PacketWebRTC) -> Void)?
var onIceServersReceived: ((PacketIceServers) -> Void)?
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
var onMalformedMessageReceived: ((MalformedMessagePacketInfo) -> Void)?
var onMalformedCriticalPacketReceived: ((MalformedCriticalPacketInfo) -> Void)?
// MARK: - Private
@@ -687,6 +701,15 @@ final class ProtocolManager: @unchecked Sendable {
switch packetId {
case 0x00:
if let p = packet as? PacketHandshake {
if p.isMalformed {
reportMalformedCriticalPacket(
packetId: packetId,
packetSize: data.count,
fingerprint: p.malformedFingerprint,
fallbackFingerprint: "packet00_parse_failed"
)
return
}
handleHandshakeResponse(p)
}
case 0x01:
@@ -712,6 +735,25 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x06:
if let p = packet as? PacketMessage {
if p.isMalformed {
let messageIdHint = p.messageId.isEmpty ? "-" : String(p.messageId.prefix(8))
let fingerprint = p.malformedFingerprint.isEmpty ? "packet06_parse_failed" : p.malformedFingerprint
reportMalformedCriticalPacket(
packetId: packetId,
packetSize: data.count,
fingerprint: fingerprint,
fallbackFingerprint: "packet06_parse_failed",
messageIdHint: messageIdHint
)
onMalformedMessageReceived?(
MalformedMessagePacketInfo(
packetSize: data.count,
fingerprint: fingerprint,
messageIdHint: messageIdHint
)
)
return
}
onMessageReceived?(p)
}
case 0x07:
@@ -781,6 +823,15 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x19:
if let p = packet as? PacketSync {
if p.isMalformed {
reportMalformedCriticalPacket(
packetId: packetId,
packetSize: data.count,
fingerprint: p.malformedFingerprint,
fallbackFingerprint: "packet19_parse_failed"
)
return
}
// Android parity: set sync flag SYNCHRONOUSLY on receive queue
// BEFORE dispatching to MainActor callback. This prevents the race
// where a 0x06 message Task runs on MainActor before BATCH_START Task.
@@ -797,11 +848,29 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x1A:
if let p = packet as? PacketSignalPeer {
if p.isMalformed {
reportMalformedCriticalPacket(
packetId: packetId,
packetSize: data.count,
fingerprint: p.malformedFingerprint,
fallbackFingerprint: "packet1a_parse_failed"
)
return
}
onSignalPeerReceived?(p)
notifySignalPeerHandlers(p)
}
case 0x1B:
if let p = packet as? PacketWebRTC {
if p.isMalformed {
reportMalformedCriticalPacket(
packetId: packetId,
packetSize: data.count,
fingerprint: p.malformedFingerprint,
fallbackFingerprint: "packet1b_parse_failed"
)
return
}
onWebRTCReceived?(p)
notifyWebRtcHandlers(p)
}
@@ -861,6 +930,44 @@ final class ProtocolManager: @unchecked Sendable {
}
}
private func reportMalformedCriticalPacket(
packetId: Int,
packetSize: Int,
fingerprint: String,
fallbackFingerprint: String,
messageIdHint: String? = nil
) {
let packetHex = String(format: "0x%02X", packetId)
let normalizedFingerprint = Self.compactFingerprint(
fingerprint.isEmpty ? fallbackFingerprint : fingerprint
)
if let messageIdHint {
Self.logger.error(
"Dropping malformed \(packetHex) packet size=\(packetSize) msgHint=\(messageIdHint) fp=\(normalizedFingerprint)"
)
} else {
Self.logger.error(
"Dropping malformed \(packetHex) packet size=\(packetSize) fp=\(normalizedFingerprint)"
)
}
onMalformedCriticalPacketReceived?(
MalformedCriticalPacketInfo(
packetId: packetId,
packetSize: packetSize,
fingerprint: normalizedFingerprint
)
)
}
private static func compactFingerprint(_ fingerprint: String) -> String {
let sanitized = fingerprint
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\t", with: " ")
guard sanitized.count > 120 else { return sanitized }
return String(sanitized.prefix(120))
}
private func handleHandshakeResponse(_ packet: PacketHandshake) {
handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = nil
@@ -1091,4 +1198,10 @@ final class ProtocolManager: @unchecked Sendable {
return nil
}
}
// MARK: - Test Support
func testHandleIncomingData(_ data: Data) {
handleIncomingData(data)
}
}

View File

@@ -2,6 +2,11 @@ import Foundation
typealias Stream = PacketBitStream
enum PacketBitStreamError: Error {
case underflow(operation: String, neededBits: Int, remainingBits: Int)
case invalidStringLength(Int)
}
/// Bit-aligned binary stream for protocol packets.
/// Matches the server (Java) implementation exactly.
///
@@ -37,6 +42,24 @@ final class PacketBitStream: NSObject {
return Data(bytes[0..<byteCount])
}
// MARK: - Pointer & State (Android/Desktop parity helpers)
func getReadPointerBits() -> Int {
readPointer
}
func setReadPointerBits(_ bits: Int) {
readPointer = min(max(bits, 0), writePointer)
}
func remainingBits() -> Int {
writePointer - readPointer
}
func hasRemainingBits() -> Bool {
readPointer < writePointer
}
// MARK: - Bit-Level I/O
func writeBit(_ value: Int) {
@@ -101,6 +124,18 @@ final class PacketBitStream: NSObject {
return readBits(8)
}
func readUInt8Strict() throws -> Int {
try ensureReadableBits(8, operation: "readUInt8")
if (readPointer & 7) == 0 {
let value = Int(bytes[readPointer >> 3])
readPointer += 8
return value
}
return try readBitsStrict(8)
}
func writeInt8(_ value: Int) {
writeUInt8(value)
}
@@ -109,6 +144,10 @@ final class PacketBitStream: NSObject {
Int(Int8(truncatingIfNeeded: readUInt8()))
}
func readInt8Strict() throws -> Int {
Int(Int8(truncatingIfNeeded: try readUInt8Strict()))
}
// MARK: - UInt16 / Int16 (16 bits)
func writeUInt16(_ value: Int) {
@@ -123,6 +162,12 @@ final class PacketBitStream: NSObject {
return (hi << 8) | lo
}
func readUInt16Strict() throws -> Int {
let hi = try readUInt8Strict()
let lo = try readUInt8Strict()
return (hi << 8) | lo
}
func writeInt16(_ value: Int) {
writeUInt16(value)
}
@@ -131,6 +176,10 @@ final class PacketBitStream: NSObject {
Int(Int16(truncatingIfNeeded: readUInt16()))
}
func readInt16Strict() throws -> Int {
Int(Int16(truncatingIfNeeded: try readUInt16Strict()))
}
// MARK: - UInt32 / Int32 (32 bits)
func writeUInt32(_ value: Int) {
@@ -148,6 +197,14 @@ final class PacketBitStream: NSObject {
return (b1 << 24) | (b2 << 16) | (b3 << 8) | b4
}
func readUInt32Strict() throws -> Int {
let b1 = try readUInt8Strict()
let b2 = try readUInt8Strict()
let b3 = try readUInt8Strict()
let b4 = try readUInt8Strict()
return (b1 << 24) | (b2 << 16) | (b3 << 8) | b4
}
func writeInt32(_ value: Int) {
writeUInt32(value)
}
@@ -156,6 +213,10 @@ final class PacketBitStream: NSObject {
Int(Int32(truncatingIfNeeded: readUInt32()))
}
func readInt32Strict() throws -> Int {
Int(Int32(truncatingIfNeeded: try readUInt32Strict()))
}
// MARK: - UInt64 / Int64 (64 bits)
func writeUInt64(_ value: Int64) {
@@ -175,6 +236,12 @@ final class PacketBitStream: NSObject {
return (high << 32) | low
}
func readUInt64Strict() throws -> Int64 {
let high = Int64(try readUInt32Strict()) & 0xFFFFFFFF
let low = Int64(try readUInt32Strict()) & 0xFFFFFFFF
return (high << 32) | low
}
func writeInt64(_ value: Int64) {
writeUInt64(value)
}
@@ -183,6 +250,10 @@ final class PacketBitStream: NSObject {
readUInt64()
}
func readInt64Strict() throws -> Int64 {
try readUInt64Strict()
}
// MARK: - Float32
func writeFloat32(_ value: Float) {
@@ -223,6 +294,24 @@ final class PacketBitStream: NSObject {
return String(decoding: codeUnits, as: UTF16.self)
}
func readStringStrict() throws -> String {
let length = try readUInt32Strict()
guard length > 0 else { return "" }
guard length <= Int(Int32.max) else {
throw PacketBitStreamError.invalidStringLength(length)
}
let requiredBits = length * 16
try ensureReadableBits(requiredBits, operation: "readString")
var codeUnits = [UInt16]()
codeUnits.reserveCapacity(length)
for _ in 0..<length {
codeUnits.append(UInt16(truncatingIfNeeded: try readUInt16Strict()))
}
return String(decoding: codeUnits, as: UTF16.self)
}
// MARK: - Bytes (UInt32 length + raw bytes)
func writeBytes(_ value: Data) {
@@ -272,10 +361,6 @@ final class PacketBitStream: NSObject {
// MARK: - Private
private func remainingBits() -> Int {
writePointer - readPointer
}
private func writeBits(_ value: Int, count: Int) {
guard count > 0 else { return }
ensureCapacityForUpcomingBits(count)
@@ -307,6 +392,31 @@ final class PacketBitStream: NSObject {
return value
}
private func readBitsStrict(_ count: Int) throws -> Int {
try ensureReadableBits(count, operation: "readBits")
var value = 0
for _ in 0..<count {
let byteIndex = readPointer >> 3
let shift = 7 - (readPointer & 7)
let bit = Int((bytes[byteIndex] >> shift) & 1)
value = (value << 1) | bit
readPointer += 1
}
return value
}
private func ensureReadableBits(_ needed: Int, operation: String) throws {
let remaining = remainingBits()
guard needed > 0 else { return }
guard remaining >= needed else {
throw PacketBitStreamError.underflow(
operation: operation,
neededBits: needed,
remainingBits: remaining
)
}
}
private func ensureCapacityForUpcomingBits(_ bitCount: Int) {
guard bitCount > 0 else { return }
let lastBitIndex = writePointer + bitCount - 1

View File

@@ -46,6 +46,11 @@ final class SessionManager {
private var hasTriggeredGroupRecoverySync = false
private var pendingIncomingMessages: [PacketMessage] = []
private var isProcessingIncomingMessages = false
/// Drop+resync recovery for malformed 0x06 packets.
private var malformedMessageResyncTask: Task<Void, Never>?
private var malformedMessageResyncQueued = false
private static let malformedMessageResyncDebounceNs: UInt64 = 350_000_000
private static let malformedMessageResyncRetryDelayNs: UInt64 = 200_000_000
/// Android parity: tracks the latest incoming message timestamp per dialog
/// for which a read receipt was already sent. Prevents redundant sends.
private var lastReadReceiptTimestamp: [String: Int64] = [:]
@@ -64,6 +69,10 @@ final class SessionManager {
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
private var pendingOutgoingPackets: [String: PacketMessage] = [:]
private var pendingOutgoingAttempts: [String: Int] = [:]
/// Guards against rapid duplicate push subscribe sends during reconnect storms.
private var lastPushTokenSubscribe: (token: String, sentAt: TimeInterval)?
/// Guards against rapid duplicate VoIP subscribe sends during reconnect storms.
private var lastVoIPTokenSubscribe: (token: String, sentAt: TimeInterval)?
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
@@ -93,6 +102,10 @@ final class SessionManager {
}
private var userInfoSearchHandlerToken: UUID?
#if DEBUG
private var malformedMessageResyncTestHook: (() -> Void)?
private(set) var malformedMessageResyncTriggerCount: Int = 0
#endif
private init() {
setupProtocolCallbacks()
@@ -754,6 +767,10 @@ final class SessionManager {
}
// Phase 2: Upload in background, then send packet
// Wrapped in do/catch: if CDN upload fails, mark message as .error
// so retryWaitingOutgoingMessagesAfterReconnect() doesn't pick it up
// and send a text-only packet (which causes empty messages on recipient).
do {
let flowTransport = attachmentFlowTransport
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
of: (Int, String, String).self
@@ -802,6 +819,19 @@ final class SessionManager {
}
MessageRepository.shared.persistNow()
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))")
} catch {
// CDN upload or packet send failed mark as .error to show failure to user.
// Note: retryWaitingOutgoingMessagesAfterReconnect() may still pick up .error
// messages within 80s, but the retry logic now checks for uploaded CDN tags
// and skips messages with placeholder-only attachments.
Self.logger.error("📤 CDN upload/send failed for \(messageId.prefix(8))…: \(error.localizedDescription)")
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId, opponentKey: optimisticDialogKey, status: .error
)
MessageRepository.shared.persistNow()
throw error
}
}
/// Builds a data URI from attachment data (desktop: `FileReader.readAsDataURL()`).
@@ -1209,8 +1239,6 @@ final class SessionManager {
/// Ends the session and disconnects.
func endSession() {
// Unsubscribe push tokens from server BEFORE disconnecting.
// Without this, old account's tokens stay registered server sends
// VoIP pushes for calls to this device even after account switch.
if let voipToken = UserDefaults.standard.string(forKey: "voip_push_token"),
!voipToken.isEmpty {
unsubscribeVoIPToken(voipToken)
@@ -1231,12 +1259,21 @@ final class SessionManager {
pendingOpponentReads.removeAll()
pendingIncomingMessages.removeAll()
isProcessingIncomingMessages = false
malformedMessageResyncTask?.cancel()
malformedMessageResyncTask = nil
malformedMessageResyncQueued = false
#if DEBUG
malformedMessageResyncTestHook = nil
malformedMessageResyncTriggerCount = 0
#endif
lastReadReceiptTimestamp.removeAll()
requestedUserInfoKeys.removeAll()
pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
pendingOutgoingRetryTasks.removeAll()
pendingOutgoingPackets.removeAll()
pendingOutgoingAttempts.removeAll()
lastPushTokenSubscribe = nil
lastVoIPTokenSubscribe = nil
isAuthenticated = false
currentPublicKey = ""
displayName = ""
@@ -1264,6 +1301,13 @@ final class SessionManager {
}
}
proto.onMalformedMessageReceived = { [weak self] info in
guard let self else { return }
Task { @MainActor [weak self] in
self?.handleMalformedMessagePacket(info)
}
}
proto.onDeliveryReceived = { [weak self] packet in
Task { @MainActor in
let opponentKey = MessageRepository.shared.dialogKey(forMessageId: packet.messageId)
@@ -1696,6 +1740,17 @@ final class SessionManager {
let isGroupDialog = context.kind == .group
let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)
if packet.content.isEmpty && packet.attachments.isEmpty {
Self.logger.warning("""
processIncoming: drop empty payload packet \
msgId=\(packet.messageId.prefix(8))\
from=\(packet.fromPublicKey.prefix(8))\
to=\(packet.toPublicKey.prefix(8))
""")
scheduleMessageRecoveryResync(messageId: packet.messageId, fingerprint: "empty_payload")
return
}
// Optimization: skip expensive crypto + upsert for incoming messages
// already stored in DB. Only outgoing messages need re-processing
// (sync may update delivery status from .waiting .delivered).
@@ -1714,19 +1769,8 @@ final class SessionManager {
)
}()
if isGroupDialog, groupKey == nil {
// Don't drop the message store with encrypted content for retry.
// Group key may arrive later (join confirmation, sync).
Self.logger.warning("processIncoming: group key not found for \(opponentKey) — storing fallback")
let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive
|| packet.fromPublicKey == myKey
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: myKey,
decryptedText: "",
fromSync: effectiveFromSync,
dialogIdentityOverride: opponentKey
)
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
Self.logger.warning("processIncoming: group key not found for \(opponentKey) — dropping and scheduling resync")
scheduleMessageRecoveryResync(messageId: packet.messageId, fingerprint: "group_key_missing")
return
}
@@ -1744,11 +1788,8 @@ final class SessionManager {
}.value
guard let cryptoResult else {
// Desktop/Android parity: NEVER drop a message on decrypt failure.
// Desktop and Android store encrypted content and retry decryption
// on load. iOS was the only platform that lost messages permanently.
Self.logger.warning("""
processIncoming: decrypt FAILED — storing fallback \
Self.logger.error("""
processIncoming: decrypt FAILED — dropping packet \
msgId=\(packet.messageId.prefix(8))\
from=\(packet.fromPublicKey.prefix(8))\
hasChachaKey=\(!packet.chachaKey.isEmpty) \
@@ -1756,22 +1797,27 @@ final class SessionManager {
contentLen=\(packet.content.count) \
isOwnMessage=\(fromMe)
""")
let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive || fromMe
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: myKey,
decryptedText: "",
fromSync: effectiveFromSync,
dialogIdentityOverride: opponentKey
)
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
scheduleMessageRecoveryResync(messageId: packet.messageId, fingerprint: "decrypt_failed")
return
}
let text = cryptoResult.text
let processedPacket = cryptoResult.processedPacket
let resolvedAttachmentPassword = cryptoResult.attachmentPassword
if text.isEmpty && processedPacket.attachments.isEmpty {
Self.logger.warning("""
processIncoming: drop post-decrypt empty payload \
msgId=\(packet.messageId.prefix(8))\
from=\(packet.fromPublicKey.prefix(8))\
to=\(packet.toPublicKey.prefix(8))
""")
scheduleMessageRecoveryResync(
messageId: packet.messageId,
fingerprint: "empty_decrypted_payload"
)
return
}
// For outgoing messages received from the server (sent by another device
// on the same account), treat as sync-equivalent so status = .delivered.
// Without this, real-time fromMe messages get .waiting timeout .error.
@@ -1925,6 +1971,73 @@ final class SessionManager {
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
}
private func handleMalformedMessagePacket(_ info: MalformedMessagePacketInfo) {
let fingerprint = info.fingerprint.isEmpty ? "packet06_parse_failed" : info.fingerprint
Self.logger.error("""
Dropping malformed 0x06 packet \
size=\(info.packetSize) \
msgHint=\(info.messageIdHint) \
fp=\(fingerprint)
""")
malformedMessageResyncQueued = true
malformedMessageResyncTask?.cancel()
scheduleMalformedMessageResyncFlush(afterNanoseconds: Self.malformedMessageResyncDebounceNs)
}
private func scheduleMessageRecoveryResync(messageId: String, fingerprint: String) {
let msgHint = messageId.isEmpty ? "-" : String(messageId.prefix(8))
handleMalformedMessagePacket(
MalformedMessagePacketInfo(
packetSize: 0,
fingerprint: fingerprint,
messageIdHint: msgHint
)
)
}
private func scheduleMalformedMessageResyncFlush(afterNanoseconds delay: UInt64) {
malformedMessageResyncTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: delay)
guard let self, !Task.isCancelled else { return }
self.malformedMessageResyncTask = nil
self.flushMalformedMessageResync()
}
}
private func flushMalformedMessageResync() {
guard malformedMessageResyncQueued else { return }
guard !currentPublicKey.isEmpty else {
malformedMessageResyncQueued = false
return
}
if syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive {
scheduleMalformedMessageResyncFlush(afterNanoseconds: Self.malformedMessageResyncRetryDelayNs)
return
}
if syncRequestInFlight {
scheduleMalformedMessageResyncFlush(afterNanoseconds: Self.malformedMessageResyncRetryDelayNs)
return
}
malformedMessageResyncQueued = false
let cursor = loadLastSyncTimestamp()
#if DEBUG
malformedMessageResyncTriggerCount += 1
if let hook = malformedMessageResyncTestHook {
hook()
return
}
#endif
Self.logger.warning("Malformed 0x06 recovery: requesting sync cursor=\(cursor)")
requestSynchronize(cursor: cursor)
}
private func requestSynchronize(cursor: Int64? = nil) {
// No connectionState guard: this method is only called from (1) handshake
// completion handler and (2) BATCH_END handler both inherently authenticated.
@@ -2121,10 +2234,14 @@ final class SessionManager {
guard let privateKeyHex else {
return nil
}
// Allow empty content for messages with attachments (photo-only, call, etc.).
// Normally content is always non-empty (XChaCha20 of "" still produces ciphertext),
// but buggy senders or edge cases may send empty content with valid attachments.
// Allow empty content only for attachment-only packets.
// Text messages with empty content and no attachments are treated as invalid
// and must not create empty bubbles in UI.
if packet.content.isEmpty {
guard !packet.attachments.isEmpty else {
Self.logger.warning("Rejecting packet with empty content and no attachments")
return nil
}
return ("", nil)
}
@@ -2321,6 +2438,75 @@ final class SessionManager {
packetFlowSender = LivePacketFlowSender()
}
static func testDecryptIncomingMessage(
packet: PacketMessage,
myPublicKey: String,
privateKeyHex: String?,
groupKey: String?
) -> (text: String, hasRawKeyData: Bool)? {
guard let result = decryptIncomingMessage(
packet: packet,
myPublicKey: myPublicKey,
privateKeyHex: privateKeyHex,
groupKey: groupKey
) else {
return nil
}
return (result.text, result.rawKeyData != nil)
}
#if DEBUG
static func testRecoverRetryPlaintext(storedText: String, privateKeyHex: String) -> String? {
recoverRetryPlaintext(storedText: storedText, privateKeyHex: privateKeyHex)
}
static func testRawKeyAndNonceFromStoredAttachmentPassword(_ stored: String) -> Data? {
rawKeyAndNonceFromStoredAttachmentPassword(stored)
}
func testSetMalformedMessageResyncHook(_ hook: (() -> Void)?) {
malformedMessageResyncTestHook = hook
}
func testSimulateMalformedMessagePacketDrop(
packetSize: Int = 0,
fingerprint: String = "test_packet06_malformed",
messageIdHint: String = "-"
) {
handleMalformedMessagePacket(
MalformedMessagePacketInfo(
packetSize: packetSize,
fingerprint: fingerprint,
messageIdHint: messageIdHint
)
)
}
func testResetMalformedMessageResyncState() {
malformedMessageResyncTask?.cancel()
malformedMessageResyncTask = nil
malformedMessageResyncQueued = false
malformedMessageResyncTestHook = nil
malformedMessageResyncTriggerCount = 0
}
func testSetSyncState(
syncRequestInFlight: Bool? = nil,
syncBatchInProgress: Bool? = nil
) {
if let syncRequestInFlight {
self.syncRequestInFlight = syncRequestInFlight
}
if let syncBatchInProgress {
self.syncBatchInProgress = syncBatchInProgress
}
}
func testProcessIncomingMessage(_ packet: PacketMessage) async {
await processIncomingMessage(packet)
}
#endif
/// Public convenience for views that need to trigger a user-info fetch.
func requestUserInfoIfNeeded(forKey publicKey: String) {
requestUserInfoIfNeeded(opponentKey: publicKey, privateKeyHash: privateKeyHash)
@@ -2548,8 +2734,41 @@ final class SessionManager {
continue
}
let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
// Decrypt stored text to recover original plaintext.
// DB stores encryptWithPassword(plaintext, privateKey) we must reverse this
// to avoid double-encrypting (makeOutgoingPacket encrypts with XChaCha20).
guard let plaintext = Self.recoverRetryPlaintext(
storedText: message.text,
privateKeyHex: privateKeyHex
) else {
markRetryMessageAsError(
message,
reason: "plaintext_unrecoverable"
)
continue
}
let uploadBackedAttachments = message.attachments.filter {
$0.type == .image || $0.type == .file || $0.type == .avatar
}
let uploadedAttachments = uploadBackedAttachments.filter {
!$0.effectiveDownloadTag.isEmpty
}
let hasUploadBackedAttachments = !uploadBackedAttachments.isEmpty
if hasUploadBackedAttachments,
uploadedAttachments.count != uploadBackedAttachments.count {
markRetryMessageAsError(
message,
reason: "missing_attachment_transport_tags"
)
continue
}
let hasPlaintext = !plaintext.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
guard hasUploadBackedAttachments || hasPlaintext else {
continue
}
// Update dialog delivery status back to .waiting (shows clock icon).
DialogRepository.shared.updateDeliveryStatus(
@@ -2563,17 +2782,75 @@ final class SessionManager {
// (Executor6Message maxPaddingSec=30). Original timestamp would fail validation
// if reconnect took >30s. Server overwrites timestamp with System.currentTimeMillis()
// anyway (Executor6Message:102), so client timestamp is only for age validation.
let freshTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
if hasUploadBackedAttachments {
// Retry WITH attachments using ORIGINAL key+nonce
// CDN blob was encrypted with hex(originalKeyAndNonce).
// We MUST reuse the SAME key+nonce so the recipient derives the
// SAME PBKDF2 password and can decrypt the blob from CDN.
guard let storedPwd = message.attachmentPassword,
let originalKeyAndNonce = Self.rawKeyAndNonceFromStoredAttachmentPassword(storedPwd)
else {
markRetryMessageAsError(
message,
reason: "attachment_key_unrecoverable"
)
continue
}
let key = Data(originalKeyAndNonce.prefix(32))
let nonce = Data(originalKeyAndNonce.subdata(in: 32..<56))
// XChaCha20-encrypt text with ORIGINAL key+nonce
let ciphertextWithTag = try XChaCha20Engine.encrypt(
plaintext: Data(plaintext.utf8), key: key, nonce: nonce
)
let content = ciphertextWithTag.hexString
// ECDH-encrypt ORIGINAL key+nonce for recipient (fresh ephemeral key)
let chachaKey = try MessageCrypto.encryptKeyForRecipient(
keyAndNonce: originalKeyAndNonce,
recipientPublicKeyHex: message.toPublicKey
)
// aesChachaKey for sync (same derivation as original send)
guard let latin1String = String(data: originalKeyAndNonce, encoding: .isoLatin1) else {
throw CryptoError.encryptionFailed
}
let aesChachaPayload = Data(latin1String.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload, password: privateKeyHex
)
var packet = PacketMessage()
packet.fromPublicKey = currentPublicKey
packet.toPublicKey = message.toPublicKey
packet.content = content
packet.chachaKey = chachaKey
packet.timestamp = freshTimestamp
packet.privateKey = privateKeyHash
packet.messageId = message.id
packet.attachments = uploadedAttachments
packet.aesChachaKey = aesChachaKey
ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet)
Self.logger.info("Retrying message+attachments \(message.id.prefix(8))… (\(uploadedAttachments.count) att) to \(message.toPublicKey.prefix(12))")
} else {
// Retry text-only (no upload-backed attachments)
let packet = try makeOutgoingPacket(
text: text,
text: plaintext,
toPublicKey: message.toPublicKey,
messageId: message.id,
timestamp: Int64(Date().timeIntervalSince1970 * 1000),
timestamp: freshTimestamp,
privateKeyHex: privateKeyHex,
privateKeyHash: privateKeyHash
)
ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet)
Self.logger.info("Retrying message \(message.id.prefix(8))… to \(message.toPublicKey.prefix(12))")
}
} catch {
Self.logger.error("Failed to retry message \(message.id): \(error.localizedDescription)")
MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error)
@@ -2586,6 +2863,80 @@ final class SessionManager {
}
}
private static func recoverRetryPlaintext(
storedText: String,
privateKeyHex: String
) -> String? {
if storedText.isEmpty { return "" }
// Try decryption with compression first (standard path), then without.
if let data = try? CryptoManager.shared.decryptWithPassword(
storedText,
password: privateKeyHex,
requireCompression: true
),
let decoded = String(data: data, encoding: .utf8) {
return decoded
}
if let data = try? CryptoManager.shared.decryptWithPassword(
storedText,
password: privateKeyHex
),
let decoded = String(data: data, encoding: .utf8) {
return decoded
}
// Legacy fallback: allow plain text only if it doesn't look like ciphertext.
if isLikelyEncryptedPayload(storedText) {
return nil
}
return storedText
}
private static func isLikelyEncryptedPayload(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed.hasPrefix("CHNK:") { return true }
let parts = trimmed.components(separatedBy: ":")
if parts.count == 2 {
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
if parts.allSatisfy({ part in
part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
}) {
return true
}
}
if trimmed.count >= 40 {
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) {
return true
}
}
return false
}
private static func rawKeyAndNonceFromStoredAttachmentPassword(_ stored: String) -> Data? {
guard stored.hasPrefix("rawkey:") else { return nil }
let hex = String(stored.dropFirst("rawkey:".count))
guard let decoded = Data(strictHexString: hex), decoded.count == 56 else {
return nil
}
return decoded
}
private func markRetryMessageAsError(_ message: ChatMessage, reason: String) {
Self.logger.error("Retry ABORT: \(reason) for message \(message.id.prefix(8))")
MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error)
DialogRepository.shared.updateDeliveryStatus(
messageId: message.id,
opponentKey: message.toPublicKey,
status: .error
)
}
private func registerOutgoingRetry(for packet: PacketMessage) {
let messageId = packet.messageId
pendingOutgoingRetryTasks[messageId]?.cancel()
@@ -2705,30 +3056,40 @@ final class SessionManager {
// MARK: - Push Notifications
/// Stores the APNs device token received from AppDelegate.
/// Called from AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken.
/// Stores the FCM registration token received from Firebase Messaging delegate.
func setAPNsToken(_ token: String) {
UserDefaults.standard.set(token, forKey: "apns_device_token")
let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedToken.isEmpty else { return }
UserDefaults.standard.set(normalizedToken, forKey: "apns_device_token")
// If already authenticated, send immediately
if ProtocolManager.shared.connectionState == .authenticated {
sendPushTokenToServer()
sendPushTokenToServer(force: true)
}
}
/// Sends the stored APNs push token to the server via PacketPushNotification (0x10).
/// Sends the stored FCM push token to the server via PacketPushNotification (0x10).
/// Android parity: called after successful handshake.
private func sendPushTokenToServer() {
private func sendPushTokenToServer(force: Bool = false) {
guard let token = UserDefaults.standard.string(forKey: "apns_device_token"),
!token.isEmpty,
ProtocolManager.shared.connectionState == .authenticated
else { return }
let now = Date().timeIntervalSince1970
if !force,
let lastPushTokenSubscribe,
lastPushTokenSubscribe.token == token,
now - lastPushTokenSubscribe.sentAt < 5 {
return
}
var packet = PacketPushNotification()
packet.notificationsToken = token
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
ProtocolManager.shared.sendPacket(packet)
lastPushTokenSubscribe = (token, now)
Self.logger.info("FCM push token sent to server")
}
@@ -2736,25 +3097,36 @@ final class SessionManager {
/// Stores the VoIP push token received from PushKit.
func setVoIPToken(_ token: String) {
UserDefaults.standard.set(token, forKey: "voip_push_token")
let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedToken.isEmpty else { return }
UserDefaults.standard.set(normalizedToken, forKey: "voip_push_token")
if ProtocolManager.shared.connectionState == .authenticated {
sendVoIPTokenToServer()
sendVoIPTokenToServer(force: true)
}
}
/// Sends the stored VoIP push token to the server via PacketPushNotification (0x10).
private func sendVoIPTokenToServer() {
private func sendVoIPTokenToServer(force: Bool = false) {
guard let token = UserDefaults.standard.string(forKey: "voip_push_token"),
!token.isEmpty,
ProtocolManager.shared.connectionState == .authenticated
else { return }
let now = Date().timeIntervalSince1970
if !force,
let lastVoIPTokenSubscribe,
lastVoIPTokenSubscribe.token == token,
now - lastVoIPTokenSubscribe.sentAt < 5 {
return
}
var packet = PacketPushNotification()
packet.notificationsToken = token
packet.action = .subscribe
packet.tokenType = .voipApns
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
ProtocolManager.shared.sendPacket(packet)
lastVoIPTokenSubscribe = (token, now)
Self.logger.info("VoIP push token sent to server")
}
@@ -2773,20 +3145,24 @@ final class SessionManager {
Self.logger.info("FCM token unsubscribed from server")
}
/// Sends unsubscribe for a stale VoIP token (called when PushKit invalidates token).
/// Sends unsubscribe for a stale VoIP token and clears local storage.
func unsubscribeVoIPToken(_ token: String) {
guard !token.isEmpty,
ProtocolManager.shared.connectionState == .authenticated
else { return }
let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedToken.isEmpty else { return }
if ProtocolManager.shared.connectionState == .authenticated {
var packet = PacketPushNotification()
packet.notificationsToken = token
packet.notificationsToken = normalizedToken
packet.action = .unsubscribe
packet.tokenType = .voipApns
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
ProtocolManager.shared.sendPacket(packet)
Self.logger.info("VoIP token unsubscribed from server")
}
UserDefaults.standard.removeObject(forKey: "voip_push_token")
if let lastVoIPTokenSubscribe, lastVoIPTokenSubscribe.token == normalizedToken {
self.lastVoIPTokenSubscribe = nil
}
}
// MARK: - Release Notes (Desktop Parity)

View File

@@ -8,6 +8,14 @@ import Foundation
/// - legacy/local-only: raw preview payload without `tag::` prefix
enum AttachmentPreviewCodec {
// Legacy iOS upload IDs were generated from [a-z0-9] with fixed length 8.
// We intentionally keep this strict to avoid stripping arbitrary prefixes
// from valid blurhash payloads that may contain "::".
private static let legacyTransportIdRegex = try! NSRegularExpression(
pattern: "^[a-z0-9]{8}$",
options: []
)
struct ParsedFilePreview: Equatable {
let downloadTag: String
let fileSize: Int
@@ -42,11 +50,25 @@ enum AttachmentPreviewCodec {
static func blurHash(from preview: String) -> String {
let raw = payload(from: preview)
// Strip trailing "|WxH" dimension suffix if present.
if let pipeIdx = raw.lastIndex(of: "|") {
return String(raw[raw.startIndex..<pipeIdx])
// Legacy compatibility: older iOS builds could embed an 8-char upload
// id in preview, e.g. "jbov1nac::blurhash". We strip only this known
// prefix shape to avoid corrupting valid blurhash values that may
// legitimately contain "::".
var candidate = raw
if let sep = raw.range(of: "::") {
let prefix = String(raw[..<sep.lowerBound])
let suffix = String(raw[sep.upperBound...])
if isLegacyTransportId(prefix), !suffix.isEmpty {
candidate = suffix
}
return raw
}
// Strip trailing "|WxH" dimension suffix if present.
if let pipeIdx = candidate.lastIndex(of: "|") {
return String(candidate[candidate.startIndex..<pipeIdx])
}
return candidate
}
/// Extract pixel dimensions encoded as `|WxH` suffix in image preview.
@@ -145,4 +167,10 @@ enum AttachmentPreviewCodec {
}
return value
}
private static func isLegacyTransportId(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let range = NSRange(trimmed.startIndex..<trimmed.endIndex, in: trimmed)
return legacyTransportIdRegex.firstMatch(in: trimmed, options: [], range: range) != nil
}
}

View File

@@ -11,23 +11,23 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Звонки — CallKit и PushKit**
Интеграция с CallKit: входящие звонки отображаются на экране блокировки и Dynamic Island. PushKit для мгновенной доставки вызовов. Аватарки в списке звонков. Live Activity на экране блокировки.
**Группы — карточки приглашений и аватарки**
Inline-карточка приглашения в группу (Desktop/Android parity). Имя и аватарка отправителя в групповых чатах. Multi-typer typing индикатор. Фикс пароля вложений hex→plain для совместимости с Android.
**Пересылка сообщений — Telegram-parity**
Полностью переработан UI пересылки: правильный размер бабла, аватарка отправителя с инициалами, корректные отступы текста и таймстампа. Пересылка без перезаливки файлов на CDN.
**Темизация — light/dark**
Circular reveal анимация переключения темы. Адаптивные цвета чата, контекстного меню, attachment picker и авторизации. Обои по теме.
**Чат — анимации и навигация**
Мгновенное появление отправленных сообщений (Telegram-parity spring). Разделители дат со sticky-поведением. Reply-to-reply с подсветкой и навигацией. Свайп-реплай с Telegram-эффектами. Корректное отображение эмодзи с Android/Desktop.
**Звонки — стабильность**
Фикс аудио в фоне: pre-configuration AudioSession перед CallKit (Telegram parity). Имя на CallKit/CarPlay. Устранение дублирования CallKit-вызовов. Disconnect recovery, WebRTC packet buffering, E2EE rebind loop.
**Пуш-уведомления**
Data-only пуши: мгновенная обработка прочтений, мут-проверка групп, имя отправителя из контактов.
**Пуш-уведомления — Telegram-parity**
In-app баннер вместо системного (glass overlay, звук, вибрация). Группировка по чатам (threadIdentifier). Letter-avatar в Notification Service Extension.
**Кроссплатформенный паритет**
10 фиксов совместимости: reply-бар, файлы, аватар, blurhash, per-attachment транспорт. Ограничения реплая/форварда синхронизированы с Desktop.
**Чат — layout и анимации**
Фикс перекрытия текста таймстампом в фото+caption сообщениях. Плавная анимация date pills при клавиатуре и вставке сообщений. Динамический пул date pills для длинных историй.
**Стабильность**
Фикс клавиатуры при свайп-бэк. Race condition свайп-минимизации call bar. Скролл реплай-сообщений под композер. Пустой чат: glass-подложка на iOS < 26.
**Дедупликация сообщений**
Трёхуровневая защита от дублей (queue + process + DB). Forward Picker UI parity.
"""
)
]

View File

@@ -106,7 +106,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
#if DEBUG
Messaging.messaging().setAPNSToken(deviceToken, type: .sandbox)
#else
Messaging.messaging().setAPNSToken(deviceToken, type: .prod)
#endif
}
// MARK: - Data-Only Push (Server parity: type/from/dialog fields)
@@ -181,15 +185,23 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// Check if the server already sent a visible alert (aps.alert exists).
let aps = userInfo["aps"] as? [String: Any]
let hasVisibleAlert = aps?["alert"] != nil
let hasMutableContent: Bool = {
if let intValue = aps?["mutable-content"] as? Int { return intValue == 1 }
if let numberValue = aps?["mutable-content"] as? NSNumber { return numberValue.intValue == 1 }
if let stringValue = aps?["mutable-content"] as? String {
return stringValue == "1" || stringValue.lowercased() == "true"
}
return false
}()
// Don't notify for muted chats.
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys")
?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
// If server sent visible alert, NSE handles sound+badge don't double-count.
// If server sent visible alert OR mutable-content, NSE handles sound+badge don't double-count.
// If muted, wake app but don't show notification (NSE also suppresses muted).
if hasVisibleAlert || isMuted {
if hasVisibleAlert || hasMutableContent || isMuted {
completionHandler(.newData)
return
}
@@ -553,19 +565,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
name: .openChatFromNotification,
object: route
)
// Clear all delivered notifications from this sender
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { notification in
let info = notification.request.content.userInfo
let key = Self.extractSenderKey(from: info)
return key == senderKey
}
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
}
}
// Do not bulk-clear here: if navigation fails or route expires,
// the user can lose unseen notifications. ChatDetailView clears
// this sender's notifications once the chat is actually opened.
}
completionHandler()

View File

@@ -16,6 +16,10 @@ final class NotificationService: UNNotificationServiceExtension {
private static let processedIdsKey = "nse_processed_message_ids"
/// Max dedup entries kept in App Group NSE has tight memory limits.
private static let maxProcessedIds = 100
/// Android parity: suppress duplicate notifications from the same sender
/// within a short window to avoid FCM burst duplicates.
private static let recentSenderNotificationTimestampsKey = "nse_recent_sender_notif_timestamps"
private static let senderDedupWindowSeconds: TimeInterval = 10
/// Tracks dialogs recently read on another device (e.g. Desktop).
/// When a READ push arrives, we store {dialogKey: timestamp}. Subsequent message
/// pushes for the same dialog within the window are suppressed the user is actively
@@ -140,6 +144,7 @@ final class NotificationService: UNNotificationServiceExtension {
// 2. Extract sender key server sends `dialog` field (was `from`).
let senderKey = content.userInfo["dialog"] as? String
?? Self.extractSenderKey(from: content.userInfo)
var shouldCollapseDuplicateBurst = false
// 3. Filter muted chats BEFORE badge increment muted chats must not inflate badge.
if let shared {
@@ -170,13 +175,19 @@ final class NotificationService: UNNotificationServiceExtension {
}
}
// 3.5 Dedup: skip badge increment if we already processed this push.
// Protects against duplicate FCM delivery (rare, but server dedup window is ~10s).
// 3.5 Dedup:
// - messageId duplicate: keep badge unchanged (exact duplicate delivery)
// - sender-window duplicate: collapse UI to latest notification, but keep
// badge increment so unread-message count is preserved.
let messageId = content.userInfo["message_id"] as? String
?? content.userInfo["messageId"] as? String
?? request.identifier
var processedIds = shared.stringArray(forKey: Self.processedIdsKey) ?? []
if processedIds.contains(messageId) {
let isSenderWindowDuplicate = Self.isSenderWindowDuplicate(senderKey: senderKey, shared: shared)
let isMessageIdDuplicate = processedIds.contains(messageId)
shouldCollapseDuplicateBurst = isSenderWindowDuplicate || isMessageIdDuplicate
if isMessageIdDuplicate {
// Already counted show notification but don't inflate badge.
let currentBadge = shared.integer(forKey: Self.badgeKey)
content.badge = NSNumber(value: currentBadge)
@@ -235,6 +246,15 @@ final class NotificationService: UNNotificationServiceExtension {
senderKey: senderKey
)
// Android parity: for duplicate bursts, keep only the latest notification
// for this sender instead of stacking multiple identical entries.
if !senderKey.isEmpty, shouldCollapseDuplicateBurst {
Self.replaceDeliveredNotifications(for: senderKey) {
contentHandler(finalContent)
}
return
}
contentHandler(finalContent)
}
@@ -245,6 +265,64 @@ final class NotificationService: UNNotificationServiceExtension {
}
}
// MARK: - Sender Dedup Helpers
/// Returns true if the sender has a recent notification in the dedup window.
/// When `shouldRecord` is true, records current timestamp for non-duplicates.
private static func isSenderWindowDuplicate(
senderKey: String,
shared: UserDefaults,
shouldRecord: Bool = true
) -> Bool {
let dedupKey = senderKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "__no_sender__"
: senderKey.trimmingCharacters(in: .whitespacesAndNewlines)
let now = Date().timeIntervalSince1970
var timestamps = shared.dictionary(forKey: recentSenderNotificationTimestampsKey) as? [String: Double] ?? [:]
// Keep the map compact under NSE memory constraints.
timestamps = timestamps.filter { now - $0.value < 120 }
if let last = timestamps[dedupKey], now - last < senderDedupWindowSeconds {
shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey)
return true
}
if shouldRecord {
timestamps[dedupKey] = now
shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey)
} else {
shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey)
}
return false
}
/// Removes delivered notifications for this sender so duplicate bursts collapse
/// into a single latest entry (Android parity).
private static func replaceDeliveredNotifications(
for senderKey: String,
then completion: @escaping () -> Void
) {
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { notification in
let info = notification.request.content.userInfo
let infoSender = info["sender_public_key"] as? String
?? info["dialog"] as? String
?? ""
return infoSender == senderKey || notification.request.content.threadIdentifier == senderKey
}
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
}
completion()
}
}
// MARK: - Communication Notification (CarPlay + Focus)
/// Wraps the notification content with an INSendMessageIntent so iOS treats it
@@ -259,7 +337,8 @@ final class NotificationService: UNNotificationServiceExtension {
) -> UNNotificationContent {
let handle = INPersonHandle(value: senderKey, type: .unknown)
let displayName = senderName.isEmpty ? "Rosetta" : senderName
let avatarImage = generateLetterAvatar(name: displayName, key: senderKey)
let avatarImage = loadNotificationAvatar(for: senderKey)
?? generateLetterAvatar(name: displayName, key: senderKey)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
@@ -280,7 +359,7 @@ final class NotificationService: UNNotificationServiceExtension {
attachments: nil
)
// Set avatar on sender parameter (Telegram parity: 50x50 letter avatar).
// Set avatar on sender parameter (prefer real avatar from App Group, fallback to letter avatar).
if let avatarImage {
intent.setImage(avatarImage, forParameterNamed: \.sender)
}
@@ -364,6 +443,64 @@ final class NotificationService: UNNotificationServiceExtension {
return INImage(imageData: pngData)
}
/// Loads sender avatar from shared App Group cache written by the main app.
/// Falls back to letter avatar when no real image is available.
private static func loadNotificationAvatar(for senderKey: String) -> INImage? {
guard let appGroupURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: Self.appGroupID
) else {
return nil
}
let avatarsDir = appGroupURL.appendingPathComponent("NotificationAvatars", isDirectory: true)
for candidate in avatarKeyCandidates(for: senderKey) {
let normalized = normalizedAvatarKey(candidate)
guard !normalized.isEmpty else { continue }
let avatarURL = avatarsDir.appendingPathComponent("\(normalized).jpg")
if let data = try? Data(contentsOf: avatarURL), !data.isEmpty {
return INImage(imageData: data)
}
}
return nil
}
/// Server may send group dialog key in different forms (`raw`, `#group:raw`, `group:raw`).
/// Probe all variants so NSE can find avatar mirrored by the main app.
private static func avatarKeyCandidates(for senderKey: String) -> [String] {
let trimmed = senderKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
var candidates: [String] = [trimmed]
let lower = trimmed.lowercased()
if lower.hasPrefix("#group:") {
let raw = String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
if !raw.isEmpty {
candidates.append(raw)
candidates.append("group:\(raw)")
}
} else if lower.hasPrefix("group:") {
let raw = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
if !raw.isEmpty {
candidates.append(raw)
candidates.append("#group:\(raw)")
}
} else if !trimmed.isEmpty {
candidates.append("#group:\(trimmed)")
candidates.append("group:\(trimmed)")
}
// Keep first-seen order and drop duplicates.
var seen = Set<String>()
return candidates.filter { seen.insert($0).inserted }
}
private static func normalizedAvatarKey(_ key: String) -> String {
key
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "0x", with: "")
.lowercased()
}
// MARK: - Helpers
/// Android parity: extract sender key from multiple possible key names.

View File

@@ -99,14 +99,16 @@ final class AttachmentParityTests: XCTestCase {
XCTFail("Missing image attachment in packet")
return
}
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), imageTag)
XCTAssertEqual(sentImage.transportTag, imageTag)
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), "")
guard let sentFile = sent.attachments.first(where: { $0.id == fileAttachment.id }) else {
XCTFail("Missing file attachment in packet")
return
}
XCTAssertEqual(sentFile.transportTag, fileTag)
let parsedFile = AttachmentPreviewCodec.parseFilePreview(sentFile.preview)
XCTAssertEqual(parsedFile.downloadTag, fileTag)
XCTAssertEqual(parsedFile.downloadTag, "")
XCTAssertEqual(parsedFile.fileSize, fileData.count)
XCTAssertEqual(parsedFile.fileName, "notes.txt")
}

View File

@@ -5,36 +5,37 @@ import Testing
struct PushNotificationExtendedTests {
@Test("Realistic FCM token with device ID round-trip")
func fcmTokenWithDeviceIdRoundTrip() throws {
@Test("Realistic FCM token round-trip")
func fcmTokenRoundTrip() throws {
// Real FCM tokens are ~163 chars
let fcmToken = "dQw4w9WgXcQ:APA91bHnzPc5Y0z4R8kP3mN6vX2tL7wJ1qA5sD8fG0hK3lZ9xC2vB4nM7oP1iU8yT6rE5wQ3jF4kL2mN0bV7cX9sD1aF3gH5jK7lP9oI2uY4tR6eW8qZ0xC"
var packet = PacketPushNotification()
packet.notificationsToken = fcmToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"
packet.deviceId = "ios-fcm-device"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == fcmToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop")
#expect(decoded.deviceId == "ios-fcm-device")
}
@Test("Realistic VoIP hex token round-trip")
func voipTokenWithDeviceIdRoundTrip() throws {
// PushKit tokens are 32 bytes = 64 hex chars
let voipToken = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
@Test("Realistic APNs hex token round-trip")
func apnsTokenRoundTrip() throws {
let apnsToken = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
var packet = PacketPushNotification()
packet.notificationsToken = voipToken
packet.notificationsToken = apnsToken
packet.action = .subscribe
packet.tokenType = .voipApns
packet.deviceId = "device-xyz-123"
packet.deviceId = "ios-voip-device"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == voipToken)
#expect(decoded.notificationsToken == apnsToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "ios-voip-device")
}
@Test("Long token (256 chars) round-trip — stress test UInt32 string length")
@@ -44,38 +45,43 @@ struct PushNotificationExtendedTests {
packet.notificationsToken = longToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "dev"
packet.deviceId = "ios-long-device"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == longToken)
#expect(decoded.notificationsToken.count == 256)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ios-long-device")
}
@Test("Unicode device ID with emoji and Cyrillic round-trip")
func unicodeDeviceIdRoundTrip() throws {
let unicodeId = "Телефон Гайдара 📱"
@Test("Unicode token round-trip")
func unicodeTokenRoundTrip() throws {
let unicodeToken = "Токен-Гайдара-📱"
var packet = PacketPushNotification()
packet.notificationsToken = "token"
packet.notificationsToken = unicodeToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = unicodeId
packet.deviceId = "ios-unicode-device"
let decoded = try decode(packet)
#expect(decoded.deviceId == unicodeId)
#expect(decoded.notificationsToken == unicodeToken)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ios-unicode-device")
}
@Test("Unsubscribe action round-trip for both token types",
arguments: [PushTokenType.fcm, PushTokenType.voipApns])
func unsubscribeRoundTrip(tokenType: PushTokenType) throws {
@Test("Unsubscribe action round-trip")
func unsubscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "test-token"
packet.action = .unsubscribe
packet.tokenType = tokenType
packet.deviceId = "dev"
packet.tokenType = .voipApns
packet.deviceId = "ios-unsub-device"
let decoded = try decode(packet)
#expect(decoded.action == .unsubscribe)
#expect(decoded.tokenType == tokenType)
#expect(decoded.notificationsToken == "test-token")
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "ios-unsub-device")
}
private func decode(_ packet: PacketPushNotification) throws -> PacketPushNotification {
@@ -200,11 +206,6 @@ struct CallPushEnumParityTests {
#expect(pair.0.rawValue == pair.1)
}
@Test("PushTokenType enum values match server")
func pushTokenTypeValues() {
#expect(PushTokenType.fcm.rawValue == 0)
#expect(PushTokenType.voipApns.rawValue == 1)
}
}
// MARK: - Wire Format Byte-Level Tests
@@ -216,8 +217,8 @@ struct CallPushWireFormatTests {
var packet = PacketPushNotification()
packet.notificationsToken = "A"
packet.action = .unsubscribe
packet.tokenType = .fcm
packet.deviceId = "B"
packet.tokenType = .voipApns
packet.deviceId = "D"
let data = PacketRegistry.encode(packet)
#expect(data.count == 16)
@@ -230,12 +231,12 @@ struct CallPushWireFormatTests {
#expect(data[6] == 0x00); #expect(data[7] == 0x41)
// action = 1 (unsubscribe)
#expect(data[8] == 0x01)
// tokenType = 0 (fcm)
#expect(data[9] == 0x00)
// deviceId "B": length=1, 'B'=0x0042
// tokenType = 1 (voipApns)
#expect(data[9] == 0x01)
// deviceId "D": length=1, 'D'=0x0044
#expect(data[10] == 0x00); #expect(data[11] == 0x00)
#expect(data[12] == 0x00); #expect(data[13] == 0x01)
#expect(data[14] == 0x00); #expect(data[15] == 0x42)
#expect(data[14] == 0x00); #expect(data[15] == 0x44)
}
@Test("SignalPeer call byte layout: signalType→src→dst→callId→joinToken")

View File

@@ -1,9 +1,12 @@
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
@@ -228,16 +231,23 @@ final class CryptoParityTests: XCTestCase {
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(
Data(latin1.utf8), password: privateKeyHex
plaintext, password: privateKeyHex
)
XCTAssertThrowsError(
try CryptoManager.shared.decryptWithPassword(
do {
let decrypted = try CryptoManager.shared.decryptWithPassword(
encrypted, password: wrongKeyHex, requireCompression: true
),
"Decryption with wrong password must fail"
)
XCTAssertNotEqual(
decrypted,
plaintext,
"Wrong password must never recover original plaintext"
)
} catch {
// Expected path for the majority of wrong-password attempts.
}
}
// MARK: - Attachment Password Candidates
@@ -273,8 +283,9 @@ final class CryptoParityTests: XCTestCase {
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")
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() {
@@ -292,7 +303,7 @@ final class CryptoParityTests: XCTestCase {
let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored)
// Desktop: Buffer.from(keyBytes).toString('hex')
let expectedDesktopPassword = "deadbeefcafebabe01020304050607080910111213141516171819202122232425262728292a2b2c2d2e2f30"
let expectedDesktopPassword = "deadbeefcafebabe0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30"
// Verify hex format matches (lowercase, no separators)
XCTAssertTrue(candidates[0].allSatisfy { "0123456789abcdef".contains($0) },
@@ -301,6 +312,7 @@ final class CryptoParityTests: XCTestCase {
// 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
@@ -319,7 +331,7 @@ final class CryptoParityTests: XCTestCase {
XCTAssertNotNil(key1)
XCTAssertNotNil(key2)
XCTAssertEqual(key1, key2, "PBKDF2 must be deterministic")
XCTAssertEqual(key1!.count, 32, "PBKDF2 key must be 32 bytes")
XCTAssertEqual(key1.count, 32, "PBKDF2 key must be 32 bytes")
}
func testPBKDF2_differentPasswordsDifferentKeys() throws {
@@ -378,12 +390,21 @@ final class CryptoParityTests: XCTestCase {
Data("secret".utf8), password: "correct_password"
)
XCTAssertThrowsError(
try CryptoManager.shared.decryptWithPassword(
encrypted, password: "wrong_password", requireCompression: true
),
"Wrong password with requireCompression must fail"
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)
@@ -395,9 +416,11 @@ final class CryptoParityTests: XCTestCase {
}
func testAndroidUtf8Decoder_validMultibyte() {
let bytes = Data("Привет 🔐".utf8)
// 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")
XCTAssertEqual(result, "Привет мир", "Valid UTF-8 must decode identically")
}
func testAndroidUtf8Decoder_matchesWhatWG_onValidUtf8() {
@@ -567,6 +590,112 @@ final class CryptoParityTests: XCTestCase {
"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 {
@@ -613,12 +742,3 @@ final class CryptoParityTests: XCTestCase {
}
}
}
// MARK: - Test Helpers
extension MessageRepository {
/// Exposes isProbablyEncryptedPayload for testing.
static func testIsProbablyEncrypted(_ value: String) -> Bool {
isProbablyEncryptedPayload(value)
}
}

View File

@@ -107,6 +107,26 @@ final class FileAttachmentTests: XCTestCase {
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
}
func testBlurHash_LegacyNonUUIDTagPrefix() {
let preview = "jbov1nac::LVRv{GtRSXWB"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtRSXWB")
}
func testBlurHash_LegacyNonUUIDTagWithDimensions() {
let preview = "jbov1nac::LVRv{GtR|640x480"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
}
func testBlurHash_DoesNotStripUnknownNonUUIDPrefix() {
let preview = "legacy_upload_id::LVRv{GtRSXWB"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "legacy_upload_id::LVRv{GtRSXWB")
}
func testBlurHash_LegacyNonUUIDTagWithEmptySuffix() {
let preview = "jbov1nac::"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "")
}
// =========================================================================
// MARK: - AttachmentPreviewCodec: Image Dimensions
// =========================================================================

View File

@@ -0,0 +1,451 @@
import XCTest
import P256K
@testable import Rosetta
@MainActor
final class MessageDecodeHardeningTests: XCTestCase {
func testProtocolManagerDropsMalformedMessagePacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnMessage = proto.onMessageReceived
let originalOnMalformed = proto.onMalformedMessageReceived
defer {
proto.onMessageReceived = originalOnMessage
proto.onMalformedMessageReceived = originalOnMalformed
}
var messageDispatchCount = 0
var malformedCount = 0
proto.onMessageReceived = { _ in
messageDispatchCount += 1
}
proto.onMalformedMessageReceived = { _ in
malformedCount += 1
}
let malformedData = makePacketMessageData(metaFieldCount: 2) + Data([0x00])
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(messageDispatchCount, 0)
XCTAssertEqual(malformedCount, 1)
}
func testProtocolManagerStillDispatchesValidMessagePacket() {
let proto = ProtocolManager.shared
let originalOnMessage = proto.onMessageReceived
let originalOnMalformed = proto.onMalformedMessageReceived
defer {
proto.onMessageReceived = originalOnMessage
proto.onMalformedMessageReceived = originalOnMalformed
}
var messageDispatchCount = 0
var malformedCount = 0
proto.onMessageReceived = { _ in
messageDispatchCount += 1
}
proto.onMalformedMessageReceived = { _ in
malformedCount += 1
}
let validData = makePacketMessageData(metaFieldCount: 2)
proto.testHandleIncomingData(validData)
XCTAssertEqual(messageDispatchCount, 1)
XCTAssertEqual(malformedCount, 0)
}
func testProtocolManagerDropsMalformedHandshakePacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnHandshake = proto.onHandshakeCompleted
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onHandshakeCompleted = originalOnHandshake
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var handshakeDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onHandshakeCompleted = { _ in
handshakeDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
let malformedData = makeHandshakeData(stateRaw: 9)
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(handshakeDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketHandshake.packetId)
}
func testProtocolManagerDropsMalformedSyncPacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnSync = proto.onSyncReceived
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onSyncReceived = originalOnSync
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var syncDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onSyncReceived = { _ in
syncDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
let malformedData = makeSyncData(statusRaw: 7)
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(syncDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketSync.packetId)
}
func testProtocolManagerDropsMalformedSignalPacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnSignal = proto.onSignalPeerReceived
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onSignalPeerReceived = originalOnSignal
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var signalDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onSignalPeerReceived = { _ in
signalDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
var signal = PacketSignalPeer()
signal.signalType = .endCallBecauseBusy
let malformedData = PacketRegistry.encode(signal) + Data([0x00])
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(signalDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketSignalPeer.packetId)
}
func testProtocolManagerDropsMalformedWebRtcPacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnWebRtc = proto.onWebRTCReceived
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onWebRTCReceived = originalOnWebRtc
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var webRtcDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onWebRTCReceived = { _ in
webRtcDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
let malformedData = makeWebRtcData(includeIdentity: false) + Data([0x00])
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(webRtcDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketWebRTC.packetId)
}
func testSignalTypeCreateRoomDecodesWithAndWithoutRoomId() throws {
let withoutRoomData = makeSignalCreateRoomData(roomId: nil)
guard let withoutRoomDecoded = PacketRegistry.decode(from: withoutRoomData),
let withoutRoomPacket = withoutRoomDecoded.packet as? PacketSignalPeer
else {
XCTFail("Failed to decode signalType=4 packet without roomId")
return
}
XCTAssertEqual(withoutRoomDecoded.packetId, PacketSignalPeer.packetId)
XCTAssertFalse(withoutRoomPacket.isMalformed)
XCTAssertEqual(withoutRoomPacket.signalType, .createRoom)
XCTAssertEqual(withoutRoomPacket.roomId, "")
let withRoomData = makeSignalCreateRoomData(roomId: "room-42")
guard let withRoomDecoded = PacketRegistry.decode(from: withRoomData),
let withRoomPacket = withRoomDecoded.packet as? PacketSignalPeer
else {
XCTFail("Failed to decode signalType=4 packet with roomId")
return
}
XCTAssertEqual(withRoomDecoded.packetId, PacketSignalPeer.packetId)
XCTAssertFalse(withRoomPacket.isMalformed)
XCTAssertEqual(withRoomPacket.signalType, .createRoom)
XCTAssertEqual(withRoomPacket.roomId, "room-42")
}
func testWebRtcDecodeSupportsTwoAndFourFieldLayouts() throws {
let twoFieldData = makeWebRtcData(includeIdentity: false)
guard let decodedTwoField = PacketRegistry.decode(from: twoFieldData),
let twoFieldPacket = decodedTwoField.packet as? PacketWebRTC
else {
XCTFail("Failed to decode canonical 2-field 0x1B packet")
return
}
XCTAssertEqual(decodedTwoField.packetId, PacketWebRTC.packetId)
XCTAssertFalse(twoFieldPacket.isMalformed)
XCTAssertEqual(twoFieldPacket.signalType, .offer)
XCTAssertEqual(twoFieldPacket.sdpOrCandidate, "{\"type\":\"offer\",\"sdp\":\"v=0\"}")
XCTAssertEqual(twoFieldPacket.publicKey, "")
XCTAssertEqual(twoFieldPacket.deviceId, "")
let fourFieldData = makeWebRtcData(
includeIdentity: true,
publicKey: "02legacyPublic",
deviceId: "legacy-device"
)
guard let decodedFourField = PacketRegistry.decode(from: fourFieldData),
let fourFieldPacket = decodedFourField.packet as? PacketWebRTC
else {
XCTFail("Failed to decode legacy 4-field 0x1B packet")
return
}
XCTAssertEqual(decodedFourField.packetId, PacketWebRTC.packetId)
XCTAssertFalse(fourFieldPacket.isMalformed)
XCTAssertEqual(fourFieldPacket.signalType, .offer)
XCTAssertEqual(fourFieldPacket.sdpOrCandidate, "{\"type\":\"offer\",\"sdp\":\"v=0\"}")
XCTAssertEqual(fourFieldPacket.publicKey, "02legacyPublic")
XCTAssertEqual(fourFieldPacket.deviceId, "legacy-device")
}
func testWebRtcEncodeUsesCanonicalTwoFieldLayout() {
var packet = PacketWebRTC()
packet.signalType = .answer
packet.sdpOrCandidate = "{\"type\":\"answer\",\"sdp\":\"v=0\"}"
packet.publicKey = "02shouldNotBeEncoded"
packet.deviceId = "device-should-not-be-encoded"
let encoded = PacketRegistry.encode(packet)
let stream = Rosetta.Stream(data: encoded)
XCTAssertEqual(stream.readInt16(), PacketWebRTC.packetId)
XCTAssertEqual(stream.readInt8(), WebRTCSignalType.answer.rawValue)
XCTAssertEqual(stream.readString(), packet.sdpOrCandidate)
XCTAssertFalse(stream.hasRemainingBits())
}
func testMalformedPacketRecoveryDebouncesResyncToSingleRequest() async throws {
let session = SessionManager.shared
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
session.testConfigureSessionForParityFlows(
currentPublicKey: "02malformed_resync_test",
privateKeyHex: privateKeyHex
)
session.testResetMalformedMessageResyncState()
defer {
session.testSetMalformedMessageResyncHook(nil)
session.testResetMalformedMessageResyncState()
}
var triggerCount = 0
session.testSetMalformedMessageResyncHook {
triggerCount += 1
}
session.testSimulateMalformedMessagePacketDrop(packetSize: 111, fingerprint: "fp-1", messageIdHint: "m1")
session.testSimulateMalformedMessagePacketDrop(packetSize: 112, fingerprint: "fp-2", messageIdHint: "m2")
session.testSimulateMalformedMessagePacketDrop(packetSize: 113, fingerprint: "fp-3", messageIdHint: "m3")
try await Task.sleep(nanoseconds: 900_000_000)
XCTAssertEqual(triggerCount, 1)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 1)
}
func testMalformedPacketRecoveryWaitsUntilSyncBatchEnds() async throws {
let session = SessionManager.shared
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
session.testConfigureSessionForParityFlows(
currentPublicKey: "02malformed_resync_batch_test",
privateKeyHex: privateKeyHex
)
session.testResetMalformedMessageResyncState()
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: true)
defer {
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: false)
session.testSetMalformedMessageResyncHook(nil)
session.testResetMalformedMessageResyncState()
}
var triggerCount = 0
session.testSetMalformedMessageResyncHook {
triggerCount += 1
}
session.testSimulateMalformedMessagePacketDrop(packetSize: 211, fingerprint: "fp-batch", messageIdHint: "m-batch")
// While sync is active, recovery must remain queued and not fire.
try await Task.sleep(nanoseconds: 900_000_000)
XCTAssertEqual(triggerCount, 0)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 0)
// After sync ends, queued recovery should fire once.
session.testSetSyncState(syncBatchInProgress: false)
try await Task.sleep(nanoseconds: 700_000_000)
XCTAssertEqual(triggerCount, 1)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 1)
}
func testPostDecryptEmptyPayloadDropsWithoutUpsertAndDebouncesResync() async throws {
let session = SessionManager.shared
let myPrivateKey = try P256K.KeyAgreement.PrivateKey()
let myPrivateKeyData = myPrivateKey.rawRepresentation
let myPrivateKeyHex = myPrivateKeyData.hexString
let myPublicKey = try CryptoManager.shared.deriveCompressedPublicKey(from: myPrivateKeyData).hexString
try DatabaseManager.shared.bootstrap(accountPublicKey: myPublicKey)
await MessageRepository.shared.bootstrap(accountPublicKey: myPublicKey, storagePassword: myPrivateKeyHex)
await DialogRepository.shared.bootstrap(accountPublicKey: myPublicKey, storagePassword: myPrivateKeyHex)
session.testConfigureSessionForParityFlows(
currentPublicKey: myPublicKey,
privateKeyHex: myPrivateKeyHex
)
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: false)
session.testResetMalformedMessageResyncState()
defer {
session.testSetMalformedMessageResyncHook(nil)
session.testResetMalformedMessageResyncState()
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: false)
}
var triggerCount = 0
session.testSetMalformedMessageResyncHook {
triggerCount += 1
}
let peerPrivateKey = try P256K.KeyAgreement.PrivateKey()
let peerPublicKey = try CryptoManager.shared
.deriveCompressedPublicKey(from: peerPrivateKey.rawRepresentation)
.hexString
var processedMessageIds: [String] = []
for index in 0..<3 {
let encrypted = try MessageCrypto.encryptOutgoing(
plaintext: "",
recipientPublicKeyHex: myPublicKey
)
var packet = PacketMessage()
packet.fromPublicKey = peerPublicKey
packet.toPublicKey = myPublicKey
packet.content = encrypted.content
packet.chachaKey = encrypted.chachaKey
packet.timestamp = 1_710_000_000_000 + Int64(index)
packet.privateKey = "hash"
packet.messageId = "empty-decrypted-\(index)-\(UUID().uuidString)"
packet.attachments = []
packet.aesChachaKey = ""
processedMessageIds.append(packet.messageId)
await session.testProcessIncomingMessage(packet)
}
try await Task.sleep(nanoseconds: 900_000_000)
XCTAssertEqual(triggerCount, 1)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 1)
for messageId in processedMessageIds {
XCTAssertFalse(
MessageRepository.shared.hasMessage(messageId),
"Post-decrypt empty payload must not be persisted as a bubble"
)
}
}
private func makePacketMessageData(metaFieldCount: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketMessage.packetId)
stream.writeString("02from")
stream.writeString("02to")
stream.writeString("ciphertext")
stream.writeString("chacha-key")
stream.writeInt64(1_710_000_000_000)
stream.writeString("hash")
stream.writeString("msg-hardening")
stream.writeInt8(1)
stream.writeString("att-1")
stream.writeString("preview")
stream.writeString("blob")
stream.writeInt8(AttachmentType.image.rawValue)
if metaFieldCount >= 2 {
stream.writeString("tag-1")
stream.writeString("cdn.rosetta.im")
}
if metaFieldCount >= 4 {
stream.writeString("02encoded-for")
stream.writeString("desktop")
}
stream.writeString("aes-key")
return stream.toData()
}
private func makeHandshakeData(stateRaw: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketHandshake.packetId)
stream.writeString("hash")
stream.writeString("02public")
stream.writeInt8(1)
stream.writeInt8(15)
stream.writeString("device-id")
stream.writeString("iPhone")
stream.writeString("iOS")
stream.writeInt8(stateRaw)
return stream.toData()
}
private func makeSyncData(statusRaw: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketSync.packetId)
stream.writeInt8(statusRaw)
stream.writeInt64(1_710_000_000_000)
return stream.toData()
}
private func makeSignalCreateRoomData(roomId: String?) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketSignalPeer.packetId)
stream.writeInt8(SignalType.createRoom.rawValue)
stream.writeString("02src")
stream.writeString("02dst")
if let roomId {
stream.writeString(roomId)
}
return stream.toData()
}
private func makeWebRtcData(
includeIdentity: Bool,
publicKey: String = "",
deviceId: String = ""
) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketWebRTC.packetId)
stream.writeInt8(WebRTCSignalType.offer.rawValue)
stream.writeString("{\"type\":\"offer\",\"sdp\":\"v=0\"}")
if includeIdentity {
stream.writeString(publicKey)
stream.writeString(deviceId)
}
return stream.toData()
}
}

View File

@@ -1,14 +1,11 @@
import Testing
@testable import Rosetta
/// Verifies PacketPushNotification wire format matches server
/// (im.rosetta.packet.Packet16PushNotification).
///
/// Server wire format:
/// Verifies PacketPushNotification wire format matches Server/Android:
/// writeInt16(packetId=0x10)
/// writeString(notificationToken)
/// writeInt8(action) 0=subscribe, 1=unsubscribe
/// writeInt8(tokenType) 0=FCM, 1=VoIPApns
/// writeInt8(action)
/// writeInt8(tokenType)
/// writeString(deviceId)
struct PushNotificationPacketTests {
@@ -24,49 +21,49 @@ struct PushNotificationPacketTests {
#expect(PushNotificationAction.unsubscribe.rawValue == 1)
}
@Test("PushTokenType.fcm == 0 (server: FCM)")
@Test("PushTokenType.fcm == 0")
func fcmTokenTypeValue() {
#expect(PushTokenType.fcm.rawValue == 0)
}
@Test("PushTokenType.voipApns == 1 (server: VoIPApns)")
@Test("PushTokenType.voipApns == 1")
func voipTokenTypeValue() {
#expect(PushTokenType.voipApns.rawValue == 1)
}
// MARK: - Round Trip (encode decode)
@Test("FCM subscribe round-trip preserves all fields")
func fcmSubscribeRoundTrip() throws {
@Test("Subscribe round-trip preserves all fields")
func subscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "test-fcm-token-abc123"
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "device-id-xyz"
packet.deviceId = "ios-device-1"
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "test-fcm-token-abc123")
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "device-id-xyz")
#expect(decoded.deviceId == "ios-device-1")
}
@Test("VoIP unsubscribe round-trip preserves all fields")
func voipUnsubscribeRoundTrip() throws {
@Test("Unsubscribe round-trip preserves all fields")
func unsubscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "voip-hex-token-deadbeef"
packet.notificationsToken = "test-token-deadbeef"
packet.action = .unsubscribe
packet.tokenType = .voipApns
packet.deviceId = "another-device-id"
packet.deviceId = "ios-device-2"
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "voip-hex-token-deadbeef")
#expect(decoded.notificationsToken == "test-token-deadbeef")
#expect(decoded.action == .unsubscribe)
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "another-device-id")
#expect(decoded.deviceId == "ios-device-2")
}
@Test("Empty token and deviceId round-trip")
@Test("Empty token round-trip")
func emptyFieldsRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = ""
@@ -76,6 +73,7 @@ struct PushNotificationPacketTests {
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "")
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "")
}
@@ -100,7 +98,7 @@ struct PushNotificationPacketTests {
var packet = PacketPushNotification()
packet.notificationsToken = "T" // 1 UTF-16 code unit
packet.action = .subscribe // 0
packet.tokenType = .voipApns // 1
packet.tokenType = .fcm // 0
packet.deviceId = "D" // 1 UTF-16 code unit
let data = PacketRegistry.encode(packet)
@@ -110,9 +108,9 @@ struct PushNotificationPacketTests {
// [2-5] string length = 1 (UInt32 big-endian) for "T"
// [6-7] 'T' = 0x0054 (UInt16 big-endian)
// [8] action = 0 (subscribe)
// [9] tokenType = 1 (voipApns)
// [9] tokenType = 0 (fcm)
// [10-13] string length = 1 for "D"
// [14-15] 'D' = 0x0044 (UInt16 big-endian)
// [14-15] 'D' = 0x0044
#expect(data.count == 16)
@@ -133,8 +131,8 @@ struct PushNotificationPacketTests {
// action = 0 (subscribe)
#expect(data[8] == 0x00)
// tokenType = 1 (voipApns)
#expect(data[9] == 0x01)
// tokenType = 0 (fcm)
#expect(data[9] == 0x00)
// deviceId string length = 1
#expect(data[10] == 0x00)

View File

@@ -153,6 +153,65 @@ final class SchemaParityTests: XCTestCase {
XCTAssertEqual(decoded.packetId, 0x06)
XCTAssertEqual(decodedMessage.attachments.first?.type, .call)
XCTAssertFalse(decodedMessage.isMalformed)
}
func testPacketMessageDecodeSupportsAttachmentMeta4Compatibility() throws {
let encoded = makePacketMessageData(attachmentMetaFieldCount: 4)
guard let decoded = PacketRegistry.decode(from: encoded),
let message = decoded.packet as? PacketMessage else {
XCTFail("Failed to decode packet with 4 attachment meta fields")
return
}
XCTAssertEqual(decoded.packetId, 0x06)
XCTAssertFalse(message.isMalformed)
XCTAssertEqual(message.fromPublicKey, "02from")
XCTAssertEqual(message.toPublicKey, "02to")
XCTAssertEqual(message.messageId, "msg-compat")
XCTAssertEqual(message.aesChachaKey, "aes-key")
XCTAssertEqual(message.attachments.count, 1)
XCTAssertEqual(message.attachments[0].transportTag, "tag-1")
XCTAssertEqual(message.attachments[0].transportServer, "cdn.rosetta.im")
}
func testPacketMessageDecodeSupportsAttachmentMeta0Compatibility() throws {
let encoded = makePacketMessageData(attachmentMetaFieldCount: 0)
guard let decoded = PacketRegistry.decode(from: encoded),
let message = decoded.packet as? PacketMessage else {
XCTFail("Failed to decode packet with 0 attachment meta fields")
return
}
XCTAssertEqual(decoded.packetId, 0x06)
XCTAssertFalse(message.isMalformed)
XCTAssertEqual(message.messageId, "msg-compat")
XCTAssertEqual(message.aesChachaKey, "aes-key")
XCTAssertEqual(message.attachments.count, 1)
XCTAssertEqual(message.attachments[0].transportTag, "")
XCTAssertEqual(message.attachments[0].transportServer, "")
}
func testPacketMessageDecodeMarksMalformedForTruncatedOrMisalignedPayload() throws {
let canonical = makePacketMessageData(attachmentMetaFieldCount: 2)
let truncated = canonical.dropLast(3)
let withTrailingByte = canonical + Data([0x00])
guard let decodedTruncated = PacketRegistry.decode(from: Data(truncated)),
let messageTruncated = decodedTruncated.packet as? PacketMessage else {
XCTFail("Failed to decode truncated packet wrapper")
return
}
XCTAssertTrue(messageTruncated.isMalformed)
XCTAssertFalse(messageTruncated.malformedFingerprint.isEmpty)
guard let decodedTrailing = PacketRegistry.decode(from: withTrailingByte),
let messageTrailing = decodedTrailing.packet as? PacketMessage else {
XCTFail("Failed to decode trailing-byte packet wrapper")
return
}
XCTAssertTrue(messageTrailing.isMalformed)
XCTAssertFalse(messageTrailing.malformedFingerprint.isEmpty)
}
func testSessionPacketContextResolverAcceptsGroupWireShape() throws {
@@ -188,4 +247,33 @@ final class SchemaParityTests: XCTestCase {
_ = stream
XCTAssertTrue(true)
}
private func makePacketMessageData(attachmentMetaFieldCount: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketMessage.packetId)
stream.writeString("02from")
stream.writeString("02to")
stream.writeString("ciphertext")
stream.writeString("chacha-key")
stream.writeInt64(1_710_000_000_000)
stream.writeString("hash")
stream.writeString("msg-compat")
stream.writeInt8(1)
stream.writeString("att-1")
stream.writeString("preview")
stream.writeString("blob")
stream.writeInt8(AttachmentType.image.rawValue)
if attachmentMetaFieldCount >= 2 {
stream.writeString("tag-1")
stream.writeString("cdn.rosetta.im")
}
if attachmentMetaFieldCount >= 4 {
stream.writeString("02encoded-for")
stream.writeString("desktop")
}
stream.writeString("aes-key")
return stream.toData()
}
}