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>
This commit is contained in:
jack
2026-07-26 17:31:30 +02:00
co-authored by Claude Opus 5
parent 934b2cd2d3
commit dc7475a0ba
20 changed files with 1977 additions and 20 deletions
@@ -79,12 +79,17 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
}
private(set) var clearedConversations: [ConversationID] = []
private(set) var archivePurgeCount = 0
func clearPublicConversation(_ conversationID: ConversationID) {
clearedConversations.append(conversationID)
conversations[conversationID] = []
}
func purgeArchivedPublicMessages() {
archivePurgeCount += 1
}
func queueGeohashSystemMessage(_ content: String) {
queuedGeohashSystemMessages.append(content)
}
@@ -345,6 +350,35 @@ struct ChatPublicConversationCoordinatorContextTests {
#expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash))
}
/// `/clear` on the mesh timeline used to only record a watermark, leaving
/// the archive on disk for up to its freshness window so someone who
/// cleared before a police stop had deleted nothing. It must now erase the
/// archive behind the timeline.
@Test @MainActor
func clearCurrentPublicTimeline_onMesh_erasesTheArchive() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
context.activeChannel = .mesh
coordinator.clearCurrentPublicTimeline()
#expect(context.clearedConversations == [.mesh])
#expect(context.archivePurgeCount == 1)
}
/// Geohash timelines are Nostr-backed and carry no mesh gossip archive, so
/// clearing one must not reach for it.
@Test @MainActor
func clearCurrentPublicTimeline_onGeohash_leavesTheArchiveAlone() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
coordinator.clearCurrentPublicTimeline()
#expect(context.archivePurgeCount == 0)
}
@Test @MainActor
func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async {
let context = MockChatPublicConversationContext()
+39
View File
@@ -810,6 +810,45 @@ struct GossipSyncManagerTests {
#expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0)
}
/// Clearing the mesh timeline must leave nothing behind on disk: a
/// relaunch that restored the archive would undo the clear.
@Test func removeAllPublicMessagesErasesTheArchiveOnDisk() async throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
let senderID = try #require(Data(hexString: "1122334455667788"))
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: senderID,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data([0x01, 0x02]),
signature: nil,
ttl: 1
)
let manager = GossipSyncManager(
myPeerID: myPeerID,
requestSyncManager: RequestSyncManager(),
archive: GossipMessageArchive(fileURL: fileURL)
)
manager.onPublicPacketSeen(packet)
manager._performMaintenanceSynchronously(now: Date())
#expect(FileManager.default.fileExists(atPath: fileURL.path))
manager.removeAllPublicMessages()
let erased = await TestHelpers.waitUntil(
{
!FileManager.default.fileExists(atPath: fileURL.path)
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
},
timeout: TestConstants.shortTimeout
)
#expect(erased)
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
@@ -0,0 +1,117 @@
import Foundation
import Testing
@testable import bitchat
/// Media used to be bounded only by a 100 MB incoming quota, so a received
/// photo or a sent voice note could sit on disk indefinitely outliving the
/// conversation it belonged to, which is exactly what a seized device gives up.
/// These cover the age-based sweep that bounds it in time as well.
struct MediaRetentionTests {
private func makeRoot() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("media-retention-\(UUID().uuidString)", isDirectory: true)
}
private func write(
_ name: String,
in directory: URL,
modified: Date
) throws -> URL {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let url = directory.appendingPathComponent(name)
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: url)
try FileManager.default.setAttributes(
[.modificationDate: modified],
ofItemAtPath: url.path
)
return url
}
@Test
func expiresOutgoingMediaPastRetentionAndKeepsFreshMedia() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let outgoing = root.appendingPathComponent("files/images/outgoing", isDirectory: true)
// Outgoing media had no lifetime at all before this sweep: the quota
// only ever considered incoming directories.
let stale = try write(
"sent_old.jpg",
in: outgoing,
modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60)
)
let fresh = try write(
"sent_new.jpg",
in: outgoing,
modified: Date(timeIntervalSinceNow: -60)
)
let removed = store.expireAgedMedia()
#expect(removed == 1)
#expect(!FileManager.default.fileExists(atPath: stale.path))
#expect(FileManager.default.fileExists(atPath: fresh.path))
}
@Test
func expiresIncomingMediaPastRetention() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
let stale = try write(
"received.m4a",
in: incoming,
modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60)
)
#expect(store.expireAgedMedia() == 1)
#expect(!FileManager.default.fileExists(atPath: stale.path))
}
@Test
func retentionSweepSkipsInFlightLiveCaptures() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
// Deleting a live capture mid-stream unlinks the inode under the
// coordinator's open FileHandle, so age must not override the guard.
let inFlight = try write(
"\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac",
in: incoming,
modified: Date(timeIntervalSinceNow: -30 * 24 * 60 * 60)
)
#expect(store.expireAgedMedia() == 0)
#expect(FileManager.default.fileExists(atPath: inFlight.path))
}
@Test
func nonPositiveRetentionIsANoOp() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let incoming = try store.incomingDirectory(subdirectory: "images/incoming")
let file = try write(
"received.jpg",
in: incoming,
modified: Date(timeIntervalSinceNow: -365 * 24 * 60 * 60)
)
#expect(store.expireAgedMedia(retention: 0) == 0)
#expect(FileManager.default.fileExists(atPath: file.path))
}
@Test
func defaultRetentionIsSevenDays() {
#expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60)
}
}
@@ -0,0 +1,121 @@
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")
}
}
}