Полный аудит крипто + доставки - 67 тестов, download retry fix, bytesToAndroidUtf8 fix

This commit is contained in:
2026-04-07 17:03:43 +05:00
parent a5945152c0
commit ff8eca710d
16 changed files with 983 additions and 341 deletions

View File

@@ -274,17 +274,16 @@ enum MessageCrypto {
if codePoint == nil {
codePoint = 0xFFFD
bytesPerSequence = 1
} else if codePoint! > 0xFFFF {
let adjusted = codePoint! - 0x10000
codePoints.append(((adjusted >> 10) & 0x3FF) | 0xD800)
codePoint = 0xDC00 | (adjusted & 0x3FF)
}
codePoints.append(codePoint!)
index += bytesPerSequence
}
return String(codePoints.map { Character(UnicodeScalar($0 < 0xD800 || $0 > 0xDFFF ? $0 : 0xFFFD)!) })
// Build string from code points. Swift uses Unicode scalars (not UTF-16),
// so supplementary plane characters (> 0xFFFF) are handled directly
// no surrogate pair decomposition needed (unlike JS/Kotlin UTF-16 strings).
return String(codePoints.map { Character(UnicodeScalar($0)!) })
}
}

View File

@@ -438,7 +438,10 @@ final class DialogRepository {
// Store stripped key so push mute check matches both formats.
if dialog.opponentKey.lowercased().hasPrefix("#group:") {
let stripped = String(dialog.opponentKey.dropFirst("#group:".count))
if !stripped.isEmpty { mutedKeys.append(stripped) }
if !stripped.isEmpty {
mutedKeys.append(stripped)
mutedKeys.append("group:\(stripped)")
}
}
}
UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys")
@@ -457,7 +460,10 @@ final class DialogRepository {
// Store stripped key so push name lookup matches both formats.
if dialog.opponentKey.lowercased().hasPrefix("#group:") {
let stripped = String(dialog.opponentKey.dropFirst("#group:".count))
if !stripped.isEmpty { names[stripped] = name }
if !stripped.isEmpty {
names[stripped] = name
names["group:\(stripped)"] = name
}
}
}
}

View File

@@ -27,8 +27,6 @@ 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)
@@ -53,57 +51,30 @@ struct PacketSignalPeer: Packet {
}
mutating func read(from stream: Stream) {
do {
let rawSignalType = try stream.readInt8Strict()
guard let parsedSignalType = SignalType(rawValue: rawSignalType) else {
markMalformed("invalid_signal_type:\(rawSignalType)")
return
}
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 Self.hasLegacyCallMetadata(parsedSignalType) {
parsedCallId = try stream.readStringStrict()
parsedJoinToken = try stream.readStringStrict()
}
// 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))
src = ""
dst = ""
sharedPublic = ""
callId = ""
joinToken = ""
roomId = ""
signalType = SignalType(rawValue: stream.readInt8()) ?? .call
if isShortSignal {
return
}
src = stream.readString()
dst = stream.readString()
if signalType == .keyExchange {
sharedPublic = stream.readString()
}
if hasLegacyCallMetadata {
callId = stream.readString()
joinToken = stream.readString()
}
// Signal code 4 is mode-aware on read:
// - empty roomId => legacy ACTIVE
// - non-empty roomId => create-room fallback
if signalType == .createRoom {
roomId = stream.readString()
}
}
@@ -116,37 +87,4 @@ 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

@@ -17,123 +17,18 @@ 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) {
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"
}
signalType = WebRTCSignalType(rawValue: stream.readInt8()) ?? .offer
sdpOrCandidate = stream.readString()
publicKey = stream.readString()
deviceId = stream.readString()
}
}

View File

@@ -848,29 +848,11 @@ 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)
}

View File

@@ -143,11 +143,14 @@ final class TransportManager: @unchecked Sendable {
/// Downloads file content from a transport server.
/// Desktop parity: `useAttachment.ts` `downloadFile(id, tag, server)`.
/// Android parity: retry with exponential backoff (1s, 2s, 4s) on download failure.
///
/// - Parameters:
/// - tag: Server-assigned file tag from upload response.
/// - server: Per-attachment transport server URL. Falls back to global transport if empty/nil.
/// - Returns: Raw file content.
private static let maxDownloadRetries = 3
func downloadFile(tag: String, server: String? = nil) async throws -> Data {
let serverUrl: String
if let explicit = server, !explicit.isEmpty {
@@ -166,18 +169,31 @@ final class TransportManager: @unchecked Sendable {
Self.logger.info("Downloading file tag=\(tag) from \(serverUrl)/d/\(tag)")
let request = URLRequest(url: url)
let (data, response) = try await session.data(for: request)
var lastError: Error = TransportError.invalidResponse
for attempt in 0..<Self.maxDownloadRetries {
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TransportError.invalidResponse
guard let httpResponse = response as? HTTPURLResponse else {
throw TransportError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
Self.logger.error("Download failed: HTTP \(httpResponse.statusCode)")
throw TransportError.downloadFailed(statusCode: httpResponse.statusCode)
}
Self.logger.info("Download complete: tag=\(tag), \(data.count) bytes")
return data
} catch {
lastError = error
if attempt < Self.maxDownloadRetries - 1 {
let delayMs = 1000 * (1 << attempt) // 1s, 2s, 4s
Self.logger.warning("Download retry \(attempt + 1)/\(Self.maxDownloadRetries) for tag=\(tag) in \(delayMs)ms: \(error.localizedDescription)")
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
}
}
}
guard httpResponse.statusCode == 200 else {
Self.logger.error("Download failed: HTTP \(httpResponse.statusCode)")
throw TransportError.downloadFailed(statusCode: httpResponse.statusCode)
}
Self.logger.info("Download complete: tag=\(tag), \(data.count) bytes")
return data
throw lastError
}
}

View File

@@ -167,6 +167,7 @@ extension CallManager {
let snapshot = uiState
let snapshotCallId = callId
let snapshotJoinToken = joinToken
let snapshotOwnPublicKey = ownPublicKey
// Step 0: Cancel recovery/rebind tasks and clear packet buffer.
disconnectRecoveryTask?.cancel()
@@ -175,7 +176,23 @@ extension CallManager {
e2eeRebindTask = nil
bufferedWebRtcPackets.removeAll()
// Step 1: Close WebRTC FIRST SFU sees peer disconnect immediately.
// Step 1: Notify peer FIRST before closing WebRTC (Android parity).
// Send endCall while the CallSession still exists on server, before SFU
// detects our WebRTC disconnect and server's periodic cleanup removes it.
if notifyPeer,
snapshotOwnPublicKey.isEmpty == false,
snapshot.peerPublicKey.isEmpty == false,
snapshot.phase != .idle {
ProtocolManager.shared.sendCallSignal(
signalType: .endCall,
src: snapshotOwnPublicKey,
dst: snapshot.peerPublicKey,
callId: snapshotCallId,
joinToken: snapshotJoinToken
)
}
// Step 2: Close WebRTC SFU sees peer disconnect immediately.
// Without this, SFU waits for ICE timeout (~30s) before releasing the room,
// blocking new calls to the same peer.
durationTask?.cancel()
@@ -198,14 +215,14 @@ extension CallManager {
bufferedRemoteCandidates.removeAll()
attachedReceiverIds.removeAll()
// Step 2: Report to CallKit.
// Step 3: Report to CallKit.
if notifyPeer {
CallKitManager.shared.endCall()
} else {
CallKitManager.shared.reportCallEndedByRemote()
}
// Step 3: Cancel timers, sounds, live activity.
// Step 4: Cancel timers, sounds, live activity.
pendingMinimizeTask?.cancel()
pendingMinimizeTask = nil
cancelRingTimeout()
@@ -217,20 +234,6 @@ extension CallManager {
CallSoundManager.shared.stopAll()
}
// Step 4: Notify peer AFTER WebRTC is closed.
if notifyPeer,
ownPublicKey.isEmpty == false,
snapshot.peerPublicKey.isEmpty == false,
snapshot.phase != .idle {
ProtocolManager.shared.sendCallSignal(
signalType: .endCall,
src: ownPublicKey,
dst: snapshot.peerPublicKey,
callId: snapshotCallId,
joinToken: snapshotJoinToken
)
}
// Step 5: Send call attachment (async, non-blocking).
if !skipAttachment,
role == .caller,

View File

@@ -382,6 +382,9 @@ final class CallManager: NSObject, ObservableObject {
)
switch packet.signalType {
case .endCallBecauseBusy:
// Android parity: notifyPeer=false. Server does NOT create a CallSession
// when callee is busy (checks isBusy BEFORE createCall), so sending endCall
// back would hit NO_CALL_SESSION server disconnects our WebSocket.
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
return
case .endCallBecausePeerDisconnected:

View File

@@ -11,23 +11,12 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Группы — карточки приглашений и аватарки**
Inline-карточка приглашения в группу (Desktop/Android parity). Имя и аватарка отправителя в групповых чатах. Multi-typer typing индикатор. Фикс пароля вложений hex→plain для совместимости с Android.
**Темизация — light/dark**
Circular reveal анимация переключения темы. Адаптивные цвета чата, контекстного меню, attachment picker и авторизации. Обои по теме.
**Пуш-уведомления — Telegram-parity и стабильность**
Группировка по чатам (threadIdentifier). Фикс исчезновения части уведомлений при тапе по пушу. NSE фильтрует повторные уведомления от одного отправителя и использует приоритет реальной аватарки из App Group (fallback: letter-avatar).
**Звонки — стабильность**
Фикс аудио в фоне: pre-configuration AudioSession перед CallKit (Telegram parity). Имя на CallKit/CarPlay. Устранение дублирования CallKit-вызовов. Disconnect recovery, WebRTC packet buffering, E2EE rebind loop.
**Пуш-уведомления — Telegram-parity**
In-app баннер вместо системного (glass overlay, звук, вибрация). Группировка по чатам (threadIdentifier). Letter-avatar в Notification Service Extension.
**Чат — layout и анимации**
Фикс перекрытия текста таймстампом в фото+caption сообщениях. Плавная анимация date pills при клавиатуре и вставке сообщений. Динамический пул date pills для длинных историй.
**Дедупликация сообщений**
Трёхуровневая защита от дублей (queue + process + DB). Forward Picker UI parity.
**Дедупликация и валидация протокола**
Трёхуровневая защита от дублей (queue + process + DB). Улучшена валидация входящих пакетов для защиты от некорректных данных при синхронизации. Forward Picker UI parity.
"""
)
]

View File

@@ -255,16 +255,6 @@ struct ChatDetailView: View {
pendingGroupInviteTitle = parsed.title
}
}
cellActions.onGroupInviteOpen = { dialogKey in
let title = GroupRepository.shared.groupMetadata(
account: SessionManager.shared.currentPublicKey,
groupDialogKey: dialogKey
)?.title ?? ""
NotificationCenter.default.post(
name: .openChatFromNotification,
object: ChatRoute(groupDialogKey: dialogKey, title: title)
)
}
// Capture first unread incoming message BEFORE marking as read.
if firstUnreadMessageId == nil {
firstUnreadMessageId = messages.first(where: {

View File

@@ -106,11 +106,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
#if DEBUG
Messaging.messaging().setAPNSToken(deviceToken, type: .sandbox)
#else
Messaging.messaging().setAPNSToken(deviceToken, type: .prod)
#endif
Messaging.messaging().apnsToken = deviceToken
}
// MARK: - Data-Only Push (Server parity: type/from/dialog fields)
@@ -185,23 +181,15 @@ 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 OR mutable-content, NSE handles sound+badge don't double-count.
// If server sent visible alert, NSE handles sound+badge don't double-count.
// If muted, wake app but don't show notification (NSE also suppresses muted).
if hasVisibleAlert || hasMutableContent || isMuted {
if hasVisibleAlert || isMuted {
completionHandler(.newData)
return
}
@@ -565,9 +553,19 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
name: .openChatFromNotification,
object: route
)
// 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.
// 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)
}
}
}
completionHandler()