mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:25:22 +00:00
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>
118 lines
4.1 KiB
Swift
118 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|