mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:05:19 +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>
673 lines
25 KiB
Swift
673 lines
25 KiB
Swift
//
|
|
// NoiseEncryptionService.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
///
|
|
/// # NoiseEncryptionService
|
|
///
|
|
/// High-level encryption service that manages Noise Protocol sessions for secure
|
|
/// peer-to-peer communication in BitChat. Acts as the bridge between the transport
|
|
/// layer (BLEService) and the cryptographic layer (NoiseProtocol).
|
|
///
|
|
/// ## Overview
|
|
/// This service provides a simplified API for establishing and managing encrypted
|
|
/// channels between peers. It handles:
|
|
/// - Static identity key management
|
|
/// - Session lifecycle (creation, maintenance, teardown)
|
|
/// - Message encryption/decryption
|
|
/// - Peer authentication and fingerprint tracking
|
|
/// - Automatic rekeying for forward secrecy
|
|
///
|
|
/// ## Architecture
|
|
/// The service operates at multiple levels:
|
|
/// 1. **Identity Management**: Persistent Curve25519 keys stored in Keychain
|
|
/// 2. **Session Management**: Per-peer Noise sessions with state tracking
|
|
/// 3. **Message Processing**: Encryption/decryption with proper framing
|
|
/// 4. **Security Features**: Rate limiting, fingerprint verification
|
|
///
|
|
/// ## Key Features
|
|
///
|
|
/// ### Identity Keys
|
|
/// - Static Curve25519 key pair for Noise XX pattern
|
|
/// - Ed25519 signing key pair for additional authentication
|
|
/// - Keys persisted securely in iOS/macOS Keychain
|
|
/// - Fingerprints derived from SHA256 of public keys
|
|
///
|
|
/// ### Session Management
|
|
/// - Lazy session creation (on-demand when sending messages)
|
|
/// - Automatic session recovery after disconnections
|
|
/// - Configurable rekey intervals for forward secrecy
|
|
/// - Graceful handling of simultaneous handshakes
|
|
///
|
|
/// ### Security Properties
|
|
/// - Forward secrecy via ephemeral keys in handshakes
|
|
/// - Mutual authentication via static key exchange
|
|
/// - Protection against replay attacks
|
|
/// - Rate limiting to prevent DoS attacks
|
|
///
|
|
/// ## Encryption Flow
|
|
/// ```
|
|
/// 1. Message arrives for encryption
|
|
/// 2. Check if session exists for peer
|
|
/// 3. If not, initiate Noise handshake
|
|
/// 4. Once established, encrypt message
|
|
/// 5. Add message type header for protocol handling
|
|
/// 6. Return encrypted payload for transmission
|
|
/// ```
|
|
///
|
|
/// ## Integration Points
|
|
/// - **BLEService**: Calls this service for all private messages
|
|
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
|
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
|
/// - **KeychainManager**: Secure storage for identity keys
|
|
///
|
|
/// ## Thread Safety
|
|
/// - Concurrent read access via reader-writer queue
|
|
/// - Session operations protected by per-peer queues
|
|
/// - Atomic updates for critical state changes
|
|
///
|
|
/// ## Error Handling
|
|
/// - Graceful fallback for encryption failures
|
|
/// - Clear error messages for debugging
|
|
/// - Automatic retry with exponential backoff
|
|
/// - User notification for critical failures
|
|
///
|
|
/// ## Performance Considerations
|
|
/// - Sessions cached in memory for fast access
|
|
/// - Minimal allocations in hot paths
|
|
/// - Efficient binary message format
|
|
/// - Background queue for CPU-intensive operations
|
|
///
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
import os.log
|
|
|
|
// MARK: - Encryption Status
|
|
|
|
/// Represents the current encryption status of a peer connection.
|
|
/// Used for UI indicators and decision-making about message handling.
|
|
enum EncryptionStatus: Equatable {
|
|
case none // Failed or incompatible
|
|
case noHandshake // No handshake attempted yet
|
|
case noiseHandshaking // Currently establishing
|
|
case noiseSecured // Established but not verified
|
|
case noiseVerified // Established and verified
|
|
|
|
var icon: String? { // Made optional to hide icon when no handshake
|
|
switch self {
|
|
case .none:
|
|
return "lock.slash" // Failed handshake
|
|
case .noHandshake:
|
|
return nil // No icon when no handshake attempted
|
|
case .noiseHandshaking:
|
|
return "lock.rotation"
|
|
case .noiseSecured:
|
|
return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock
|
|
case .noiseVerified:
|
|
return "checkmark.seal.fill" // Verified badge
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .none:
|
|
return "Encryption failed"
|
|
case .noHandshake:
|
|
return "Not encrypted"
|
|
case .noiseHandshaking:
|
|
return "Establishing encryption..."
|
|
case .noiseSecured:
|
|
return "Encrypted"
|
|
case .noiseVerified:
|
|
return "Encrypted & Verified"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Noise Encryption Service
|
|
|
|
/// Manages end-to-end encryption for BitChat using the Noise Protocol Framework.
|
|
/// Provides a high-level API for establishing secure channels between peers,
|
|
/// handling all cryptographic operations transparently.
|
|
/// - Important: This service maintains the device's cryptographic identity
|
|
class NoiseEncryptionService {
|
|
// Static identity key (persistent across sessions)
|
|
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
|
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
|
|
|
|
// Ed25519 signing key (persistent across sessions)
|
|
private let signingKey: Curve25519.Signing.PrivateKey
|
|
public let signingPublicKey: Curve25519.Signing.PublicKey
|
|
|
|
// Session manager
|
|
private let sessionManager: NoiseSessionManager
|
|
|
|
// Peer fingerprints (SHA256 hash of static public key)
|
|
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
|
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
|
|
|
// Thread safety
|
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
|
|
|
// Security components
|
|
private let rateLimiter = NoiseRateLimiter()
|
|
|
|
// Session maintenance
|
|
private var rekeyTimer: Timer?
|
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
|
|
|
// Callbacks
|
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
|
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
|
|
|
// Add a handler for peer authentication
|
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
|
}
|
|
}
|
|
|
|
// Legacy support - setting this will add to the handlers array
|
|
var onPeerAuthenticated: ((String, String) -> Void)? {
|
|
get { nil } // Always return nil for backward compatibility
|
|
set {
|
|
if let handler = newValue {
|
|
addOnPeerAuthenticatedHandler(handler)
|
|
}
|
|
}
|
|
}
|
|
|
|
init() {
|
|
// Load or create static identity key (ONLY from keychain)
|
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
|
|
|
// Try to load from keychain
|
|
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
|
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
|
loadedKey = key
|
|
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true)
|
|
}
|
|
// If no identity exists, create new one
|
|
else {
|
|
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
let keyData = loadedKey.rawRepresentation
|
|
|
|
// Save to keychain
|
|
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
|
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved)
|
|
}
|
|
|
|
// Now assign the final value
|
|
self.staticIdentityKey = loadedKey
|
|
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
|
|
|
// Load or create signing key pair
|
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
|
|
|
// Try to load from keychain
|
|
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
|
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
|
loadedSigningKey = key
|
|
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true)
|
|
}
|
|
// If no signing key exists, create new one
|
|
else {
|
|
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
let keyData = loadedSigningKey.rawRepresentation
|
|
|
|
// Save to keychain
|
|
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
|
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved)
|
|
}
|
|
|
|
// Now assign the signing keys
|
|
self.signingKey = loadedSigningKey
|
|
self.signingPublicKey = signingKey.publicKey
|
|
|
|
// Initialize session manager
|
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey)
|
|
|
|
// Set up session callbacks
|
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
|
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
|
}
|
|
|
|
// Start session maintenance timer
|
|
startRekeyTimer()
|
|
}
|
|
|
|
// MARK: - Public Interface
|
|
|
|
/// Get our static public key for sharing
|
|
func getStaticPublicKeyData() -> Data {
|
|
return staticIdentityPublicKey.rawRepresentation
|
|
}
|
|
|
|
/// Get our signing public key for sharing
|
|
func getSigningPublicKeyData() -> Data {
|
|
return signingPublicKey.rawRepresentation
|
|
}
|
|
|
|
/// Get our identity fingerprint
|
|
func getIdentityFingerprint() -> String {
|
|
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
|
return hash.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
/// Get peer's public key data
|
|
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
|
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
|
}
|
|
|
|
/// Clear persistent identity (for panic mode)
|
|
func clearPersistentIdentity() {
|
|
// Clear from keychain
|
|
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
|
|
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
|
|
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning)
|
|
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning)
|
|
// Stop rekey timer
|
|
stopRekeyTimer()
|
|
}
|
|
|
|
/// Sign data with our Ed25519 signing key
|
|
func signData(_ data: Data) -> Data? {
|
|
do {
|
|
let signature = try signingKey.signature(for: data)
|
|
return signature
|
|
} catch {
|
|
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Verify signature with a peer's Ed25519 public key
|
|
func verifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
|
|
do {
|
|
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
|
|
return signingPublicKey.isValidSignature(signature, for: data)
|
|
} catch {
|
|
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Announce Signature Helpers
|
|
|
|
/// Build the canonical announce binding message bytes and sign with our Ed25519 key
|
|
/// - Parameters:
|
|
/// - peerID: 8-byte routing ID (as in packet header)
|
|
/// - noiseKey: 32-byte Curve25519.KeyAgreement public key
|
|
/// - ed25519Key: 32-byte Ed25519 public key (self)
|
|
/// - nickname: UTF-8 nickname (<=255 bytes)
|
|
/// - timestampMs: UInt64 milliseconds since epoch
|
|
/// - Returns: Ed25519 signature over the canonical bytes, or nil on failure
|
|
func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? {
|
|
let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)
|
|
return signData(message)
|
|
}
|
|
|
|
/// Verify an announce signature
|
|
func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool {
|
|
let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)
|
|
return verifySignature(signature, for: message, publicKey: publicKey)
|
|
}
|
|
|
|
/// Build canonical bytes for announce signing.
|
|
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
|
var out = Data()
|
|
// context
|
|
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
|
out.append(UInt8(min(context.count, 255)))
|
|
out.append(context.prefix(255))
|
|
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
|
let peerID8 = peerID.prefix(8)
|
|
out.append(peerID8)
|
|
if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) }
|
|
// noise static key (expect 32)
|
|
let noise32 = noiseKey.prefix(32)
|
|
out.append(noise32)
|
|
if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) }
|
|
// ed25519 public key (expect 32)
|
|
let ed32 = ed25519Key.prefix(32)
|
|
out.append(ed32)
|
|
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
|
|
// nickname length + bytes
|
|
let nickData = nickname.data(using: .utf8) ?? Data()
|
|
out.append(UInt8(min(nickData.count, 255)))
|
|
out.append(nickData.prefix(255))
|
|
// timestamp
|
|
var ts = timestampMs.bigEndian
|
|
withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) }
|
|
return out
|
|
}
|
|
|
|
// MARK: - Packet Signing/Verification
|
|
|
|
/// Sign a BitchatPacket using the noise private key
|
|
func signPacket(_ packet: BitchatPacket) -> BitchatPacket? {
|
|
// Create canonical packet bytes for signing
|
|
guard let packetData = packet.toBinaryDataForSigning() else {
|
|
return nil
|
|
}
|
|
|
|
// Sign with the noise private key (converted to Ed25519 for signing)
|
|
guard let signature = signData(packetData) else {
|
|
return nil
|
|
}
|
|
|
|
// Return new packet with signature
|
|
var signedPacket = packet
|
|
signedPacket.signature = signature
|
|
return signedPacket
|
|
}
|
|
|
|
/// Verify a BitchatPacket signature using the provided public key
|
|
func verifyPacketSignature(_ packet: BitchatPacket, publicKey: Data) -> Bool {
|
|
guard let signature = packet.signature else {
|
|
return false
|
|
}
|
|
|
|
// Create canonical packet bytes for verification (without signature)
|
|
|
|
guard let packetData = packet.toBinaryDataForSigning() else {
|
|
return false
|
|
}
|
|
|
|
// For noise public keys, we need to derive the Ed25519 key for verification
|
|
// This assumes the noise key can be used for Ed25519 signing
|
|
return verifySignature(signature, for: packetData, publicKey: publicKey)
|
|
}
|
|
|
|
|
|
// MARK: - Handshake Management
|
|
|
|
/// Initiate a Noise handshake with a peer
|
|
func initiateHandshake(with peerID: String) throws -> Data {
|
|
|
|
// Validate peer ID
|
|
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
|
|
throw NoiseSecurityError.invalidPeerID
|
|
}
|
|
|
|
// Check rate limit
|
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
|
|
throw NoiseSecurityError.rateLimitExceeded
|
|
}
|
|
|
|
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID))
|
|
|
|
// Return raw handshake data without wrapper
|
|
// The Noise protocol handles its own message format
|
|
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
|
return handshakeData
|
|
}
|
|
|
|
/// Process an incoming handshake message
|
|
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
|
|
|
// Validate peer ID
|
|
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
|
|
throw NoiseSecurityError.invalidPeerID
|
|
}
|
|
|
|
// Validate message size
|
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning)
|
|
throw NoiseSecurityError.messageTooLarge
|
|
}
|
|
|
|
// Check rate limit
|
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
|
|
throw NoiseSecurityError.rateLimitExceeded
|
|
}
|
|
|
|
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
|
// The Noise protocol handles its own message format
|
|
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
|
|
|
|
|
|
// Return raw response without wrapper
|
|
return responsePayload
|
|
}
|
|
|
|
/// Check if we have an established session with a peer
|
|
func hasEstablishedSession(with peerID: String) -> Bool {
|
|
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
|
}
|
|
|
|
/// Check if we have a session (established or handshaking) with a peer
|
|
func hasSession(with peerID: String) -> Bool {
|
|
return sessionManager.getSession(for: peerID) != nil
|
|
}
|
|
|
|
// MARK: - Encryption/Decryption
|
|
|
|
/// Encrypt data for a specific peer
|
|
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
|
// Validate message size
|
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
|
throw NoiseSecurityError.messageTooLarge
|
|
}
|
|
|
|
// Check rate limit
|
|
guard rateLimiter.allowMessage(from: peerID) else {
|
|
throw NoiseSecurityError.rateLimitExceeded
|
|
}
|
|
|
|
// Check if we have an established session
|
|
guard hasEstablishedSession(with: peerID) else {
|
|
// Signal that handshake is needed
|
|
onHandshakeRequired?(peerID)
|
|
throw NoiseEncryptionError.handshakeRequired
|
|
}
|
|
|
|
return try sessionManager.encrypt(data, for: peerID)
|
|
}
|
|
|
|
/// Decrypt data from a specific peer
|
|
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
|
// Validate message size
|
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
|
throw NoiseSecurityError.messageTooLarge
|
|
}
|
|
|
|
// Check rate limit
|
|
guard rateLimiter.allowMessage(from: peerID) else {
|
|
throw NoiseSecurityError.rateLimitExceeded
|
|
}
|
|
|
|
// Check if we have an established session
|
|
guard hasEstablishedSession(with: peerID) else {
|
|
throw NoiseEncryptionError.sessionNotEstablished
|
|
}
|
|
|
|
return try sessionManager.decrypt(data, from: peerID)
|
|
}
|
|
|
|
// MARK: - Peer Management
|
|
|
|
/// Get fingerprint for a peer
|
|
func getPeerFingerprint(_ peerID: String) -> String? {
|
|
return serviceQueue.sync {
|
|
return peerFingerprints[peerID]
|
|
}
|
|
}
|
|
|
|
/// Get peer ID for a fingerprint
|
|
func getPeerID(for fingerprint: String) -> String? {
|
|
return serviceQueue.sync {
|
|
return fingerprintToPeerID[fingerprint]
|
|
}
|
|
}
|
|
|
|
/// Remove a peer session
|
|
func removePeer(_ peerID: String) {
|
|
sessionManager.removeSession(for: peerID)
|
|
|
|
serviceQueue.sync(flags: .barrier) {
|
|
if let fingerprint = peerFingerprints[peerID] {
|
|
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
}
|
|
peerFingerprints.removeValue(forKey: peerID)
|
|
}
|
|
|
|
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
|
// Calculate fingerprint
|
|
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
|
|
|
// Store fingerprint mapping
|
|
serviceQueue.sync(flags: .barrier) {
|
|
peerFingerprints[peerID] = fingerprint
|
|
fingerprintToPeerID[fingerprint] = peerID
|
|
}
|
|
|
|
// Log security event
|
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
|
|
|
// Notify all handlers about authentication
|
|
serviceQueue.async { [weak self] in
|
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
|
handler(peerID, fingerprint)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
|
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
|
return hash.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
// MARK: - Session Maintenance
|
|
|
|
private func startRekeyTimer() {
|
|
rekeyTimer = Timer.scheduledTimer(withTimeInterval: rekeyCheckInterval, repeats: true) { [weak self] _ in
|
|
self?.checkSessionsForRekey()
|
|
}
|
|
}
|
|
|
|
private func stopRekeyTimer() {
|
|
rekeyTimer?.invalidate()
|
|
rekeyTimer = nil
|
|
}
|
|
|
|
private func checkSessionsForRekey() {
|
|
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
|
|
|
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
|
|
|
// Attempt to rekey the session
|
|
do {
|
|
try sessionManager.initiateRekey(for: peerID)
|
|
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug)
|
|
|
|
// Signal that handshake is needed
|
|
onHandshakeRequired?(peerID)
|
|
} catch {
|
|
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session)
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
stopRekeyTimer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Protocol Message Types for Noise
|
|
|
|
/// Message types for the Noise encryption protocol layer.
|
|
/// These types wrap the underlying BitChat protocol messages with encryption metadata.
|
|
enum NoiseMessageType: UInt8 {
|
|
case handshakeInitiation = 0x10
|
|
case handshakeResponse = 0x11
|
|
case handshakeFinal = 0x12
|
|
case encryptedMessage = 0x13
|
|
case sessionRenegotiation = 0x14
|
|
}
|
|
|
|
// MARK: - Noise Message Wrapper
|
|
|
|
/// Container for encrypted messages in the Noise protocol.
|
|
/// Provides versioning and type information for proper message handling.
|
|
/// The actual message content is encrypted in the payload field.
|
|
struct NoiseMessage: Codable {
|
|
let type: UInt8
|
|
let sessionID: String // Random ID for this handshake session
|
|
let payload: Data
|
|
|
|
init(type: NoiseMessageType, sessionID: String, payload: Data) {
|
|
self.type = type.rawValue
|
|
self.sessionID = sessionID
|
|
self.payload = payload
|
|
}
|
|
|
|
func encode() -> Data? {
|
|
do {
|
|
let encoded = try JSONEncoder().encode(self)
|
|
return encoded
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func decode(from data: Data) -> NoiseMessage? {
|
|
return try? JSONDecoder().decode(NoiseMessage.self, from: data)
|
|
}
|
|
|
|
static func decodeWithError(from data: Data) -> NoiseMessage? {
|
|
do {
|
|
let decoded = try JSONDecoder().decode(NoiseMessage.self, from: data)
|
|
return decoded
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Binary Encoding
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
data.appendUInt8(type)
|
|
data.appendUUID(sessionID)
|
|
data.appendData(payload)
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> NoiseMessage? {
|
|
// Create defensive copy
|
|
let dataCopy = Data(data)
|
|
|
|
var offset = 0
|
|
|
|
guard let type = dataCopy.readUInt8(at: &offset),
|
|
let sessionID = dataCopy.readUUID(at: &offset),
|
|
let payload = dataCopy.readData(at: &offset) else { return nil }
|
|
|
|
guard let messageType = NoiseMessageType(rawValue: type) else { return nil }
|
|
|
|
return NoiseMessage(type: messageType, sessionID: sessionID, payload: payload)
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum NoiseEncryptionError: Error {
|
|
case handshakeRequired
|
|
case sessionNotEstablished
|
|
}
|