mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4daba71eb2 | ||
|
|
917c477012 |
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
1. **Identity and cryptographic keys**
|
||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
||||
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
||||
|
||||
## Your Controls
|
||||
|
||||
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||
- **No account:** The project operates no account record for you to request or export.
|
||||
|
||||
@@ -15,9 +15,6 @@ enum NoiseSecurityConstants {
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
|
||||
@@ -11,5 +11,4 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ import BitFoundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
/// A responder rehandshake must not evict a working transport session
|
||||
/// before the candidate proves that its authenticated static key belongs
|
||||
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||
/// until the XX handshake completes and the binding is validated.
|
||||
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
@@ -59,9 +54,6 @@ final class NoiseSessionManager {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||
candidate.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +62,7 @@ final class NoiseSessionManager {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
for (_, candidate) in responderCandidates {
|
||||
candidate.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
responderCandidates.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +79,6 @@ final class NoiseSessionManager {
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
existingSession.reset()
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
@@ -104,7 +91,6 @@ final class NoiseSessionManager {
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
@@ -114,50 +100,39 @@ final class NoiseSessionManager {
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
let session: NoiseSession
|
||||
let isReplacementCandidate: Bool
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let candidate = responderCandidates[peerID] {
|
||||
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||
// but never the established session it is trying to replace.
|
||||
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||
candidate.reset()
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
responderCandidates[peerID] = replacement
|
||||
session = replacement
|
||||
} else {
|
||||
session = candidate
|
||||
}
|
||||
isReplacementCandidate = true
|
||||
} else if let existing = sessions[peerID] {
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info(
|
||||
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||
category: .session
|
||||
)
|
||||
let candidate = sessionFactory(peerID, .responder)
|
||||
responderCandidates[peerID] = candidate
|
||||
session = candidate
|
||||
isReplacementCandidate = true
|
||||
} else if existing.getState() == .handshaking,
|
||||
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||
// No established transport state exists to preserve. A
|
||||
// fresh initiation replaces the incomplete handshake.
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
existing.reset()
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = replacement
|
||||
session = replacement
|
||||
isReplacementCandidate = false
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
session = existing
|
||||
isReplacementCandidate = false
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
isReplacementCandidate = false
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
@@ -166,40 +141,18 @@ final class NoiseSessionManager {
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||
throw NoiseSessionError.peerIdentityMismatch
|
||||
}
|
||||
|
||||
if isReplacementCandidate {
|
||||
_ = responderCandidates.removeValue(forKey: peerID)
|
||||
let previous = sessions.updateValue(session, forKey: peerID)
|
||||
if let previous, previous !== session {
|
||||
previous.reset()
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// A failed candidate is discarded without touching the
|
||||
// established session. Ordinary failed handshakes retain the
|
||||
// historical cleanup behavior.
|
||||
if isReplacementCandidate {
|
||||
if let storedCandidate = responderCandidates[peerID],
|
||||
storedCandidate === session {
|
||||
_ = responderCandidates.removeValue(forKey: peerID)
|
||||
}
|
||||
} else if let storedSession = sessions[peerID],
|
||||
storedSession === session {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
session.reset()
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
@@ -212,24 +165,6 @@ final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||
/// also accepted by internal callers when they exactly match the static
|
||||
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||
private func authenticatedRemoteKey(
|
||||
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID
|
||||
) -> Bool {
|
||||
let rawKey = remoteKey.rawRepresentation
|
||||
if claimedPeerID.isShort {
|
||||
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||
}
|
||||
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||
return claimedNoiseKey == rawKey
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
|
||||
@@ -4,6 +4,14 @@ import Foundation
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
private static let mediaSubdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
@@ -24,6 +32,23 @@ struct BLEIncomingFileStore {
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
/// returning. Recreating the directory tree keeps later capture/receive
|
||||
/// paths usable without allowing a detached cleanup task to race them.
|
||||
func panicWipe() throws {
|
||||
let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||
try fileManager.removeItem(at: filesDirectory)
|
||||
}
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
try fileManager.createDirectory(
|
||||
at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves (and creates) an incoming-media directory for callers that
|
||||
/// write progressively instead of via `save` (live voice captures).
|
||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||
@@ -113,15 +138,18 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func rootDirectory() throws -> URL {
|
||||
try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
|
||||
@@ -49,11 +49,7 @@ final class BLENoisePacketHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns true when the handshake message was processed successfully.
|
||||
/// Callers use this to distinguish an authenticated replacement completion
|
||||
/// from a rejected candidate while an older session remains established.
|
||||
@discardableResult
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
// Use NoiseEncryptionService for handshake processing
|
||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||
@@ -76,26 +72,14 @@ final class BLENoisePacketHandler {
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
return true
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The candidate was already discarded by the session manager.
|
||||
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||
// handshake or recreate state for the attacker-selected ID.
|
||||
SecureLogger.warning(
|
||||
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
} catch {
|
||||
SecureLogger.error("Failed to process handshake: \(error)")
|
||||
// Try initiating a new handshake
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
|
||||
@@ -1618,44 +1618,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept a leave only when the claimed sender proves possession of the
|
||||
/// signing key bound by a verified announce. The persisted identity cache
|
||||
/// keeps delayed/relayed leaves verifiable after the live registry entry
|
||||
/// has aged out.
|
||||
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
let registrySigningKey = collectionsQueue.sync {
|
||||
peerRegistry.info(for: peerID)?.signingPublicKey
|
||||
}
|
||||
let verifiedViaRegistry = registrySigningKey.map {
|
||||
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||||
} ?? false
|
||||
let verifiedViaPersistedIdentity = !verifiedViaRegistry
|
||||
&& identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in
|
||||
PeerID(publicKey: identity.publicKey) == peerID
|
||||
&& identity.signingPublicKey.map {
|
||||
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||||
} == true
|
||||
}
|
||||
|
||||
guard verifiedViaRegistry || verifiedViaPersistedIdentity else {
|
||||
SecureLogger.warning(
|
||||
"🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// A valid departure retires transport state too; otherwise
|
||||
// canDeliverSecurely could remain true for a peer we just removed.
|
||||
noiseService.clearSession(for: peerID)
|
||||
readLinkState { _ in
|
||||
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||||
owner == peerID ? link : nil
|
||||
}
|
||||
for link in departedLinks {
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||
}
|
||||
}
|
||||
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
|
||||
_ = collectionsQueue.sync(flags: .barrier) {
|
||||
// Remove the peer when they leave
|
||||
peerRegistry.remove(peerID)
|
||||
@@ -1672,7 +1635,6 @@ final class BLEService: NSObject {
|
||||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||
}
|
||||
return true
|
||||
}
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
// Throttle announces to prevent flooding
|
||||
@@ -2374,12 +2336,6 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool {
|
||||
bleQueue.sync {
|
||||
noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID
|
||||
}
|
||||
}
|
||||
|
||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
peerRegistry.upsert(BLEPeerInfo(
|
||||
@@ -4829,9 +4785,7 @@ extension BLEService {
|
||||
handleMeshPong(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
// A forged leave must neither evict the claimed peer nor spread
|
||||
// to downstream nodes.
|
||||
guard handleLeave(packet, from: senderID) else { return }
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
case .none:
|
||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||
@@ -5472,14 +5426,8 @@ extension BLEService {
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
// XX message 1 is exactly the unauthenticated 32-byte ephemeral key.
|
||||
// While replacing an existing session, do not authenticate its ingress
|
||||
// link until a later message completes and validates the candidate.
|
||||
let completedAuthenticatedHandshake = !wasEstablished
|
||||
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
|
||||
if processed, isEstablished, completedAuthenticatedHandshake {
|
||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
|
||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainInstallLifecycleAction: Equatable {
|
||||
case markerPresent
|
||||
case bootstrapMarker
|
||||
case clearStaleKeys
|
||||
case retryLater
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
/// Default keychain for components that construct their own rather than
|
||||
/// having one injected. Under test this is an in-memory keychain: the
|
||||
@@ -41,27 +48,80 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||
|
||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||
// device locked (identity-cache saves failed with -25308 throughout
|
||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||
// restoration must be able to read the noise keys before the user
|
||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||
// migrating through device backups onto a second device.
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
reconcileInstallLifecycle()
|
||||
migrateAccessibilityIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
static func installLifecycleAction(
|
||||
containerKnowsMarker: Bool,
|
||||
markerRead: KeychainReadResult
|
||||
) -> KeychainInstallLifecycleAction {
|
||||
switch markerRead {
|
||||
case .success:
|
||||
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
|
||||
case .itemNotFound:
|
||||
return .bootstrapMarker
|
||||
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
|
||||
return .retryLater
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
private static let installMarkerAccount = "install_lifecycle_marker"
|
||||
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
|
||||
|
||||
/// Keychain items can survive app removal while the app container and its
|
||||
/// UserDefaults do not. The first version carrying this marker bootstraps
|
||||
/// without deleting existing users' identities. On a later reinstall, a
|
||||
/// surviving keychain marker plus a missing defaults marker proves the app
|
||||
/// container was replaced, so stale secrets are removed before use.
|
||||
private func reconcileInstallLifecycle() {
|
||||
let defaults = UserDefaults.standard
|
||||
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
|
||||
switch Self.installLifecycleAction(
|
||||
containerKnowsMarker: containerKnowsMarker,
|
||||
markerRead: markerRead
|
||||
) {
|
||||
case .markerPresent:
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
case .bootstrapMarker:
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
|
||||
case .clearStaleKeys:
|
||||
_ = deleteAllKeychainData()
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
case .retryLater:
|
||||
// Do not guess that a temporarily unreadable marker is absent.
|
||||
// Leaving the defaults flag unchanged lets a later construction
|
||||
// retry once protected keychain data is available.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||
/// the right class on their own (saves are delete-then-add), but the
|
||||
/// long-lived identity keys are written once and would otherwise stay
|
||||
/// unreadable while the device is locked.
|
||||
private func migrateAccessibilityIfNeeded() {
|
||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
case errSecSuccess, errSecItemNotFound:
|
||||
// Nothing to migrate on a fresh install; both are terminal.
|
||||
UserDefaults.standard.set(true, forKey: flag)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
|
||||
default:
|
||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||
// leave the flag unset so the next launch retries.
|
||||
@@ -492,6 +552,15 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
||||
|
||||
#if os(iOS)
|
||||
// The non-secret marker is intentionally recreated after a panic so a
|
||||
// later uninstall/reinstall can still be distinguished from an in-place
|
||||
// upgrade. It never restores any wiped identity or relationship data.
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
UserDefaults.standard.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
#endif
|
||||
|
||||
return totalDeleted > 0
|
||||
}
|
||||
|
||||
@@ -526,18 +595,39 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||
var query: [String: Any] = [
|
||||
let primaryKeyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
var addQuery = primaryKeyQuery
|
||||
addQuery.merge([
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||
kSecAttrSynchronizable as String: false
|
||||
]) { _, new in new }
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
// Delete by the item's primary key only. Value/accessibility fields
|
||||
// are add attributes, not valid selectors for replacing an existing
|
||||
// item; including them can leave the old item in place and make the
|
||||
// subsequent add fail as a duplicate.
|
||||
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(deleteStatus)),
|
||||
context: "Unable to replace custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
return
|
||||
}
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(addStatus)),
|
||||
context: "Unable to save custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load data from a custom service
|
||||
|
||||
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
|
||||
assemblies.values.contains { $0.messageID == message.id }
|
||||
}
|
||||
|
||||
/// Stop every live file handle/player before the panic media directory is
|
||||
/// removed. This prevents an in-flight assembly from continuing to write
|
||||
/// through an unlinked file after the wipe returns.
|
||||
func resetForPanic() {
|
||||
for assembly in Array(assemblies.values) {
|
||||
cancelAssembly(assembly)
|
||||
}
|
||||
for player in drainingPlayers.values {
|
||||
player.stop()
|
||||
}
|
||||
drainingPlayers.removeAll(keepingCapacity: false)
|
||||
finishedBursts.removeAll(keepingCapacity: false)
|
||||
updatePublicTalkerIndicator()
|
||||
}
|
||||
|
||||
/// Called for every inbound private message: when it is the finalized
|
||||
/// voice note of a burst we assembled (matched by burst ID in the file
|
||||
/// name), swap it into the existing live bubble and report `true` so the
|
||||
|
||||
@@ -78,6 +78,7 @@ final class ChatMediaTransferCoordinator {
|
||||
|
||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||
private var preparationGeneration: UInt64 = 0
|
||||
|
||||
init(context: any ChatMediaTransferContext) {
|
||||
self.context = context
|
||||
@@ -98,13 +99,14 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
let generation = preparationGeneration
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
do {
|
||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
@@ -116,13 +118,13 @@ final class ChatMediaTransferCoordinator {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
@@ -132,11 +134,15 @@ final class ChatMediaTransferCoordinator {
|
||||
#if os(iOS)
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
let generation = preparationGeneration
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: processedURL)
|
||||
return
|
||||
}
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -147,11 +153,15 @@ final class ChatMediaTransferCoordinator {
|
||||
#elseif os(macOS)
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
let generation = preparationGeneration
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: processedURL)
|
||||
return
|
||||
}
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -170,6 +180,7 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
let targetPeer = context.selectedPrivateChatPeer
|
||||
let generation = preparationGeneration
|
||||
|
||||
do {
|
||||
try ImageUtils.validateImageSource(at: sourceURL)
|
||||
@@ -184,7 +195,10 @@ final class ChatMediaTransferCoordinator {
|
||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||
return
|
||||
}
|
||||
let message = self.enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
@@ -201,13 +215,13 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.context.addSystemMessage("Image is too large to send.")
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||
}
|
||||
}
|
||||
@@ -341,6 +355,19 @@ final class ChatMediaTransferCoordinator {
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
/// Invalidates detached preparation work and cancels every transfer that
|
||||
/// reached the transport. Stale tasks check the generation before they
|
||||
/// can recreate a message or send after a panic wipe.
|
||||
func resetForPanic() {
|
||||
preparationGeneration &+= 1
|
||||
let transferIDs = Set(transferIdToMessageIDs.keys)
|
||||
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
||||
messageIDToTransferId.removeAll(keepingCapacity: false)
|
||||
for transferID in transferIDs {
|
||||
context.cancelTransfer(transferID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatMediaTransferCoordinator {
|
||||
|
||||
@@ -177,7 +177,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
||||
context: self,
|
||||
sweepsOnInit: !TestEnvironment.isRunningTests
|
||||
)
|
||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
@@ -292,6 +295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
var nostrRelayManager: NostrRelayManager?
|
||||
private let userDefaults = UserDefaults.standard
|
||||
let keychain: KeychainManagerProtocol
|
||||
private let panicMediaWipe: () throws -> Void
|
||||
/// Private group membership: keys in the keychain, metadata on disk.
|
||||
let groupStore: GroupStore
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
@@ -799,7 +803,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
readReceiptsDefaults: UserDefaults? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
) {
|
||||
let conversations = conversations ?? ConversationStore()
|
||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||
@@ -814,6 +819,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
self.panicMediaWipe = panicMediaWipe ?? {
|
||||
// Unit tests share the developer's real Application Support
|
||||
// directory. Production uses the managed store; tests that need
|
||||
// to exercise the wipe inject a temporary-directory closure.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
try BLEIncomingFileStore().panicWipe()
|
||||
}
|
||||
self.groupStore = GroupStore(keychain: keychain)
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
@@ -1156,6 +1168,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
func panicClearAllData() {
|
||||
// Messages are processed immediately - nothing to flush
|
||||
|
||||
// Invalidate detached media preparation and close live capture file
|
||||
// handles before clearing state or removing the media directory.
|
||||
mediaTransferCoordinator.resetForPanic()
|
||||
liveVoiceCoordinator.resetForPanic()
|
||||
|
||||
// Clear all messages (public timelines and private chats live in the
|
||||
// single-writer ConversationStore; the derived `messages` view and
|
||||
// the legacy mirror empty with it)
|
||||
@@ -1278,44 +1295,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
// Skipped under tests: the test process shares the user's real
|
||||
// ~/Library/Application Support/files tree, and this detached
|
||||
// utility-priority wipe fires at a nondeterministic time —
|
||||
// deleting media that concurrently running tests (e.g. the
|
||||
// sendImage flow) just wrote there, and the developer's real
|
||||
// app data with it.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
|
||||
// Delete the entire files directory and recreate it
|
||||
if FileManager.default.fileExists(atPath: filesDir.path) {
|
||||
try FileManager.default.removeItem(at: filesDir)
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
}
|
||||
|
||||
// Recreate empty directory structure
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
}
|
||||
|
||||
// BCH-01-013: Clear iOS app switcher snapshots
|
||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
||||
#if os(iOS)
|
||||
Self.clearAppSwitcherSnapshots()
|
||||
#endif
|
||||
// The wipe must finish before this security action returns. A detached
|
||||
// task could otherwise lose a race with a new capture or app exit and
|
||||
// leave pre-panic media behind.
|
||||
do {
|
||||
try panicMediaWipe()
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
}
|
||||
|
||||
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
||||
// the host user's real cache tree just as the default media wipe does.
|
||||
#if os(iOS)
|
||||
if !TestEnvironment.isRunningTests {
|
||||
Self.clearAppSwitcherSnapshots()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Force immediate UI update for panic mode
|
||||
// UI updates immediately - no flushing needed
|
||||
|
||||
|
||||
@@ -99,95 +99,6 @@ struct BLEServiceCoreTests {
|
||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
|
||||
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
||||
ble._test_handlePacket(
|
||||
unsigned,
|
||||
fromPeerID: alicePeerID,
|
||||
signingPublicKey: alice.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!unsignedRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
|
||||
let badSignature = try #require(
|
||||
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
||||
)
|
||||
ble._test_handlePacket(
|
||||
badSignature,
|
||||
fromPeerID: alicePeerID,
|
||||
signingPublicKey: alice.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!badSignatureRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
}
|
||||
|
||||
@Test
|
||||
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
// Establish a real session so the leave regression also verifies that
|
||||
// stale secure-delivery state is retired, not just the peer-list row.
|
||||
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||
let message2 = try #require(
|
||||
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||
)
|
||||
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||
let centralUUID = "central-valid-leave"
|
||||
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
||||
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
||||
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let signedLeave = try #require(
|
||||
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
||||
)
|
||||
ble._test_handlePacket(
|
||||
signedLeave,
|
||||
fromPeerID: alicePeerID,
|
||||
signingPublicKey: alice.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
let evicted = await TestHelpers.waitUntil(
|
||||
{
|
||||
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
||||
&& !ble.canDeliverSecurely(to: alicePeerID)
|
||||
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(evicted)
|
||||
let relayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(relayed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||
let ble = makeService()
|
||||
@@ -779,18 +690,6 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
|
||||
)
|
||||
}
|
||||
|
||||
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(marker.utf8),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
|
||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private(set) var publicMessages: [BitchatMessage] = []
|
||||
|
||||
@@ -188,6 +188,21 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
||||
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
||||
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
||||
|
||||
coordinator.resetForPanic()
|
||||
|
||||
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
|
||||
@@ -15,7 +15,9 @@ import BitFoundation
|
||||
|
||||
/// Creates a ChatViewModel with mock dependencies for testing
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
private func makeTestableViewModel(
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
@@ -26,7 +28,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
transport: transport,
|
||||
panicMediaWipe: panicMediaWipe
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
@@ -1097,6 +1100,18 @@ struct ChatViewModelBluetoothTests {
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||
var wipeFinished = false
|
||||
let (viewModel, _) = makeTestableViewModel {
|
||||
wipeFinished = true
|
||||
}
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(wipeFinished)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -12,15 +12,8 @@ struct NoiseCoverageTests {
|
||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||
// historical names, but derive each wire ID from the static key that the
|
||||
// corresponding manager authenticates during the handshake.
|
||||
private var alicePeerID: PeerID {
|
||||
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
|
||||
}
|
||||
private var bobPeerID: PeerID {
|
||||
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
|
||||
}
|
||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||
|
||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("PreviewKeychainManager Tests")
|
||||
struct PreviewKeychainManagerTests {
|
||||
|
||||
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
||||
func installLifecycleDecision() {
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: true,
|
||||
markerRead: .success(Data([1]))
|
||||
) == .markerPresent)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .success(Data([1]))
|
||||
) == .clearStaleKeys)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .itemNotFound
|
||||
) == .bootstrapMarker)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .deviceLocked
|
||||
) == .retryLater)
|
||||
}
|
||||
|
||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||
func previewKeychainManagerRoundTripsData() {
|
||||
let manager = PreviewKeychainManager()
|
||||
|
||||
@@ -370,6 +370,46 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
let subdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
for subdirectory in subdirectories {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
||||
}
|
||||
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
||||
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data("legacy".utf8).write(to: unmanaged)
|
||||
|
||||
try store.panicWipe()
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
||||
for subdirectory in subdirectories {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
var isDirectory: ObjCBool = false
|
||||
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
||||
#expect(isDirectory.boolValue)
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
|
||||
@@ -152,21 +152,6 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||
|
||||
#expect(recorder.hasSessionQueries.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Encrypted
|
||||
|
||||
@Test
|
||||
|
||||
@@ -91,150 +91,39 @@ struct NoiseEncryptionServiceTests {
|
||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
let alicePeerID = PeerID(str: "0011223344556677")
|
||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
let recorder = AuthenticationRecorder()
|
||||
|
||||
#expect(alice.onPeerAuthenticated == nil)
|
||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
||||
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||
#expect(authenticated)
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(alice.hasSession(with: bobPeerID))
|
||||
#expect(bob.hasSession(with: alicePeerID))
|
||||
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(alice.hasSession(with: alicePeerID))
|
||||
#expect(bob.hasSession(with: bobPeerID))
|
||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
||||
|
||||
let plaintext = Data("secret payload".utf8)
|
||||
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
||||
#expect(decrypted == plaintext)
|
||||
|
||||
alice.clearSession(for: bobPeerID)
|
||||
#expect(!alice.hasSession(with: bobPeerID))
|
||||
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||
alice.clearSession(for: alicePeerID)
|
||||
#expect(!alice.hasSession(with: alicePeerID))
|
||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
||||
|
||||
bob.clearEphemeralStateForPanic()
|
||||
#expect(!bob.hasSession(with: alicePeerID))
|
||||
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
||||
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
||||
let recorder = AuthenticationRecorder()
|
||||
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
|
||||
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
||||
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
||||
} catch let error as NoiseSessionError {
|
||||
#expect(error == .peerIdentityMismatch)
|
||||
} catch {
|
||||
Issue.record("Unexpected mismatch error: \(error)")
|
||||
}
|
||||
|
||||
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!emittedAuthentication)
|
||||
}
|
||||
|
||||
@Test("Failed forged replacement preserves the established peer session")
|
||||
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||
let recorder = AuthenticationRecorder()
|
||||
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
|
||||
try establishSessions(alice: alice, bob: receiver)
|
||||
let initialAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(initialAuthentication)
|
||||
|
||||
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
||||
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
||||
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||
let forgedMessage2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||
)
|
||||
// The replacement has not authenticated yet; the working Alice
|
||||
// transport session must remain available throughout the candidate.
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||
Issue.record("Expected forged replacement to fail peer binding")
|
||||
} catch let error as NoiseSessionError {
|
||||
#expect(error == .peerIdentityMismatch)
|
||||
} catch {
|
||||
Issue.record("Unexpected replacement error: \(error)")
|
||||
}
|
||||
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
||||
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
|
||||
@Test("Valid rehandshake atomically replaces the established session")
|
||||
func validRehandshakeReplacesEstablishedSession() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||
|
||||
try establishSessions(alice: alice, bob: receiver)
|
||||
alice.clearSession(for: receiverPeerID)
|
||||
|
||||
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||
)
|
||||
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
|
||||
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
||||
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||
#expect(!bob.hasSession(with: bobPeerID))
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||
@@ -311,16 +200,16 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
private func establishSessions(
|
||||
alice: NoiseEncryptionService,
|
||||
bob: NoiseEncryptionService
|
||||
bob: NoiseEncryptionService,
|
||||
alicePeerID: PeerID,
|
||||
bobPeerID: PeerID
|
||||
) throws {
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
||||
let message2 = try #require(response, "Expected handshake response")
|
||||
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
||||
let message3 = try #require(final, "Expected handshake final")
|
||||
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
||||
#expect(finalMessage == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
|
||||
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
||||
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
|
||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||
|
||||
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||
|
||||
@@ -88,7 +88,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu
|
||||
|
||||
## Panic Wipe Coverage
|
||||
|
||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
|
||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.
|
||||
|
||||
## Release Review Checklist
|
||||
|
||||
|
||||
Reference in New Issue
Block a user