Feature/location channels (#459)

* Require signed announces; add Ed25519 key/signature/timestamp TLVs and verify

- Protocol: AnnouncementPacket now requires ed25519PublicKey (0x03), announceSignature (0x04), and announceTimestamp (0x05). Encoder emits, decoder requires; unknown TLVs still tolerated.
- NoiseEncryptionService: add canonical announce sign/verify helpers using context 'bitchat-announce-v1'.
- BLEService: sign Announce, include TLVs; on receive verify (±5 min skew) and ignore unverified; store ed25519 key and isVerifiedNickname in PeerInfo.
- Preserve 8-byte on-wire IDs to keep BLE headers small; no per-message bloat.

* Fix Data.append usage for timestamp bytes in Packets and NoiseEncryptionService (use append(contentsOf:) on UnsafeRawBufferPointer)

* BLEService: fix non-optional announce fields and unwrap signature result; enforce required verification path

* Enforce verified-only public messages; append peerID suffix on nickname collisions in handleMessage

* Suffix duplicate nicknames with peerID prefix in snapshots and getPeerNicknames; ensures lists and messages disambiguate 'jack' vs 'jack'

* Include self nickname in collision detection: suffix remote 'jack' when it matches our nickname in lists and public chat

* UI labels: remove space before '#abcd' suffix in names (public chat, peer lists, snapshots)

* Style '#abcd' suffix light gray:
- Public chat: color suffix in sender via AttributedString segments
- People list: split name and color suffix segment; add helper splitNameSuffix()

* Debounce 'bitchatters nearby' notification: add 60s grace before reset when mesh goes empty; cancel reset when peers return

* Lighten '#abcd' suffix: use Color.secondary.opacity(0.6) in public chat sender and People list

* Toolbar peers indicator: use blue when Bluetooth peers present (was default text color)

* Toolbar peers indicator: use grey when zero peers (was red)

* Toolbar peers indicator: use system grey (Color.secondary) when zero peers, not green

* Fix crash: mutate peripherals dict on BLE queue (not main). Move scan restart to BLE queue as well.

* Reduce connect timeout churn: back off peripherals that time out (15s), increase timeout to 8s, skip non-connectable adverts, and demote timeout log to debug; clean backoff map periodically

* Mentions: support '@name#abcd' disambiguation; filter hashtag/URL styling inside mentions; deliver notifications only to the specified suffixed target when collisions exist

* Fix string interpolation in mention log (escape sequence)

* Mentions: parse '@name#abcd' in sender/receiver; render mention suffix grey (not orange/blue/underlined); ensure receiver notifications trigger for suffixed tokens

* Mentions: include own suffixed token 'name#<myIDprefix>' in valid tokens so '@name#abcd' to self is recognized and notified

* Public actions: show own system message by processing own public messages (remove early-return). Private /hug: add local system message for sender's chat.

* Grammar: change local '/hug' message to past tense ('you hugged/slapped'). Notifications: only fire 'bitchatters nearby' on rising-edge; remove 5-min re-fire and increase empty reset grace to 10m.

* Public /hug echo: add local system message so sender sees action immediately (no reliance on echo)

* Fix visibility: expose addPublicSystemMessage on ChatViewModel and use it from CommandProcessor

* Implement iOS Location Channels (geohash public chats)

- Domain: add ChannelID, GeohashChannel(Level), lightweight geohash encoder
- Identity: derive per-geohash Nostr keys (HMAC-SHA256 device seed)
- Nostr: kind 20000 + #g tag, nickname tag (n), filter helper
- iOS: LocationChannelManager (permissions, one-shot location), Info.plist string
- UI: toolbar # badge (#mesh or #<level>), sheet (irc style, full-row taps, settings link)
- VM: subscribe/send for active channel, clear timeline on switch, nickname cache
- Emotes: route public via Nostr/mesh without echo, local system confirmation, emojis restored
- Haptics: detect hugs/slaps for nickname#abcd and trigger on receiver
- Rendering: remove hashtag/mention formatting (plain monospaced)

Note: iOS-only; macOS unaffected.

* Lifecycle and persistence: resubscribe on foreground/relay connect, cap dedup set, persist mesh timeline.

- ChatViewModel: resubscribe geohash on app foreground and relay reconnect
- Add processed Nostr events cap (2k) with eviction
- Persist mesh public messages in meshTimeline; hydrate on switching to #mesh
- Local geochat messages use nostr: senderPeerID for classification
- Tests: fix JSON contains assertion for #g tag

* Fix Swift 6 concurrency warnings: remove closure-based NC observer, wrap relay reconnect resubscribe in Task @MainActor, resubscribe in appDidBecomeActive selector.

* Location Channels polish: live geohash refresh while sheet open; keep selection stable; toolbar shows #<geohash>; sheet labels show human level + #geohash.

* Add per-geohash in-memory timelines and cap private chats to 1337.

- ChatViewModel: persist geohash messages in geoTimelines[geohash] with cap; hydrate timeline on channel switch
- PrivateChatManager: trim per-peer chats to 1337 on send/receive
- Toolbar badge: prevent wrapping with lineLimit(1)/truncation

* Toolbar badge layout + brand color; Teleport custom geohash; Live refresh uses startUpdatingLocation with significant distance.

- Toolbar: right-justify geohash, head truncation, use brand green
- Sheet: add #custom geohash textfield + join; keep minimal IRClike style
- Manager: startUpdatingLocation()/stopUpdatingLocation() with distanceFilter=250 while sheet is open

* Location sheet live updates: reduce distanceFilter to 21m for more frequent geohash refreshes while open.

* Toolbar badge spacing/width; Sheet: rename to #geohash and button 'teleport'.

- Move # label closer to peer count and widen to avoid truncation
- Change custom field placeholder to '#geohash' and action button to 'teleport' with monospaced font

* Sheet polish: style 'teleport' button with subtle background, disable until valid; prefix '#' label and placeholder 'geohash'; center + style 'remove location permission' and hide separators for these rows.  Toolbar: adjust spacing/width for # label.

* Sheet UX: restrict custom geohash input to valid base32 (lowercase, max 12), reduce spacing so placeholder sits closer to '#', add divider under country row, style 'remove permission' and hide row separators as before. Toolbar: minor spacing/width already adjusted.

* Sheet behavior: show channel list even without permission; add green 'get location and my geohash' button; ensure only one separator between country and teleport by removing manual Divider and showing default row separator; refine teleport input filtering and spacing.

* Channel activity nudges: notify after 9 minutes inactivity only in background; triple-tap clear also clears persistent timelines.

- ChatViewModel: track last activity per channel; send local notification when new activity resumes while app in background (with small cooldown)
- CommandProcessor: /clear clears current public channel persistence via viewModel helper

* Fix compile: add activeChannelDisplayName() and clearCurrentPublicTimeline() helpers in ChatViewModel.

* Fix concurrent dictionary access in BLEService.broadcastPacket: snapshot shared collections under collectionsQueue to avoid CocoaDictionary iterator crashes.

* People: channel-aware list and counts

- Sidebar: NETWORK → PEOPLE; removed PEOPLE subheader/icon
- Toolbar: blue #mesh badge; green #<geohash> badge; count color by channel
- Geohash participants: track per-geohash unique senders with 5m decay; publish list
- Update on geohash send/receive; 30s prune timer; channel switch hooks

No changes to mesh peer UX; mesh list retained as-is.

* BLE: snapshot collections for thread-safe access; stopServices uses snapshot; safe characteristic snapshot in broadcast path

LocationChannelsSheet: rename button label to 'remove location access'

* Channel sheet: show current peer counts

- Mesh row shows connected mesh peers count
- Geohash rows show 5m-active participant counts via ViewModel
- Live-updates while sheet is open

* Channel sheet: pluralize counts as 'person/people' in titles

* Geohash sampling: subscribe to all available channels while sheet open

- ViewModel: add multi-channel sampling subscriptions and per-geohash participant updates
- Sheet: start sampling on appear, sync on list changes, stop on disappear

* Channel sheet: render counts '(N person/people)' in smaller font next to label

* Channel sheet: use square brackets for counts and adjust parsing; fix mesh title to '#mesh'

* Geohash DMs: implement send/receive via NIP-17 with per-geohash identity

- NostrEmbeddedBitChat: add no-recipient encoder for geohash DMs
- NostrTransport: add sendPrivateMessageGeohash(using provided identity)
- ChatViewModel:
  • startGeohashDM + mapping helpers
  • subscribe to per-geohash gift wraps; store DMs in conversations keyed by 'nostr_<prefix>'
  • route sendPrivateMessage() for 'nostr_' to Nostr geohash send; local echo and status
  • map pubkeys from geohash public events for DM initiation
  • resubscribe DM feed on reconnect and channel switch; unsubscribe on leave
- ContentView: long-press 'private message' starts geohash DM when sender is nostr

* Geohash DMs UX: tap participants to DM; support /msg nickname in geohash; DM notifications

- People (geohash) list: bold 'you', sort to top, tap to open DM
- ChatViewModel.getPeerIDForNickname resolves geohash names to nostr_ conv keys
- Geohash DM receive: send local notification when not viewing

* Geohash DMs polish: header title, star hidden, self-DM blocked, message icon, delivered/read ACKs

- Header shows '#<geohash>/@name#abcd' for geohash DMs; hide favorite star
- Geohash people row: add small message icon; prevent self tap
- Prevent sending geohash DM to self
- Send delivery/read ACKs on receive; handle delivered/read to update status

* Geohash DMs: hide encryption status icon in DM header

* BLE: fix data race in broadcast path

- Snapshot peripherals via Array(values) and filter outside sync
- Snapshot subscribedCentrals and centralToPeerID under collectionsQueue
- Mutate subscribedCentrals under collectionsQueue barriers in didSubscribe/unsubscribe

* LocationChannelsSheet: open fully by default (large detent only)

* BLE: remove unused centralMapSnapshot variable

* Geohash DMs: only notify on incoming PM when app is backgrounded; avoid foreground noise

* GeoDM: reliable delivered/read receipts, background-only notifications, and UI tweak

- Implement delivered + read receipts for geohash DMs; send READ on open\n- Handle relay OK acks for gift-wrap sends; resubscribe processes PM/DELIVERED/READ\n- Prevent mesh Noise handshakes for virtual geohash peers (nostr_*)\n- Notify GeoDMs only in background; suppress alerts for already-read msgs\n- Logging: promote key GeoDM receive logs; demote/remove verbose noise\n- UI: remove trailing envelope button from geohash People list

* Fix: remove duplicate variable declarations in AnnouncementPacket.decode()

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-20 01:10:23 +02:00
committed by GitHub
co-authored by jack
parent 1c33a92765
commit b09710a7aa
18 changed files with 2143 additions and 258 deletions
+43
View File
@@ -53,6 +53,49 @@ struct NostrEmbeddedBitChat {
return "bitchat1:" + base64URLEncode(data)
}
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + base64URLEncode(data)
}
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
var payload = Data([NoisePayloadType.privateMessage.rawValue])
payload.append(tlv)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + base64URLEncode(data)
}
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
+53
View File
@@ -1,6 +1,7 @@
import Foundation
import CryptoKit
import P256K
import Security
// Keychain helper for secure storage
struct KeychainHelper {
@@ -104,6 +105,7 @@ struct NostrIdentity: Codable {
struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed"
/// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
@@ -145,11 +147,62 @@ struct NostrIdentityBridge {
static func clearAllAssociations() {
// Delete current Nostr identity
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
// Note: We can't efficiently delete all noise-nostr associations
// without tracking them, but they'll be orphaned and eventually cleaned up
// The important part is deleting the current identity so a new one is generated
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private static func getOrCreateDeviceSeed() -> Data {
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService)
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
var combined = Data()
combined.append(seed)
combined.append(msg)
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
return try NostrIdentity(privateKeyData: fallback)
}
}
// Bech32 encoding for Nostr (minimal implementation)
+23 -1
View File
@@ -97,6 +97,28 @@ struct NostrProtocol {
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
}
/// Create a geohash-scoped ephemeral public message (kind 20000)
static func createEphemeralGeohashEvent(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty {
tags.append(["n", nickname])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let signingKey = try senderIdentity.signingKey()
return try event.sign(with: signingKey)
}
// MARK: - Private Methods
@@ -554,4 +576,4 @@ enum NostrError: Error {
case invalidCiphertext
case signingFailed
case encryptionFailed
}
}
+22 -5
View File
@@ -6,6 +6,11 @@ import Combine
@MainActor
class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
}
struct Relay: Identifiable {
let id = UUID()
@@ -260,8 +265,8 @@ class NostrRelayManager: ObservableObject {
let event = try NostrEvent(from: eventDict)
// Only log critical events
if event.kind != 1059 { // Don't log every gift wrap
// Only log non-gift-wrap events to reduce noise
if event.kind != 1059 {
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)",
category: SecureLogger.session, level: .debug)
}
@@ -293,11 +298,13 @@ class NostrRelayManager: ObservableObject {
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success {
SecureLogger.log("✅ Event \(eventId.prefix(16))... accepted by relay: \(relayUrl)",
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
category: SecureLogger.session, level: .debug)
} else {
SecureLogger.log("📮 Event \(eventId) rejected by relay: \(reason)",
category: SecureLogger.session, level: .error)
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
}
}
@@ -542,6 +549,16 @@ struct NostrFilter: Encodable {
filter.limit = 100 // Add a reasonable limit
return filter
}
// For location channels: geohash-scoped ephemeral events (kind 20000)
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters