mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:05:20 +00:00
PTT hardening: per-peer burst keys + live-capture storage quota (#1420)
* 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>
This commit is contained in:
co-authored by
jack
Claude Opus 4.8
parent
84d315e62d
commit
c8479c15e3
@@ -5,7 +5,16 @@ import Foundation
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
|
||||
private let fileManager: FileManager
|
||||
/// 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
|
||||
|
||||
@@ -15,6 +24,14 @@ struct BLEIncomingFileStore {
|
||||
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?,
|
||||
@@ -39,6 +56,11 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -72,6 +94,7 @@ struct BLEIncomingFileStore {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user