Files
bitchat/bitchatTests/Services/NotificationRedactionTests.swift
T
jackandClaude Opus 5 dc7475a0ba Harden what a locked or seized device gives away
The realistic compromise for many of the people this app is built for is
not interception but a phone taken, and often unlocked under coercion.
Content encryption is in good shape; these are the gaps around it.

Hide notification previews, by default. Notification content is rendered
by the system on the lock screen, so it was readable without unlocking:
DM alerts carried the sender's nickname and the full message body, and
geohash alerts put the geohash in the title. Alerts now state that a DM,
mention, or location-channel activity arrived and withhold the rest until
the app is opened. userInfo still carries the routing peer ID and deep
link, neither of which the system displays, so taps land where they did.
A settings toggle restores full previews for anyone who wants them.
Default-on is the deliberate part: a phone face-up on a table should not
narrate conversations, and someone who wants previews can say so.

Cover the window on willResignActive, so the snapshot iOS stores for the
app switcher shows a placeholder rather than an open conversation. Opaque
rather than blurred, because blurred large text stays partly legible and
the snapshot goes to disk. Added synchronously from a UIKit notification
with queue: nil, since the capture follows shortly after and an
OperationQueue hop or a SwiftUI state change can lose that race. Panic
wipe already deleted snapshots already on disk; this stops new ones from
being worth deleting.

Bound media by age as well as size. The 100 MB quota only ever considered
incoming files, so outgoing media had no lifetime at all and a received
photo could outlive its conversation indefinitely. A launch-time sweep now
deletes managed media older than seven days, incoming and outgoing, with
the same exemptions quota eviction honors: in-flight live captures and
files reserved by a delivery or deletion in progress.

Make /clear tell the truth on the mesh timeline. It recorded an echo
watermark and left the gossip archive on disk for up to 6 hours, so
someone who cleared before a police stop had deleted nothing. Clearing now
erases the archive too. The watermark still matters: it suppresses
pre-clear messages this device hears again from peers. The cost is that
the device stops serving recent public backlog until it hears fresh
traffic, which is a fair reading of what clearing a timeline means.

Documented in PRIVACY_POLICY.md and the privacy assessment, including a
new section on what is deliberately NOT addressed: there is still no
duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth,
no app lock), macOS gets no file-protection classes, and media is not
sealed at the app layer. The duress question is a product decision as much
as an engineering one, since in some jurisdictions destroying data on
demand is itself an offence and hiding may protect someone better than
destroying, so it is called out rather than guessed at.

Three findings from the audit that prompted this work turned out to be
already fixed on main and are not included: keychain accessibility is
AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media
wipe uses a two-location durable marker transaction, and panic already
discards staged share-extension content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:31:30 +02:00

122 lines
4.6 KiB
Swift

import BitFoundation
import Foundation
import Testing
import UserNotifications
@testable import bitchat
/// Lock-screen notifications used to carry the DM body and the sender's
/// nickname verbatim, and the geohash in the title, so a locked phone lying on
/// a table narrated conversations to anyone looking at it. These cover the
/// redaction that is now the default.
///
/// Serialized because the preference lives in `UserDefaults.standard`; parallel
/// cases would race each other's saves.
@Suite(.serialized)
struct NotificationRedactionTests {
private final class RecordingDeliverer: NotificationRequestDelivering {
var requests: [UNNotificationRequest] = []
func add(_ request: UNNotificationRequest) {
requests.append(request)
}
}
private struct StubAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
completionHandler(true, nil)
}
}
/// Runs `body` with the preference forced to `hidePreviews`, restoring
/// whatever the environment had afterwards.
private func withHidePreviews(
_ hidePreviews: Bool,
_ body: (NotificationService, RecordingDeliverer) throws -> Void
) rethrows {
let original = NotificationPrivacySettings.hideMessagePreviews
defer { NotificationPrivacySettings.hideMessagePreviews = original }
NotificationPrivacySettings.hideMessagePreviews = hidePreviews
let deliverer = RecordingDeliverer()
let service = NotificationService(
isRunningTestsProvider: { false },
authorizer: StubAuthorizer(),
requestDeliverer: deliverer
)
try body(service, deliverer)
}
@Test func previewsAreHiddenByDefault() {
// A fresh install must start quiet rather than opt-in quiet.
UserDefaults.standard.removeObject(forKey: "notifications.hideMessagePreviews")
#expect(NotificationPrivacySettings.hideMessagePreviews)
}
@Test func redactedDirectMessageWithholdsSenderAndBody() {
withHidePreviews(true) { service, deliverer in
service.sendPrivateMessageNotification(
from: "alice",
message: "meet at the north gate",
peerID: PeerID(str: "00112233445566ff")
)
let content = try! #require(deliverer.requests.first).content
#expect(!content.title.contains("alice"))
#expect(!content.body.contains("north gate"))
#expect(!content.title.isEmpty)
// Still routable: userInfo is never rendered on the lock screen.
#expect(content.userInfo["peerID"] as? String == "00112233445566ff")
}
}
@Test func redactedMentionWithholdsSenderAndBody() {
withHidePreviews(true) { service, deliverer in
service.sendMentionNotification(from: "bob", message: "regroup now")
let content = try! #require(deliverer.requests.first).content
#expect(!content.title.contains("bob"))
#expect(!content.body.contains("regroup"))
}
}
@Test func redactedGeohashActivityWithholdsTheGeohash() {
withHidePreviews(true) { service, deliverer in
service.sendGeohashActivityNotification(
geohash: "u4pruyd",
bodyPreview: "someone said something"
)
let content = try! #require(deliverer.requests.first).content
#expect(!content.title.contains("u4pruyd"))
#expect(!content.body.contains("someone said"))
// The deep link still carries it: tapping must land in the channel.
#expect(content.userInfo["deeplink"] as? String == "bitchat://geohash/u4pruyd")
}
}
@Test func previewsShownWhenTheSettingIsOff() {
withHidePreviews(false) { service, deliverer in
service.sendPrivateMessageNotification(
from: "alice",
message: "meet at the north gate",
peerID: PeerID(str: "00112233445566ff")
)
service.sendGeohashActivityNotification(
geohash: "u4pruyd",
bodyPreview: "someone said something"
)
#expect(deliverer.requests.count == 2)
let dm = deliverer.requests[0].content
#expect(dm.title.contains("alice"))
#expect(dm.body == "meet at the north gate")
let geo = deliverer.requests[1].content
#expect(geo.title.contains("u4pruyd"))
#expect(geo.body == "someone said something")
}
}
}