fix undecryptable self-copies by preserving the self-session
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- Purge the poisoned self-session only once per account instead of on
  every connect, and rebuild it from the own published bundle when
  missing: deleting it repeatedly broke encrypt-to-self, so archived
  copies of own messages (most visibly in the self-chat) failed with
  "could not decrypt".
- Detect OMEMO 2 (urn:xmpp:omemo:2) payloads, which the pinned
  MartinOMEMO does not implement, and show them as undecryptable
  instead of empty plaintext bubbles.
- Guard both behaviours in verify.sh and document the namespace
  limitation in SECURITY.md.
This commit is contained in:
wt committed 2026-08-30 00:32:40 +07:00
1 parent 5a7fc36722
commit 8b86e33186
5 files changed
+92 -20

No files matched your search

+3
View File
@@ -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.
+12
View File
@@ -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
+19 -6
View File
@@ -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()
}
+50 -9
View File
@@ -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 <body> fallback alongside the
// OMEMO <encrypted> payload. When decryption fails, prefer that
@@ -2230,9 +2265,15 @@ 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 {
security = .decryptionFailed
fingerprint = nil
contentMessage = nil
} else {
security = .plaintext
fingerprint = nil
contentMessage = message
}
case .duplicateMessage:
emitGroupEchoIfPossible(
roomJID: roomJID,
+8 -5
View File
@@ -46,11 +46,14 @@ final class TimelineUITests: XCTestCase {
let app = launchApp()
openChat(app)
// Message 60 can sit partially behind the composer, so drag on a
// bubble that is reliably visible and hittable in the bottom area.
let target = bubble("uitest-msg-55", in: app)
XCTAssertTrue(target.waitForExistence(timeout: 10))
XCTAssertTrue(target.isHittable, "The swipe target must be hittable")
// The initial scroll position varies slightly between runs, so pick
// the first hittable bubble from the bottom area of the timeline.
let candidates = [60, 59, 58, 55, 50, 45].map {
bubble("uitest-msg-\($0)", in: app)
}
let target = candidates.first { $0.exists && $0.isHittable }
XCTAssertNotNil(target, "At least one swipe target must be hittable")
guard let target else { return }
// A controlled right-to-left drag: the stock swipeLeft() flicks too
// fast for the gesture's horizontal lock to engage reliably.
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))