mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:05:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399cdf95ab |
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// BLEOriginTTLPolicy.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Chooses the TTL a locally originated public broadcast leaves with.
|
||||
///
|
||||
/// Split out from `BLEService` so the choice is testable without a radio, and so
|
||||
/// the reasoning lives in one place: see
|
||||
/// `TransportConfig.broadcastOriginTTLRange` for why originating at a fixed
|
||||
/// maximum identifies the author to any direct listener.
|
||||
enum BLEOriginTTLPolicy {
|
||||
/// Uniform draw from the configured range.
|
||||
///
|
||||
/// The randomizer is injectable so tests can pin the value; production uses
|
||||
/// the system generator. Note that TTL is excluded from the packet signature
|
||||
/// (`toBinaryDataForSigning` zeroes it so relays can decrement without
|
||||
/// invalidating), so varying it per message is signature-safe and needs no
|
||||
/// cross-platform agreement — a peer running any version simply sees a
|
||||
/// smaller starting TTL and relays it normally.
|
||||
static func originTTL(
|
||||
range: ClosedRange<UInt8> = TransportConfig.broadcastOriginTTLRange,
|
||||
randomTTL: (ClosedRange<UInt8>) -> UInt8 = { UInt8.random(in: $0) }
|
||||
) -> UInt8 {
|
||||
// A degenerate or inverted range must not trap; fall back to the
|
||||
// documented default rather than crashing a send path.
|
||||
guard range.lowerBound <= range.upperBound, range.lowerBound >= 1 else {
|
||||
return TransportConfig.messageTTLDefault
|
||||
}
|
||||
return randomTTL(range)
|
||||
}
|
||||
}
|
||||
@@ -869,7 +869,9 @@ final class BLEService: NSObject {
|
||||
timestamp: sendTimestampMs,
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
// Not the fixed maximum: that would mark every message this device
|
||||
// wrote as originating here, to any direct listener.
|
||||
ttl: BLEOriginTTLPolicy.originTTL()
|
||||
)
|
||||
guard let signedPacket = noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
||||
@@ -2892,7 +2894,12 @@ final class BLEService: NSObject {
|
||||
|
||||
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
|
||||
(
|
||||
peerRegistry.connectedRoutingData,
|
||||
// Publishing the neighbour list hands the local adjacency graph
|
||||
// to a single passive receiver; see
|
||||
// TransportConfig.announceIncludesDirectNeighbors.
|
||||
TransportConfig.announceIncludesDirectNeighbors
|
||||
? peerRegistry.connectedRoutingData
|
||||
: [],
|
||||
PeerCapabilities.localSupported.union(runtimeCapabilities),
|
||||
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
|
||||
)
|
||||
|
||||
@@ -6,6 +6,50 @@ enum TransportConfig {
|
||||
// BLE / Protocol
|
||||
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
|
||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||
|
||||
/// TTL range a public broadcast is originated with.
|
||||
///
|
||||
/// Originating every message at the maximum makes `ttl == messageTTLDefault`
|
||||
/// a reliable "this device wrote it" marker for any direct listener, which
|
||||
/// tells a passive observer who *said* a thing rather than merely who is
|
||||
/// present. Drawing from a range makes the lower values ambiguous between an
|
||||
/// origin and a relay: in a dense graph relays already clamp broadcasts to
|
||||
/// 5, so an origin that emits 5 is indistinguishable from relayed traffic,
|
||||
/// and in a sparse chain a 6 could be an origin or one hop from a 7.
|
||||
///
|
||||
/// The cost is reach: a message originated at 5 crosses two fewer hops than
|
||||
/// one at 7. The upper bound stays at the default so the common case is
|
||||
/// unchanged, and the floor is deliberately not lower than the dense-graph
|
||||
/// relay clamp — going below it would cost reach without buying ambiguity
|
||||
/// that clamp does not already provide.
|
||||
///
|
||||
/// Announces are deliberately excluded: the link-binding paths treat
|
||||
/// `ttl == messageTTLDefault` on an announce as "direct link", and an
|
||||
/// announce's sender ID already identifies the device anyway, so there is
|
||||
/// nothing to hide and something to break.
|
||||
static let broadcastOriginTTLRange: ClosedRange<UInt8> = 5...7
|
||||
|
||||
/// Whether signed announces advertise this device's direct neighbours.
|
||||
///
|
||||
/// The neighbour TLV carries up to ten 8-byte peer IDs, so a *single*
|
||||
/// passive receiver can reconstruct the local adjacency graph — who is
|
||||
/// standing next to whom — without needing several receivers or RSSI
|
||||
/// trilateration. For a crowd, that is the most sensitive thing the radio
|
||||
/// layer discloses, and unlike the identity keys it is not needed for the
|
||||
/// protocol to work.
|
||||
///
|
||||
/// Turning it off costs source routing. `MeshTopologyTracker` builds its
|
||||
/// adjacency map from these lists, and `computeRoute` needs that map, so
|
||||
/// with every device silent there are no routes to compute and directed
|
||||
/// traffic falls back to flooding — which is the documented fallback and is
|
||||
/// already what happens whenever a route fails. Expect more airtime for
|
||||
/// directed sends in dense meshes, and no correctness change.
|
||||
///
|
||||
/// Kept as a constant rather than a user setting because it is a protocol
|
||||
/// trade-off, not a preference: flipping it back is a one-line change, and
|
||||
/// receiving peers' lists is unaffected either way, so a mixed network
|
||||
/// behaves sensibly during any transition.
|
||||
static let announceIncludesDirectNeighbors = false
|
||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||
|
||||
@@ -580,16 +580,8 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
/// constrained CI runners (2-core, serialized testing) can starve the
|
||||
/// detached utility-priority fetch task for seconds before it runs, and
|
||||
/// a successful wait returns as soon as the condition becomes true.
|
||||
/// Default deliberately far larger than the work being awaited.
|
||||
///
|
||||
/// The directory performs its fetch in a `Task.detached(priority: .utility)`,
|
||||
/// and utility priority competes with every other suite on a CI runner. At
|
||||
/// ten seconds the retry-scheduling test timed out at exactly 10.06s with
|
||||
/// the retry never scheduled — which reads like a missing retry rather than
|
||||
/// a starved background task. Returning as soon as the condition holds means
|
||||
/// a longer deadline only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 30.0,
|
||||
timeout: TimeInterval = 10.0,
|
||||
condition: @escaping @MainActor () async -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -69,45 +69,25 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
||||
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
||||
}
|
||||
|
||||
/// Wiring only: a duplicate mid-window still yields exactly one committed
|
||||
/// `false`, published through the monitor's debounce.
|
||||
///
|
||||
/// This deliberately makes no assertion about *when* the flush fires. It
|
||||
/// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was
|
||||
/// not restarted, which flaked on loaded CI runners — one observed run took
|
||||
/// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real
|
||||
/// time and neither is bounded above on a busy machine. No wall-clock bound
|
||||
/// can distinguish "deadline preserved" from "runner is slow", so the timing
|
||||
/// property is asserted where it is computable instead:
|
||||
/// `test_debounce_duplicateObservationsPreservePendingDeadline` drives
|
||||
/// `ReachabilityDebounce` with injected timestamps and checks
|
||||
/// `pendingRemaining` directly.
|
||||
///
|
||||
/// The clock is injected here so the debounce arithmetic is deterministic
|
||||
/// even though the flush itself is scheduled in real time.
|
||||
func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async {
|
||||
let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000))
|
||||
let monitor = NWPathReachabilityMonitor(
|
||||
debounceInterval: 0.2,
|
||||
now: { clock.now }
|
||||
)
|
||||
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
|
||||
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
|
||||
var received: [Bool] = []
|
||||
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
let start = Date()
|
||||
monitor.ingest(reachable: false)
|
||||
// Duplicate unsatisfied update mid-window (e.g. an interface detail
|
||||
// change while still offline).
|
||||
clock.now = clock.now.addingTimeInterval(0.1)
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
// Duplicate unsatisfied update mid-window (e.g. interface detail change
|
||||
// while still offline) must not restart the debounce window.
|
||||
monitor.ingest(reachable: false)
|
||||
// Past the original deadline, so the scheduled flush commits.
|
||||
clock.now = clock.now.addingTimeInterval(0.2)
|
||||
|
||||
// Generous: this is a liveness check, not a latency bound. A real
|
||||
// regression — never committing — still fails, just later.
|
||||
let committed = await waitUntil(timeout: 10.0) { !received.isEmpty }
|
||||
let committed = await waitUntil(timeout: 2.0) { !received.isEmpty }
|
||||
XCTAssertTrue(committed)
|
||||
XCTAssertEqual(received, [false])
|
||||
// The flush must fire at the original ~1.0s deadline, not ~1.5s
|
||||
// (a full interval after the duplicate).
|
||||
XCTAssertLessThan(Date().timeIntervalSince(start), 1.4)
|
||||
}
|
||||
|
||||
// MARK: - Service gating
|
||||
@@ -259,13 +239,3 @@ private final class GateMockProxyController: NetworkActivationProxyControlling {
|
||||
private(set) var proxyModes: [Bool] = []
|
||||
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
|
||||
}
|
||||
|
||||
/// Controllable clock, so debounce arithmetic is deterministic even where the
|
||||
/// flush itself is scheduled in real time.
|
||||
private final class MutableDate: @unchecked Sendable {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests {
|
||||
)
|
||||
let retried = await TestHelpers.waitUntil(
|
||||
{ recorder.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retried)
|
||||
let retryExpired = await TestHelpers.waitUntil(
|
||||
{ !service.hasSession(with: peerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retryExpired)
|
||||
#expect(recorder.timeoutCount == 1)
|
||||
@@ -710,12 +710,7 @@ struct NoiseEncryptionServiceTests {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
// Generous for the same reason as the quarantine-restore test
|
||||
// (#1483): this timeout also arms during the `establishSessions`
|
||||
// setup handshake below, where bob is the responder. At 0.06 a
|
||||
// preempted runner could fire it mid-setup, tear down the half-open
|
||||
// responder, and make message 3 be answered as a fresh initiation.
|
||||
ordinaryResponderHandshakeTimeout: 1.0,
|
||||
ordinaryResponderHandshakeTimeout: 0.06,
|
||||
ordinaryReconnectRollbackCooldown: 0.3
|
||||
)
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
@@ -746,12 +741,12 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
let restored = await TestHelpers.waitUntil(
|
||||
{ bob.hasEstablishedSession(with: alicePeerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(restored)
|
||||
let callbackArrived = await TestHelpers.waitUntil(
|
||||
{ recovery.timeoutCount == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(callbackArrived)
|
||||
|
||||
@@ -785,13 +780,7 @@ struct NoiseEncryptionServiceTests {
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
ordinaryHandshakeTimeout: 0.04,
|
||||
// Also arms during the `establishSessions` setup handshake below,
|
||||
// where bob is the responder. Observed failing on a loaded CI
|
||||
// runner with exactly the signature #1483 documented: the setup's
|
||||
// `#expect(finalMessage == nil)` saw a 96-byte message 2, because
|
||||
// the half-open responder had already been torn down and message 3
|
||||
// was answered as a fresh initiation.
|
||||
ordinaryResponderHandshakeTimeout: 1.0
|
||||
ordinaryResponderHandshakeTimeout: 0.04
|
||||
)
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
@@ -825,12 +814,12 @@ struct NoiseEncryptionServiceTests {
|
||||
// initiates one bounded convergence retry; drop that message 1 too.
|
||||
let retryPrepared = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retryPrepared)
|
||||
let retryExpired = await TestHelpers.waitUntil(
|
||||
{ !bob.hasSession(with: alicePeerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retryExpired)
|
||||
#expect(recovery.timeoutCount == 1)
|
||||
@@ -1037,7 +1026,7 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
let requested = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(requested)
|
||||
let retryMessage1 = try #require(recovery.messages.first)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Two radio-layer metadata leaks that need no cross-platform agreement to
|
||||
/// close: the neighbour list in announces, and the fixed origin TTL.
|
||||
struct RadioMetadataTests {
|
||||
|
||||
// MARK: - Origin TTL
|
||||
|
||||
@Test func originTTLStaysInsideTheConfiguredRange() {
|
||||
let range = TransportConfig.broadcastOriginTTLRange
|
||||
for _ in 0..<200 {
|
||||
let ttl = BLEOriginTTLPolicy.originTTL()
|
||||
#expect(range.contains(ttl))
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of the change: a message must not always leave at the maximum,
|
||||
/// because a direct listener reads `ttl == max` as "this device wrote it".
|
||||
@Test func originTTLDoesNotAlwaysUseTheMaximum() {
|
||||
var seen = Set<UInt8>()
|
||||
for _ in 0..<500 {
|
||||
seen.insert(BLEOriginTTLPolicy.originTTL())
|
||||
}
|
||||
#expect(seen.count > 1)
|
||||
#expect(seen.contains { $0 < TransportConfig.messageTTLDefault })
|
||||
}
|
||||
|
||||
@Test func originTTLUsesTheInjectedRandomizer() {
|
||||
let ttl = BLEOriginTTLPolicy.originTTL(range: 5...7, randomTTL: { _ in 6 })
|
||||
#expect(ttl == 6)
|
||||
}
|
||||
|
||||
/// A send path must never trap on a bad range.
|
||||
@Test func degenerateRangesFallBackToTheDefault() {
|
||||
#expect(BLEOriginTTLPolicy.originTTL(range: 0...0) == TransportConfig.messageTTLDefault)
|
||||
// Single-value range is legitimate and must be honoured.
|
||||
#expect(BLEOriginTTLPolicy.originTTL(range: 4...4) == 4)
|
||||
}
|
||||
|
||||
/// The floor must not drop below the dense-graph relay clamp: going lower
|
||||
/// costs reach without buying ambiguity the clamp does not already provide.
|
||||
@Test func rangeSitsBetweenTheDenseClampAndTheDefault() {
|
||||
let range = TransportConfig.broadcastOriginTTLRange
|
||||
#expect(range.upperBound == TransportConfig.messageTTLDefault)
|
||||
#expect(range.lowerBound >= TransportConfig.bleFragmentRelayTtlCapDense)
|
||||
#expect(range.lowerBound >= 2, "TTL 1 is dropped by RelayController")
|
||||
}
|
||||
|
||||
// MARK: - Neighbour list
|
||||
|
||||
@Test func neighborAdvertisingIsOffByDefault() {
|
||||
#expect(!TransportConfig.announceIncludesDirectNeighbors)
|
||||
}
|
||||
|
||||
/// The mechanism that makes this backward compatible: an empty list omits
|
||||
/// the TLV entirely rather than emitting a zero-length one, and the decoder
|
||||
/// treats its absence as "no topology offered".
|
||||
@Test func emptyNeighborListOmitsTheTLV() throws {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: [],
|
||||
capabilities: [.bridge]
|
||||
)
|
||||
let encoded = try #require(announcement.encode())
|
||||
|
||||
// TLV type 0x04 is the neighbour list; it must not appear at all.
|
||||
var offset = encoded.startIndex
|
||||
var types: [UInt8] = []
|
||||
while offset < encoded.endIndex {
|
||||
guard encoded.distance(from: offset, to: encoded.endIndex) >= 2 else { break }
|
||||
let type = encoded[offset]
|
||||
let length = Int(encoded[encoded.index(after: offset)])
|
||||
types.append(type)
|
||||
offset = encoded.index(offset, offsetBy: 2 + length)
|
||||
}
|
||||
#expect(!types.contains(0x04))
|
||||
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.directNeighbors == nil)
|
||||
// Everything else still round-trips, so old peers lose nothing but the
|
||||
// topology hint.
|
||||
#expect(decoded.nickname == "alice")
|
||||
#expect(decoded.capabilities == [.bridge])
|
||||
}
|
||||
|
||||
/// Receiving a neighbour list must keep working: peers on older builds still
|
||||
/// send one, and a mixed mesh has to behave sensibly.
|
||||
@Test func receivedNeighborListsAreStillParsed() throws {
|
||||
let neighbors = [Data(repeating: 0xA1, count: 8), Data(repeating: 0xB2, count: 8)]
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "bob",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: neighbors
|
||||
)
|
||||
let encoded = try #require(announcement.encode())
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.directNeighbors == neighbors)
|
||||
}
|
||||
}
|
||||
@@ -59,24 +59,11 @@ struct VoiceNotePlaybackControllerTests {
|
||||
return url
|
||||
}
|
||||
|
||||
/// Waits for an async settle, then asserts.
|
||||
///
|
||||
/// The deadline is deliberately far larger than the work it waits on. Every
|
||||
/// condition here depends on a `@MainActor` Task that playback schedules
|
||||
/// (the session acquire and its failure path), and on a CI runner executing
|
||||
/// many suites in parallel that Task can simply not be scheduled for
|
||||
/// seconds. At five seconds this timed out on CI and reported *two*
|
||||
/// failures — the wait itself, and the `!isPlaying` that the un-run failure
|
||||
/// path had not yet reset — which reads like a playback bug rather than a
|
||||
/// starved scheduler.
|
||||
///
|
||||
/// A generous deadline costs nothing when the condition holds, since this
|
||||
/// returns as soon as it does; it only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(30))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -26,13 +26,18 @@ Signed announces can expose:
|
||||
|
||||
- Nickname, persistent Noise public key, and Ed25519 signing public key
|
||||
- Capability flags
|
||||
- A bounded set of short direct-neighbor identifiers
|
||||
- A coarse rendezvous geohash when the bridge capability is enabled
|
||||
|
||||
Announces no longer advertise this device's direct neighbours. That TLV carried up to ten peer IDs, so a single passive receiver could reconstruct the local adjacency graph — who is standing next to whom — with no need for multiple receivers or signal-strength trilateration. It is off by default (`TransportConfig.announceIncludesDirectNeighbors`). Neighbour lists from other peers are still parsed, so a mixed network behaves sensibly. The cost is source routing: its adjacency map comes from these lists, so directed traffic falls back to flooding, which is already the documented fallback whenever a route fails.
|
||||
|
||||
The app does not advertise the device's assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address.
|
||||
|
||||
That randomization does not deliver the unlinkability it might suggest, because the application layer publishes stable identifiers above it. The 8-byte peer ID in every packet header is the first 8 bytes of the Noise static key fingerprint, so it does not rotate; announces carry the static keys themselves; and the fixed service UUID makes any bitchat device detectable as such by a passive scanner. A receiver in radio range can therefore recognise a specific device across sessions and locations, and detect that the app is in use at all. RSSI, timing, traffic volume, and radio fingerprints remain observable as well.
|
||||
|
||||
Public broadcasts are originated with a TTL drawn from a range rather than always at the maximum. A fixed maximum made `ttl == default` a reliable "this device wrote it" marker to any direct listener — disclosing authorship, not merely presence. Lower draws are ambiguous between an origin and a relay, at the cost of fewer hops for some messages. Announces keep the fixed TTL, because link binding treats a maximum-TTL announce as a direct link and an announce already identifies its sender.
|
||||
|
||||
Payload length remains observable for most traffic: only Noise frames are padded, and the padding itself is inside the signed bytes, so widening its coverage or fixing its length-marker gap is a coordinated cross-platform change rather than a local one.
|
||||
|
||||
Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded.
|
||||
|
||||
## Private Messaging and Courier Delivery
|
||||
|
||||
Reference in New Issue
Block a user