mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 08:25:21 +00:00
* PTT hardening: burst-ID collision hijack + live-capture quota bypass Fix C — burst-ID collision hijack: inbound live-voice assemblies and the finished-burst registry were keyed by the sender-chosen burst ID alone, so an attacker who observed a public burst ID could race a START and capture the real talker's frames (packets from the true sender were dropped as "collisions"). Assemblies now key on (peerID, scope, burstID): a colliding START from another peer opens its own capped assembly and can never divert the victim's frames; finalized-note absorption matches on the same triple, preserving the sender/scope binding. Live capture files also gain the peer ID in their name so colliding bursts land on distinct paths (still rejected by burstID(fromVoiceFileName:), so live names remain unabsorbable). Fix B — live-burst files bypassed the incoming-media quota: progressive voice_live_*.aac captures were written via raw FileHandle without ever touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside the 100 MB LRU accounting. The coordinator now takes an injected BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture, and sweeps orphaned voice_live_* partials from previous sessions at startup. enforceQuota gains an `excluding:` set so in-flight partials are never LRU-evicted mid-stream; existing callers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review fixes: pattern-guard live captures in quota, scope in capture names Quota-eviction gap: BLEFileTransferHandler enforces the quota for every finalized arrival via enforceQuota(reservingBytes:) with no exclusion set, so a file landing at quota could LRU-evict an in-flight live capture — unlinking the inode under the coordinator's open FileHandle and leaving a dead bubble. Protection is now layer-independent: enforceQuota itself skips voice_live_* names (they still count toward usage), and the excluding: parameter is gone since the pattern guard covers its only caller. Orphaned partials are still reclaimed by the coordinator's startup sweep, which the quota deliberately never touches. Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one peer running a DM burst and a public burst with the same burst ID mapped both assemblies onto one path — and FileManager.createFile truncates, so each START corrupted the other capture. Names now mirror the assembly key: voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard match on the voice_live_ prefix and burstID(fromVoiceFileName:) still rejects every live name, so live captures remain unabsorbable; sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact contents. Nits: the coordinator's file operations route through the store's injectable FileManager instead of FileManager.default, and the TestEnvironment.isRunningTests branch in init is replaced by a sweepsOnInit parameter (tests sharing the real application-support directory pass false for hermeticity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Promote kept live captures off the voice_live_ name at finalize Codex P1 follow-up: when a burst finalizes with frames but its .m4a note never arrives, the voice_live_*.aac capture stays behind as the bubble's replayable audio — yet the startup sweep deletes every voice_live_* file and the quota guard skips them forever, so a kept fallback was both quota-immune for the rest of the session and doomed at the next launch. finalize now promotes the capture to a plain voice_ name (same suffix) and republishes the row pointing at it; the finished-burst registry tracks the promoted URL so a late note still absorbs and deletes it. voice_live_ is thereby scoped to genuinely in-flight captures: the sweep never touches a referenced fallback, and promoted files age out of the quota like any finalized media. If the move fails the live name is kept — exactly the pre-promotion behavior. Premise correction, verified while tracing the reference model: chat rows are NOT persisted across restarts (ConversationStore is in-memory; the gossip archive replays MessageType.message packets only), so today's sweep never orphaned a persisted row — the fix removes the in-session quota immunity and makes the invariant hold if row persistence ever lands. Crash case stays deliberate and documented: a mid-burst partial from a dead process is swept because no surviving row can reference it. Tests: finalize-as-fallback promotes the file, repoints the row, and survives a later coordinator's startup sweep; promoted fallbacks are LRU-evictable by the quota; the sweep still removes true orphans while sparing promoted names; existing burst tests updated for the promoted paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.6 KiB
Swift
192 lines
8.6 KiB
Swift
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
|
|
struct BLEIncomingFileStore {
|
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
|
|
|
/// Name prefix of in-flight live voice captures (progressively written by
|
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
|
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
|
/// and kills playback — and the coordinator's startup sweep deletes any
|
|
/// orphans a previous session left behind.
|
|
static let liveCapturePrefix = "voice_live_"
|
|
|
|
/// Exposed so callers that write progressively into the store's
|
|
/// directories (live voice captures) share the same file manager.
|
|
let fileManager: FileManager
|
|
private let baseDirectory: URL?
|
|
private let dateProvider: () -> Date
|
|
|
|
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
|
self.fileManager = fileManager
|
|
self.baseDirectory = baseDirectory
|
|
self.dateProvider = dateProvider
|
|
}
|
|
|
|
/// 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 {
|
|
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
|
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
|
return directory
|
|
}
|
|
|
|
func save(
|
|
data: Data,
|
|
preferredName: String?,
|
|
subdirectory: String,
|
|
fallbackExtension: String?,
|
|
defaultPrefix: String
|
|
) -> URL? {
|
|
do {
|
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
|
let sanitized = sanitizedFileName(
|
|
preferredName,
|
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
|
fallbackExtension: fallbackExtension
|
|
)
|
|
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
|
try data.write(to: destination, options: .atomic)
|
|
return destination
|
|
} catch {
|
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Frees least-recently-modified incoming files until `reservingBytes`
|
|
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
|
/// captures) are never evicted regardless of who triggers enforcement —
|
|
/// a finalized transfer can arrive at quota while a burst is still
|
|
/// streaming — but they still count toward usage.
|
|
func enforceQuota(reservingBytes: Int) {
|
|
do {
|
|
let base = try filesDirectory()
|
|
let incomingDirs = [
|
|
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
|
base.appendingPathComponent("images/incoming", isDirectory: true),
|
|
base.appendingPathComponent("files/incoming", isDirectory: true)
|
|
]
|
|
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
|
|
|
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
|
guard let contents = try? fileManager.contentsOfDirectory(
|
|
at: dir,
|
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else { continue }
|
|
|
|
for fileURL in contents {
|
|
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
|
let size = attrs.fileSize,
|
|
let modified = attrs.contentModificationDate else { continue }
|
|
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
|
}
|
|
}
|
|
|
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
|
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
|
guard currentUsage > targetUsage else { return }
|
|
|
|
let needToFree = currentUsage - targetUsage
|
|
var freedSpace: Int64 = 0
|
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
|
guard freedSpace < needToFree else { break }
|
|
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
|
do {
|
|
try fileManager.removeItem(at: file.url)
|
|
freedSpace += file.size
|
|
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
|
} catch {
|
|
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
|
}
|
|
}
|
|
|
|
if freedSpace > 0 {
|
|
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
|
}
|
|
} catch {
|
|
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
|
}
|
|
}
|
|
|
|
private func filesDirectory() throws -> URL {
|
|
let root = 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 {
|
|
var candidate = (name ?? "")
|
|
.replacingOccurrences(of: "\0", with: "")
|
|
.precomposedStringWithCanonicalMapping
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "\\", with: "_")
|
|
|
|
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
|
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
|
if candidate.isEmpty { candidate = defaultName }
|
|
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
|
|
|
if candidate.count > 120 {
|
|
let ext = (candidate as NSString).pathExtension
|
|
let base = (candidate as NSString).deletingPathExtension
|
|
candidate = ext.isEmpty
|
|
? String(candidate.prefix(120))
|
|
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
|
}
|
|
|
|
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
|
candidate += ".\(fallbackExtension)"
|
|
}
|
|
|
|
return candidate.isEmpty ? defaultName : candidate
|
|
}
|
|
|
|
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
|
let directoryPath = directory.standardizedFileURL.path
|
|
func isInsideDirectory(_ url: URL) -> Bool {
|
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
|
}
|
|
|
|
var candidate = directory.appendingPathComponent(fileName)
|
|
guard isInsideDirectory(candidate) else {
|
|
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
|
}
|
|
|
|
if !fileManager.fileExists(atPath: candidate.path) {
|
|
return candidate
|
|
}
|
|
|
|
let baseName = (fileName as NSString).deletingPathExtension
|
|
let ext = (fileName as NSString).pathExtension
|
|
for counter in 1..<100 {
|
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
|
candidate = directory.appendingPathComponent(newName)
|
|
guard isInsideDirectory(candidate) else {
|
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
|
}
|
|
if !fileManager.fileExists(atPath: candidate.path) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
|
}
|
|
|
|
private static func timestampString(from date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|