diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md index 403d918..769a32c 100644 --- a/Docs/SECURITY.md +++ b/Docs/SECURITY.md @@ -14,6 +14,9 @@ серверной нормализацией; для PLAIN пароль отправляется как введён. - SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma), SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера. +- OMEMO работает в legacy-неймспейсе `eu.siacs.conversations.axolotl` (так + умеет закреплённая версия MartinOMEMO); сообщения OMEMO 2 + (`urn:xmpp:omemo:2`) показываются как нерасшифрованные, а не пустые. - Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах с поддержкой SecureTransport договаривается TLS 1.3. diff --git a/Scripts/verify.sh b/Scripts/verify.sh index a9550c3..40c0354 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -394,6 +394,18 @@ grep -q 'connectionStatus == .connected' Sources/Shared/Models/AppModel.swift || echo "Older-history loads must skip silently while disconnected" exit 1 } +grep -q 'removeSessionWithOwnDeviceOnce' Sources/Shared/XMPP/LumaOMEMOStore.swift || { + echo "The self-session purge must be a one-time migration" + exit 1 +} +grep -q 'ensureSelfSession' Sources/Shared/XMPP/XMPPService.swift || { + echo "A missing self-session must be rebuilt from the own bundle" + exit 1 +} +grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/XMPPService.swift || { + echo "OMEMO 2 payloads must be shown as undecryptable, not empty" + exit 1 +} grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || { echo "The camera picker must request dismissal before background preparation" exit 1 diff --git a/Sources/Shared/XMPP/LumaOMEMOStore.swift b/Sources/Shared/XMPP/LumaOMEMOStore.swift index 6a2b4ea..2cb0fca 100644 --- a/Sources/Shared/XMPP/LumaOMEMOStore.swift +++ b/Sources/Shared/XMPP/LumaOMEMOStore.swift @@ -103,12 +103,15 @@ final class LumaOMEMOStore: SignalStorage { sessionAdapter.containsSessionRecord(forAddress: address) } - /// Removes a session record with our own current device. A self-session is - /// cryptographically invalid (Signal sessions exist between two distinct - /// identities) and makes Martin's `_encode(..., forSelf: true)` encrypt to - /// the local device itself, producing messages that later fail with - /// "Bad MAC". Such a record can only be created by a bug; deleting it is - /// always safe and idempotent. + var accountJID: String { repository.accountJID } + + var localRegistrationID: UInt32 { identityAdapter.localRegistrationId() } + + /// Removes a session record with our own current device. A poisoned + /// self-session (created by a buggy older build) makes encrypt-to-self + /// fail with "Bad MAC". Deleting it is safe, but the session is then + /// legitimately rebuilt from our own bundle and must never be purged + /// again: archived self-copies depend on its ratchet continuity. func removeSessionWithOwnDevice() { let registrationID = identityAdapter.localRegistrationId() guard registrationID != 0 else { return } @@ -119,6 +122,16 @@ final class LumaOMEMOStore: SignalStorage { _ = sessionAdapter.deleteSessionRecord(forAddress: address) } + /// One-time migration purge of the poisoned self-session, keyed per + /// account. Running it on every connect destroys the self-session ratchet + /// and turns every archived encrypt-to-self copy into "could not decrypt". + func removeSessionWithOwnDeviceOnce() { + let flagKey = "app.luma.chat.selfSessionPurged." + repository.accountJID + guard !UserDefaults.standard.bool(forKey: flagKey) else { return } + UserDefaults.standard.set(true, forKey: flagKey) + removeSessionWithOwnDevice() + } + func flushPendingPersistence() { repository.flush() } diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index 64dfff5..013803a 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -427,9 +427,12 @@ final class XMPPService { guard let signalContext = SignalContext(withStorage: omemoStorage) else { throw LumaXMPPError.omemoInitializationFailed } - // Clean up any invalid self-session left behind by a previous buggy - // build so it cannot poison encrypt-to-self ("Bad MAC" loops). - omemoStorage.removeSessionWithOwnDevice() + // Clean up an invalid self-session left behind by a previous buggy + // build (one-time migration per account), then make sure a working + // self-session exists: encrypt-to-self needs it and deleting it on + // every connect made archived self-copies undecryptable. + omemoStorage.removeSessionWithOwnDeviceOnce() + ensureSelfSession(client: client, omemoStorage: omemoStorage) configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage) configureConnection(client: client, account: account, password: password) @@ -785,6 +788,13 @@ final class XMPPService { .info("sent call-history id=\(entry.id) peer=\(entry.peerJID) status=\(entry.outcome.rawValue)") } + /// Detects OMEMO 2 (`urn:xmpp:omemo:2`) payloads, which the pinned + /// MartinOMEMO version cannot decrypt (it only speaks the legacy + /// `eu.siacs.conversations.axolotl` namespace). + private static func isOMEMO2Payload(_ message: Message) -> Bool { + message.element.findChild(name: "encrypted", xmlns: "urn:xmpp:omemo:2") != nil + } + private static func callHistoryBody(_ entry: CallHistoryEntry) -> String { let kind = entry.isVideo ? "Видеозвонок" : "Аудиозвонок" switch entry.outcome { @@ -1582,6 +1592,22 @@ final class XMPPService { } } + /// Rebuilds the self-session from our own published bundle when it is + /// missing, so encrypt-to-self keeps working after the migration purge. + private func ensureSelfSession( + client: XMPPClient, + omemoStorage: LumaOMEMOStore + ) { + let registrationID = omemoStorage.localRegistrationID + guard registrationID != 0 else { return } + let address = SignalAddress( + name: omemoStorage.accountJID, + deviceId: Int32(bitPattern: registrationID) + ) + guard !omemoStorage.hasSession(for: address) else { return } + client.module(.omemo).buildSession(forAddress: address) + } + private func configureModules( client: XMPPClient, signalContext: SignalContext, @@ -1975,9 +2001,18 @@ final class XMPPService { case .failure(let error): switch error { case .notEncrypted: - security = .plaintext - fingerprint = nil - contentMessage = message + if Self.isOMEMO2Payload(message), message.body?.isEmpty != false { + // OMEMO 2 (urn:xmpp:omemo:2) is not implemented by the + // pinned MartinOMEMO library; show the honest + // undecryptable state instead of an empty bubble. + security = .decryptionFailed + fingerprint = nil + contentMessage = nil + } else { + security = .plaintext + fingerprint = nil + contentMessage = message + } default: // Some clients include a plaintext
fallback alongside the // OMEMO