fix undecryptable self-copies by preserving the self-session
- 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:
1 parent
5a7fc36722
commit
8b86e33186
5 files changed
+92
-20
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user