mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:25:19 +00:00
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut
Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".
Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
established Noise session; Nostr keeps its prompt-delivery predicate;
the protocol default forwards to canDeliverPromptly for transports
without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
it can deliver securely; otherwise it still sends (kicking the
handshake on a genuine link) but retains a copy and hands a sealed
copy to couriers, like the reachable path. flushOutbox gets the same
gate so a flush over an insecure link resends instead of dropping the
retained copy.
Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff
Review follow-ups on the canDeliverSecurely gate:
- flushOutbox no longer counts connected-but-insecure flushes toward the
maxSendAttempts drop: the message was actually transmitted over a live
link, so a peer whose Noise handshake stalls across reconnect flapping
must not burn through the cap and lose the store-and-forward copy the
gate exists to preserve. Retention stays bounded by the 24h outbox TTL
and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
for reachable-only (heuristic) sends. Regression test: >8 connected-
insecure flushes keep the retained copy, drop callback never fires.
- Document the known second-replay presence gap: linkBoundToOtherPeer
reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
the link, so once a first replay has rebound a link to an absent
victim's ID, a second replay marks the victim connected. Presence
display only — DMs stay on the retain+courier path via the router
gate. Not closed at the announce layer because the post-rebind state
is indistinguishable from a legitimate rotation/reconnect heal (a
supported, field-verified flow). Covered by a two-announce test.
- Note the accepted courier-spray metadata tradeoff at the connected-
insecure send: nearby verified peers receive a sealed copy (they learn
a DM exists, never its content), cleared on ack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery
Codex review follow-ups on 5017c232:
- P1: a legitimate rotation announce necessarily arrives on a link still
bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
new identity as disconnected — and the rebind only fixed link state,
never the registry. A healed rotation then read as disconnected until
the peer happened to announce again. rebindLinkAfterVerifiedDirect-
Announce now promotes the rebound identity to connected after the
containment checks pass (registry markConnected + topology/snapshot/
peer-list refresh; the .peerConnected UI event already fired from the
announce path). This consciously moves the forged-presence residue
from second-replay to first-successful-rebind — bounded by the rebind
containment (never steals a live identity, one rebind per link per
cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
Comments and the pinned tests updated; new regression test covers
rotate-on-open-link -> rebind -> isPeerConnected(new) == true.
- P2: BLEService.canDeliverSecurely probed the Noise session with the
peer ID as given, but sessions are keyed by the short wire ID — a send
keyed by the full 64-hex Noise key (favorites resolution) misread an
established session as insecure and needlessly retained + couriered
every DM until ack. Normalize with toShort() like isPeerConnected.
Test drives a real XX handshake and asserts both ID forms pass.
- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
flake fired again and slipped past its baselined USR (read exists in
loadFromDisk; the indexer intermittently misses it). Replace the
baseline entry with retain_codable_properties: true — deterministic,
and a truly-dead Codable field is a persisted-format change anyway.
Local periphery scan --strict: no unused code detected.
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>
217 lines
8.4 KiB
Swift
217 lines
8.4 KiB
Swift
//
|
|
// PrekeyBundleStore.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// Signature-verified one-time prekey bundles received from other peers.
|
|
///
|
|
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
|
|
/// copy, keeping the IDs we already sealed with marked used so a prekey is
|
|
/// never reused across messages. Assignments are remembered per message ID so
|
|
/// deposit retries of the same message re-use its prekey (and its budget)
|
|
/// instead of burning a fresh one per courier.
|
|
///
|
|
/// Only public key material lives here; it persists to disk so a sender can
|
|
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
|
final class PrekeyBundleStore {
|
|
struct StoredBundle: Codable {
|
|
// noiseKey is read in loadFromDisk (dictionary keying), but the
|
|
// Periphery indexer intermittently misses that read and flaked CI
|
|
// with "assign-only" — even past its baselined USR. Covered
|
|
// deterministically by retain_codable_properties in .periphery.yml
|
|
// (an in-source ignore can't work: strict mode flags it as
|
|
// superfluous on the runs where the indexer gets it right).
|
|
let noiseKey: Data
|
|
var generatedAt: UInt64
|
|
var prekeyIDs: [UInt32]
|
|
var prekeyPublicKeys: [Data]
|
|
/// IDs this device already sealed with (never reused).
|
|
var usedIDs: Set<UInt32>
|
|
/// messageID → prekey ID, so re-deposits of one message share one prekey.
|
|
var assignments: [String: UInt32]
|
|
var updatedAt: Date
|
|
}
|
|
|
|
enum Limits {
|
|
static let maxPeers = 200
|
|
/// Don't seal to bundles older than this: the owner may have rotated
|
|
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
|
|
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
|
|
}
|
|
|
|
static let shared = PrekeyBundleStore()
|
|
|
|
private var bundles: [Data: StoredBundle] = [:]
|
|
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
|
|
private let fileURL: URL?
|
|
private let maxPeers: Int
|
|
private let now: () -> Date
|
|
|
|
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
|
/// when `persistsToDisk` is false.
|
|
init(
|
|
persistsToDisk: Bool = true,
|
|
fileURL: URL? = nil,
|
|
maxPeers: Int = Limits.maxPeers,
|
|
now: @escaping () -> Date = Date.init
|
|
) {
|
|
self.now = now
|
|
self.maxPeers = maxPeers
|
|
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
|
loadFromDisk()
|
|
}
|
|
|
|
// MARK: - Ingest
|
|
|
|
/// Stores a bundle whose signature the caller has already verified
|
|
/// against the owner's announce-bound signing key. Returns false when an
|
|
/// equal-or-newer bundle is already cached (nothing changed).
|
|
@discardableResult
|
|
func ingest(_ bundle: PrekeyBundle) -> Bool {
|
|
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
|
|
!bundle.prekeys.isEmpty else { return false }
|
|
return queue.sync {
|
|
if let existing = bundles[bundle.noiseStaticPublicKey],
|
|
existing.generatedAt >= bundle.generatedAt {
|
|
return false
|
|
}
|
|
let previous = bundles[bundle.noiseStaticPublicKey]
|
|
let newIDs = Set(bundle.prekeys.map(\.id))
|
|
// Keep consumption state for IDs the fresh bundle still offers
|
|
// (a top-up keeps the owner's unconsumed keys); drop the rest.
|
|
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
|
|
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
|
|
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
|
|
noiseKey: bundle.noiseStaticPublicKey,
|
|
generatedAt: bundle.generatedAt,
|
|
prekeyIDs: bundle.prekeys.map(\.id),
|
|
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
|
|
usedIDs: carriedUsed,
|
|
assignments: carriedAssignments,
|
|
updatedAt: now()
|
|
)
|
|
enforceCapLocked()
|
|
persistLocked()
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Sealing support
|
|
|
|
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
|
|
func hasUsableBundle(for noiseKey: Data) -> Bool {
|
|
queue.sync {
|
|
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
|
|
return bundle.usedIDs.count < bundle.prekeyIDs.count
|
|
}
|
|
}
|
|
|
|
/// The prekey to seal a message with: the message's existing assignment if
|
|
/// any (re-deposits reuse it), else the lowest unused ID, which is then
|
|
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
|
|
/// spent — callers fall back to static sealing.
|
|
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
|
queue.sync {
|
|
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
|
|
|
|
if let assigned = bundle.assignments[messageID],
|
|
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
|
|
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
|
|
}
|
|
|
|
guard let index = bundle.prekeyIDs.indices
|
|
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
|
|
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
|
|
return nil
|
|
}
|
|
let id = bundle.prekeyIDs[index]
|
|
bundle.usedIDs.insert(id)
|
|
bundle.assignments[messageID] = id
|
|
bundle.updatedAt = now()
|
|
bundles[recipientNoiseKey] = bundle
|
|
persistLocked()
|
|
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
|
|
}
|
|
}
|
|
|
|
// MARK: - Maintenance
|
|
|
|
/// Panic wipe: drop all cached bundles from memory and disk.
|
|
func wipe() {
|
|
queue.sync {
|
|
bundles.removeAll()
|
|
if let fileURL {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Internals (call only on `queue`)
|
|
|
|
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
|
|
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
|
|
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
|
|
}
|
|
|
|
private func enforceCapLocked() {
|
|
while bundles.count > maxPeers {
|
|
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
|
|
bundles.removeValue(forKey: victim.key)
|
|
}
|
|
}
|
|
|
|
private func persistLocked() {
|
|
guard let fileURL else { return }
|
|
do {
|
|
if bundles.isEmpty {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
return
|
|
}
|
|
try FileManager.default.createDirectory(
|
|
at: fileURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
let data = try JSONEncoder().encode(Array(bundles.values))
|
|
var options: Data.WritingOptions = [.atomic]
|
|
#if os(iOS)
|
|
options.insert(.completeFileProtection)
|
|
#endif
|
|
try data.write(to: fileURL, options: options)
|
|
} catch {
|
|
SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
|
|
}
|
|
}
|
|
|
|
private func loadFromDisk() {
|
|
guard let fileURL else { return }
|
|
queue.sync {
|
|
guard let data = try? Data(contentsOf: fileURL),
|
|
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
|
|
return
|
|
}
|
|
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
|
|
bundles[bundle.noiseKey] = bundle
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func defaultFileURL() -> URL? {
|
|
guard let base = try? FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
) else { return nil }
|
|
return base
|
|
.appendingPathComponent("prekeys", isDirectory: true)
|
|
.appendingPathComponent("bundles.json")
|
|
}
|
|
}
|