mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
* 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>
343 lines
12 KiB
Swift
343 lines
12 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
import P256K
|
|
import Security
|
|
|
|
// Keychain helper for secure storage
|
|
struct KeychainHelper {
|
|
static func save(key: String, data: Data, service: String) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
kSecValueData as String: data
|
|
]
|
|
|
|
SecItemDelete(query as CFDictionary)
|
|
SecItemAdd(query as CFDictionary, nil)
|
|
}
|
|
|
|
static func load(key: String, service: String) -> Data? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
kSecReturnData as String: true
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
|
|
guard status == errSecSuccess else { return nil }
|
|
return result as? Data
|
|
}
|
|
|
|
static func delete(key: String, service: String) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key
|
|
]
|
|
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|
|
|
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
|
struct NostrIdentity: Codable {
|
|
let privateKey: Data
|
|
let publicKey: Data
|
|
let npub: String // Bech32-encoded public key
|
|
let createdAt: Date
|
|
|
|
/// Memberwise initializer
|
|
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
|
self.privateKey = privateKey
|
|
self.publicKey = publicKey
|
|
self.npub = npub
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
/// Generate a new Nostr identity
|
|
static func generate() throws -> NostrIdentity {
|
|
// Generate Schnorr key for Nostr
|
|
let schnorrKey = try P256K.Schnorr.PrivateKey()
|
|
let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
|
|
let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
|
|
|
return NostrIdentity(
|
|
privateKey: schnorrKey.dataRepresentation,
|
|
publicKey: xOnlyPubkey, // Store x-only public key
|
|
npub: npub,
|
|
createdAt: Date()
|
|
)
|
|
}
|
|
|
|
/// Initialize from existing private key data
|
|
init(privateKeyData: Data) throws {
|
|
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData)
|
|
let xOnlyPubkey = Data(schnorrKey.xonly.bytes)
|
|
|
|
self.privateKey = privateKeyData
|
|
self.publicKey = xOnlyPubkey
|
|
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
|
self.createdAt = Date()
|
|
}
|
|
|
|
/// Get signing key for event signatures
|
|
func signingKey() throws -> P256K.Signing.PrivateKey {
|
|
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
|
}
|
|
|
|
/// Get Schnorr signing key for Nostr event signatures
|
|
func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {
|
|
try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey)
|
|
}
|
|
|
|
/// Get hex-encoded public key (for Nostr events)
|
|
var publicKeyHex: String {
|
|
// Public key is already stored as x-only (32 bytes)
|
|
return publicKey.hexEncodedString()
|
|
}
|
|
}
|
|
|
|
/// Bridge between Noise and Nostr identities
|
|
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? {
|
|
// Check if we already have a Nostr identity
|
|
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
return identity
|
|
}
|
|
|
|
// Generate new Nostr identity
|
|
let nostrIdentity = try NostrIdentity.generate()
|
|
|
|
// Store it
|
|
let data = try JSONEncoder().encode(nostrIdentity)
|
|
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
|
|
|
return nostrIdentity
|
|
}
|
|
|
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
if let data = nostrPubkey.data(using: .utf8) {
|
|
KeychainHelper.save(key: key, data: data, service: keychainService)
|
|
}
|
|
}
|
|
|
|
/// Get Nostr public key associated with a Noise public key
|
|
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
|
let pubkey = String(data: data, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return pubkey
|
|
}
|
|
|
|
/// Clear all Nostr identity associations and current identity
|
|
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)
|
|
enum Bech32 {
|
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
|
|
static func encode(hrp: String, data: Data) throws -> String {
|
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
let checksum = createChecksum(hrp: hrp, values: values)
|
|
let combined = values + checksum
|
|
|
|
return hrp + "1" + combined.map {
|
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
return String(charset[index])
|
|
}.joined()
|
|
}
|
|
|
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
// Find the last occurrence of '1'
|
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
throw Bech32Error.invalidFormat
|
|
}
|
|
|
|
let hrp = String(bech32String[..<separatorIndex])
|
|
|
|
// Validate HRP contains only ASCII characters
|
|
for char in hrp {
|
|
guard char.asciiValue != nil else {
|
|
throw Bech32Error.invalidCharacter
|
|
}
|
|
}
|
|
|
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
|
|
// Convert characters to values
|
|
var values = [UInt8]()
|
|
for char in dataString {
|
|
guard let index = charset.firstIndex(of: char) else {
|
|
throw Bech32Error.invalidCharacter
|
|
}
|
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
}
|
|
|
|
// Verify checksum
|
|
guard values.count >= 6 else {
|
|
throw Bech32Error.invalidChecksum
|
|
}
|
|
|
|
let payloadValues = Array(values.dropLast(6))
|
|
let checksum = Array(values.suffix(6))
|
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
|
|
guard checksum == expectedChecksum else {
|
|
throw Bech32Error.invalidChecksum
|
|
}
|
|
|
|
// Convert back to bytes
|
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
return (hrp: hrp, data: Data(bytes))
|
|
}
|
|
|
|
enum Bech32Error: Error {
|
|
case invalidFormat
|
|
case invalidCharacter
|
|
case invalidChecksum
|
|
}
|
|
|
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
var acc = 0
|
|
var bits = 0
|
|
var result = [UInt8]()
|
|
let maxv = (1 << to) - 1
|
|
|
|
for value in data {
|
|
acc = (acc << from) | Int(value)
|
|
bits += from
|
|
|
|
while bits >= to {
|
|
bits -= to
|
|
result.append(UInt8((acc >> bits) & maxv))
|
|
}
|
|
}
|
|
|
|
if pad && bits > 0 {
|
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
let polymod = polymod(checksumValues) ^ 1
|
|
var checksum = [UInt8]()
|
|
|
|
for i in 0..<6 {
|
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
}
|
|
|
|
return checksum
|
|
}
|
|
|
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
var result = [UInt8]()
|
|
for c in hrp {
|
|
guard let asciiValue = c.asciiValue else {
|
|
return [] // Return empty array for invalid input
|
|
}
|
|
result.append(UInt8(asciiValue >> 5))
|
|
}
|
|
result.append(0)
|
|
for c in hrp {
|
|
guard let asciiValue = c.asciiValue else {
|
|
return [] // Return empty array for invalid input
|
|
}
|
|
result.append(UInt8(asciiValue & 31))
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func polymod(_ values: [UInt8]) -> Int {
|
|
var chk = 1
|
|
for value in values {
|
|
let b = chk >> 25
|
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
for i in 0..<5 {
|
|
if (b >> i) & 1 == 1 {
|
|
chk ^= generator[i]
|
|
}
|
|
}
|
|
}
|
|
return chk
|
|
}
|
|
}
|
|
|
|
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|