Harden what a locked or seized device gives away (#1484)

* 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>

* Inject the previews preference instead of reading shared defaults

CI caught a real problem this introduced. `NotificationServiceTests`
already asserted the full-preview title and body, and both it and the new
redaction tests read `NotificationPrivacySettings` from
`UserDefaults.standard` in the same process. Whichever ran second
depended on the other's cleanup, so it passed locally and failed in CI:

  XCTAssertEqual failed: ("🔒 new dm") is not equal to ("🔒 DM from Alice")

Fixed at the source rather than by ordering or serialization.
`NotificationService` now takes a `hidePreviewsProvider`, defaulting to
the real preference, so each test states which behavior it asserts. The
pre-existing test asks for previews shown and gains a redacted
counterpart; the redaction tests no longer touch the shared store.

`NotificationPrivacySettings` also gained store-injecting accessors so
the default-value and round-trip assertions can use an isolated suite
rather than mutating preferences other tests read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 19:26:29 +02:00
committed by GitHub
co-authored by jack Claude Opus 5
parent 229a41557e
commit c079d2ab5d
21 changed files with 2031 additions and 22 deletions
+25
View File
@@ -21,6 +21,7 @@ struct AppInfoView: View {
@State private var showTopology = false
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
@State private var locationNotesEnabled = LocationNotesSettings.enabled
@State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews
@ObservedObject private var locationManager = LocationChannelManager.shared
/// Sticky across opens: first-ever open lands on Info (the gentler
/// introduction), and afterwards the sheet reopens wherever it was left.
@@ -83,6 +84,10 @@ struct AppInfoView: View {
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let privacyTitle = String(localized: "app_info.settings.privacy.title", defaultValue: "PRIVACY", comment: "Section header (uppercase) for privacy settings such as hiding notification previews")
static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications")
static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does")
static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings")
static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation")
static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut")
@@ -477,6 +482,26 @@ struct AppInfoView: View {
}
}
// Privacy: what a locked, seized, or borrowed phone gives away
// without being unlocked.
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.privacyTitle)
settingsCard {
settingToggle(
title: Text(verbatim: Strings.Settings.hidePreviewsTitle),
subtitle: Text(verbatim: Strings.Settings.hidePreviewsSubtitle),
isOn: Binding(
get: { hideMessagePreviews },
set: { newValue in
hideMessagePreviews = newValue
NotificationPrivacySettings.hideMessagePreviews = newValue
}
)
)
}
}
// Danger zone
if onPanicWipe != nil {
VStack(alignment: .leading, spacing: 12) {