Compare commits

...
Author SHA1 Message Date
jack acc11101ad Fix geohash linking: avoid links in @mentions
Only link #geohash tokens when not directly attached to an @mention (e.g., @name#gh stays plain). Keeps standalone #geohash tappable.
2025-08-25 09:31:41 +02:00
5fd9140ffa QR verification: live challenge/response over Noise; persistence, offline badges, and UX/perf polish (#510)
* QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header

* QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly

* QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now

* QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage

* QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist

* QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder)

* QR verification: speed + persistence + UX

- Inject live Noise into VerificationService; prewarm QR on app start
- Keep camera active; remove intermediate responder toast
- One-shot/dupe guards and deferred send on handshake
- Persist verified status immediately; standardize fingerprint (SHA-256)
- Show verified badge for offline favorites; mutual verification toast
- VERIFY sheet styling to match peer sheet; UI polish
- Logs to diagnose verified load + favorites mapping

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 23:19:58 +02:00
e6903456ab NIP‑17/44: XChaCha20 v2 gift wraps, DM kind=14, receipts + UI fixes (#506)
* NIP-17/44: adopt NIP-44 v2 (XChaCha20-Poly1305, v2: base64url), switch rumor kind to 14, randomize BIP-340 aux; add XChaCha20Poly1305Compat and wire-up

* Nostr DMs: ensure delivered/read acks are sent even without Noise key mapping by falling back to direct Nostr (geohash-style) acks to sender pubkey

* NIP-44 v2 decrypt: try both Y parities for x-only sender pubkeys (even then odd) to fix unwrap authentication failures

* Tests: add NIP-44 v2 ACK round-trip tests (delivered/read) using bitchat1 embedding and gift-wrap decrypt path

* UI: fix DM autoscroll IDs by using dm:<peer>|<id> for private chat item IDs and preserve anchors, ensuring scrollTo targets exist when opening/switching/new messages

* UI: fix string interpolation in DM/geohash context keys (remove escaped interpolation) to resolve 'unused ch' warnings and ensure proper IDs

* UI: MeshPeerList shows transport state icon: radio for mesh-connected, purple globe for mutual favorite/Nostr; fallback dim person for others

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 17:56:43 +02:00
85f99984c4 Unify DM notifications and fix geohash self-echo hydration (#505)
* feat(notifications): unify DM notifications across transports; notify for unread+recent even in foreground; delegate suppresses if chat open

* fix(geohash): include own past geohash messages on channel load; skip only very recent self-echo (<15s) to avoid duplicates

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 14:40:06 +02:00
16 changed files with 1246 additions and 157 deletions
+18
View File
@@ -140,6 +140,12 @@
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -258,6 +264,9 @@
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -428,6 +437,7 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
@@ -501,6 +511,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */,
@@ -522,6 +533,7 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup;
children = (
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
@@ -720,6 +732,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
@@ -774,6 +789,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
+7
View File
@@ -31,6 +31,13 @@ struct BitchatApp: App {
.environmentObject(chatViewModel)
.onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
+2
View File
@@ -37,6 +37,8 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
+91 -116
View File
@@ -1,6 +1,7 @@
import Foundation
import CryptoKit
import P256K
import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
@@ -12,8 +13,9 @@ struct NostrProtocol {
enum EventKind: Int {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-17 gift wrap
case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000
}
@@ -30,7 +32,7 @@ struct NostrProtocol {
let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
kind: .dm, // NIP-17: DM rumor kind 14
tags: [],
content: content
)
@@ -227,7 +229,7 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 style)
// MARK: - Encryption (NIP-44 v2)
private static func encrypt(
plaintext: String,
@@ -239,33 +241,31 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey
}
// Encrypting message
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// Derived shared secret
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// Generate nonce
let nonce = AES.GCM.Nonce()
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// Encrypt
let sealed = try AES.GCM.seal(
plaintext.data(using: .utf8)!,
using: SymmetricKey(data: sharedSecret),
nonce: nonce
)
// Combine nonce + ciphertext + tag
var result = Data()
result.append(nonce.withUnsafeBytes { Data($0) })
result.append(sealed.ciphertext)
result.append(sealed.tag)
return result.base64EncodedString()
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + base64URLEncode(combined)
}
private static func decrypt(
@@ -273,86 +273,45 @@ struct NostrProtocol {
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Decrypting message
guard let data = Data(base64Encoded: ciphertext),
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
category: SecureLogger.session, level: .error)
throw NostrError.invalidCiphertext
}
// Ciphertext data parsed
// Extract components
let nonceData = data.prefix(12)
let ciphertextData = data.dropFirst(12).dropLast(16)
let tagData = data.suffix(16)
// Components parsed
// Derive shared secret - try with default Y coordinate first
var sharedSecret: Data
var decrypted: Data? = nil
do {
sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: senderPubkeyData
let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct),
tag: Data(tag),
key: key,
nonce24: Data(nonce24)
)
// Derived shared secret with first Y coordinate
// Try to decrypt
let sealedBox = try AES.GCM.SealedBox(
nonce: AES.GCM.Nonce(data: nonceData),
ciphertext: ciphertextData,
tag: tagData
)
do {
decrypted = try AES.GCM.open(
sealedBox,
using: SymmetricKey(data: sharedSecret)
)
// AES-GCM decryption successful
} catch {
// AES-GCM decryption failed, trying alternate
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
if senderPubkeyData.count == 32 {
// Trying alternate Y coordinate
// Force deriveSharedSecret to use odd Y by manipulating the data
var altPubkey = Data()
altPubkey.append(0x03) // Force odd Y
altPubkey.append(senderPubkeyData)
sharedSecret = try deriveSharedSecretDirect(
privateKey: recipientKey,
publicKey: altPubkey
)
decrypted = try AES.GCM.open(
sealedBox,
using: SymmetricKey(data: sharedSecret)
)
// AES-GCM decryption successful with alternate Y
} else {
throw error
}
}
// If 32 bytes (x-only) try both parities, otherwise single try
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
}
} catch {
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
category: SecureLogger.session, level: .error)
throw error
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
}
guard let finalDecrypted = decrypted else {
throw NostrError.encryptionFailed
}
return String(data: finalDecrypted, encoding: .utf8) ?? ""
}
private static func deriveSharedSecret(
@@ -412,17 +371,8 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Derive key using HKDF for NIP-44 v2
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: "nip44-v2".data(using: .utf8)!,
info: Data(),
outputByteCount: 32
)
let result = derivedKey.withUnsafeBytes { Data($0) }
// Final derived key ready
return result
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
// Direct version that doesn't try to add prefixes
@@ -452,15 +402,8 @@ struct NostrProtocol {
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Derive key using HKDF for NIP-44 v2
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: "nip44-v2".data(using: .utf8)!,
info: Data(),
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
@@ -537,7 +480,10 @@ struct NostrEvent: Codable {
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -581,3 +527,32 @@ enum NostrError: Error {
case signingFailed
case encryptionFailed
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
private extension NostrProtocol {
static func base64URLEncode(_ data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func base64URLDecode(_ s: String) -> Data? {
var str = s
let pad = (4 - (str.count % 4)) % 4
if pad > 0 { str += String(repeating: "=", count: pad) }
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: str)
}
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: "nip44-v2".data(using: .utf8)!,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
}
}
+116
View File
@@ -0,0 +1,116 @@
import Foundation
import CryptoKit
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
/// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat {
struct SealBox {
let ciphertext: Data
let tag: Data
}
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let nonce = try ChaChaPoly.Nonce(data: nonce12)
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
}
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
}
// MARK: - Internals
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
var out = Data(count: 12)
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
return out
}
private static func hchacha20(key: Data, nonce16: Data) -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
precondition(key.count == 32)
precondition(nonce16.count == 16)
// Constants "expand 32-byte k"
var state: [UInt32] = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
// key (8 words)
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
// nonce (4 words)
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
]
// 20 rounds (10 double rounds)
for _ in 0..<10 {
// Column rounds
quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14)
}
// Output subkey: state[0..3] and state[12..15]
var out = Data(count: 32)
out.storeLEWord(state[0], at: 0)
out.storeLEWord(state[1], at: 4)
out.storeLEWord(state[2], at: 8)
out.storeLEWord(state[3], at: 12)
out.storeLEWord(state[12], at: 16)
out.storeLEWord(state[13], at: 20)
out.storeLEWord(state[14], at: 24)
out.storeLEWord(state[15], at: 28)
return out
}
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
}
}
private extension Data {
func loadLEWord(_ offset: Int) -> UInt32 {
let range = offset..<(offset+4)
let bytes = self[range]
return bytes.withUnsafeBytes { ptr -> UInt32 in
let b = ptr.bindMemory(to: UInt8.self)
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
}
}
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
let bytes: [UInt8] = [
UInt8(value & 0xff),
UInt8((value >> 8) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 24) & 0xff)
]
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
+5
View File
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String {
switch self {
case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt"
case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
}
}
}
+51 -1
View File
@@ -508,11 +508,51 @@ final class BLEService: NSObject {
func sendBroadcastAnnounce() {
sendAnnounce()
}
// MARK: - QR Verification over Noise
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
sendNoisePayload(payload, to: peerID)
}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
sendNoisePayload(payload, to: peerID)
}
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
guard noiseService.hasSession(with: peerID) else {
// Lazy-handshake path: queue? For now, initiate handshake and drop
initiateNoiseHandshake(with: peerID)
return
}
do {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} catch {
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error)
}
}
func getPeerFingerprint(_ peerID: String) -> String? {
return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey {
return publicKey.hexEncodedString()
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
}
return nil
}
@@ -1383,6 +1423,16 @@ final class BLEService: NSObject {
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
}
case .verifyChallenge:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
}
case .verifyResponse:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
}
default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
}
+4
View File
@@ -165,8 +165,12 @@ class CommandProcessor {
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
var geoNames: [String] = []
if let vm = chatViewModel {
#if os(iOS)
let visible = vm.visibleGeohashPeople()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
#else
let visibleIndex: [String: String] = [:]
#endif
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
geoNames.append(name)
+9
View File
@@ -46,11 +46,20 @@ protocol Transport: AnyObject {
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot]
}
extension Transport {
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
}
protocol TransportPeerEventsDelegate: AnyObject {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
}
+185
View File
@@ -0,0 +1,185 @@
import Foundation
import CryptoKit
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
final class VerificationService {
static let shared = VerificationService()
// Injected Noise service from the running transport (do NOT create new BLEService)
private var noise: NoiseEncryptionService?
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
/// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable {
let v: Int
let noiseKeyHex: String
let signKeyHex: String
let npub: String?
let nickname: String
let ts: Int64
let nonceB64: String
var sigHex: String
static let context = "bitchat-verify-v1"
/// Canonical bytes used for signature (deterministic ordering)
func canonicalBytes() -> Data {
var out = Data()
func appendField(_ s: String) {
let d = s.data(using: .utf8) ?? Data()
out.append(UInt8(min(d.count, 255)))
out.append(d.prefix(255))
}
appendField(Self.context)
appendField(String(v))
appendField(noiseKeyHex.lowercased())
appendField(signKeyHex.lowercased())
appendField(npub ?? "")
appendField(nickname)
appendField(String(ts))
appendField(nonceB64)
return out
}
func toURLString() -> String {
var comps = URLComponents()
comps.scheme = "bitchat"
comps.host = "verify"
comps.queryItems = [
URLQueryItem(name: "v", value: String(v)),
URLQueryItem(name: "noise", value: noiseKeyHex),
URLQueryItem(name: "sign", value: signKeyHex),
URLQueryItem(name: "nick", value: nickname),
URLQueryItem(name: "ts", value: String(ts)),
URLQueryItem(name: "nonce", value: nonceB64),
URLQueryItem(name: "sig", value: sigHex)
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
return comps.string ?? ""
}
static func fromURL(_ url: URL) -> VerificationQR? {
guard url.scheme == "bitchat", url.host == "verify",
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
guard let vStr = val("v"), let v = Int(vStr),
let noise = val("noise"), let sign = val("sign"),
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
let nonce = val("nonce"), let sig = val("sig") else { return nil }
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
}
}
// MARK: - Public API
/// Build a signed QR string for the current identity
func buildMyQRString(nickname: String, npub: String?) -> String? {
// Simple short-lived cache to speed up sheet opening
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value
}
guard let noise = noise else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
guard let sig = noise.signData(msg) else { return nil }
let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex,
npub: payload.npub,
nickname: payload.nickname,
ts: payload.ts,
nonceB64: payload.nonceB64,
sigHex: sig.map { String(format: "%02x", $0) }.joined())
let out = signed.toURLString()
Cache.last = (nickname, npub, Date(), out)
return out
}
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> VerificationQR? {
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness
let now = Date().timeIntervalSince1970
if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let noise = noise else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil
}
// MARK: - Noise payloads (scaffold only)
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
var tlv = Data()
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
tlv.append(contentsOf: n0)
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
tlv.append(0x02)
tlv.append(UInt8(min(nonceA.count, 255)))
tlv.append(nonceA.prefix(255))
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
}
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
// Sign context: verify-response | noiseKeyHex | nonceA
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
return NoisePayload(type: .verifyResponse, data: tlv).encode()
}
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
// Expect type already stripped; we receive only TLV here
// TLV 0x01 noiseKeyHex
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
// TLV 0x02 nonceA
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
return (noiseStr, nA)
}
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
return (noiseStr, nA, sig)
}
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub)
}
}
+215 -29
View File
@@ -412,6 +412,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Delivery tracking
private var cancellables = Set<AnyCancellable>()
// MARK: - QR Verification (pending state)
private struct PendingVerification {
let noiseKeyHex: String
let signKeyHex: String
let nonceA: Data
let startedAt: Date
var sent: Bool
}
private var pendingQRVerifications: [String: PendingVerification] = [:] // peerID -> pending
// Last handled challenge nonce per peer to avoid duplicate responses
private var lastVerifyNonceByPeer: [String: Data] = [:]
// Track when we last received a verify challenge from a peer (fingerprint-keyed)
private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint
// Throttle mutual verification toasts per fingerprint
private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint
// MARK: - Public message batching (UI perf)
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = []
@@ -450,6 +466,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
}
// Throttle verification response toasts per peer to avoid spam
private var lastVerifyToastAt: [String: Date] = [:]
// Track processed Nostr ACKs to avoid duplicate processing
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
@@ -719,7 +738,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.processedNostrEvents.insert(event.id)
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 { return }
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1]
self.geoNicknames[event.pubkey.lowercased()] = nick
@@ -833,16 +856,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.sentReadReceipts.insert(messageId)
}
} else {
// Optionally notify if app backgrounded and message is truly unread
#if os(iOS)
if shouldMarkUnread && UIApplication.shared.applicationState != .active {
// Notify for truly unread and recent messages when not viewing
if shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
#endif
}
self.objectWillChange.send()
case .delivered:
@@ -869,6 +890,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .warning)
}
}
case .verifyChallenge, .verifyResponse:
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
break
}
}
} catch { }
@@ -1389,11 +1413,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .info)
}
}
// Skip our own events (we already locally echoed)
// Skip only very recent self-echo from relay; include older self events for hydration
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 { return }
}
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
@@ -1521,19 +1546,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
// pared back: omit defer READ log
}
// Notify only when app is backgrounded and not viewing, and only if not already read
#if os(iOS)
// Notify for truly unread and recent messages when not viewing
if !isViewing && shouldMarkUnread {
if UIApplication.shared.applicationState != .active {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
// pared back: omit notification log
}
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
#endif
self.objectWillChange.send()
default:
// Handle delivered/read receipts for our sent messages
@@ -3163,12 +3183,28 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let token = String(matchText.dropFirst()).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
// Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)
let attachedToMention: Bool = {
// nsRange is the Range<String.Index> for this match within content
// Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention
if nsRange.lowerBound > content.startIndex {
var i = content.index(before: nsRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
}
return false
}()
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
tagStyle.foregroundColor = baseColor
if isGeohash, let url = URL(string: "bitchat://geohash/\(token)") {
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") {
tagStyle.link = url
tagStyle.underlineStyle = .single
}
@@ -3728,10 +3764,35 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Update encryption status after verification
updateEncryptionStatus(for: peerID)
}
@MainActor
func unverifyFingerprint(for peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false)
SecureIdentityStateManager.shared.forceSave()
verifiedFingerprints.remove(fingerprint)
updateEncryptionStatus(for: peerID)
}
@MainActor
func loadVerifiedFingerprints() {
// Load verified fingerprints directly from secure storage
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
// Log snapshot for debugging persistence
let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ")
SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info)
// Also log any offline favorites and whether we consider them verified
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
for fav in offlineFavorites {
let fp = unifiedPeerService.getFingerprint(for: fav.id)
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
let fpShort = fp?.prefix(8) ?? "nil"
SecureLogger.log("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: SecureLogger.security, level: .info)
}
// Invalidate cached encryption statuses so offline favorites can show verified badges immediately
invalidateEncryptionCache()
// Trigger UI refresh of peer list
objectWillChange.send()
}
private func setupNoiseCallbacks() {
@@ -3765,6 +3826,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .debug)
}
// If a QR verification is pending but not sent yet, send it now that session is authenticated
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.log("📤 Sent deferred verify challenge to \(peerID) after handshake", category: SecureLogger.security, level: .debug)
}
// Schedule UI update
// UI will update automatically
}
@@ -3868,6 +3937,72 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
objectWillChange.send()
}
}
case .verifyChallenge:
// Parse and respond
guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return }
// Ensure intended for our noise key
let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()
guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }
// Deduplicate: ignore if we've already responded to this nonce for this peer
if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return }
lastVerifyNonceByPeer[peerID] = tlv.nonceA
// Record inbound challenge time keyed by stable fingerprint if available
if let fp = getFingerprint(for: peerID) {
lastInboundVerifyChallengeAt[fp] = Date()
// If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)
if verifiedFingerprints.contains(fp) {
let now = Date()
let last = lastMutualToastAt[fp] ?? .distantPast
if now.timeIntervalSince(last) > 60 { // 1-minute throttle
lastMutualToastAt[fp] = now
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
NotificationService.shared.sendLocalNotification(
title: "Mutual verification",
body: "You and \(name) verified each other",
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
)
}
}
}
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)
// Silent response: no toast needed on responder
case .verifyResponse:
guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }
// Check pending for this peer
guard let pending = pendingQRVerifications[peerID] else { return }
guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }
// Verify signature with expected sign key
let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)
if ok {
pendingQRVerifications.removeValue(forKey: peerID)
if let fp = getFingerprint(for: peerID) {
let short = fp.prefix(8)
SecureLogger.log("🔐 Marking verified fingerprint: \(short)", category: SecureLogger.security, level: .info)
SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true)
SecureIdentityStateManager.shared.forceSave()
verifiedFingerprints.insert(fp)
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
NotificationService.shared.sendLocalNotification(
title: "Verified",
body: "You verified \(name)",
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
)
// If we also recently responded to their challenge, flag mutual and toast (initiator side)
if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 {
let now = Date()
let lastToast = lastMutualToastAt[fp] ?? .distantPast
if now.timeIntervalSince(lastToast) > 60 {
lastMutualToastAt[fp] = now
NotificationService.shared.sendLocalNotification(
title: "Mutual verification",
body: "You and \(name) verified each other",
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
)
}
}
updateEncryptionStatus(for: peerID)
}
}
}
}
}
@@ -3893,6 +4028,36 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// MARK: - QR Verification API
@MainActor
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
// Find a matching peer by Noise key
let targetNoise = qr.noiseKeyHex.lowercased()
guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {
return false
}
let peerID = peer.id
// If we already have a pending verification with this peer, don't send another
if pendingQRVerifications[peerID] != nil {
return true
}
// Generate nonceA
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false)
pendingQRVerifications[peerID] = pending
// If Noise session is established, send immediately; otherwise trigger handshake and send on auth
let noise = meshService.getNoiseService()
if noise.hasEstablishedSession(with: peerID) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
pendingQRVerifications[peerID] = pending
} else {
meshService.triggerHandshake(with: peerID)
}
return true
}
// Mention parsing moved from BLE use the existing non-optional helper below
// MARK: - Peer Connection Events
@@ -4482,10 +4647,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChatManager.sanitizeChat(for: ephemeralPeerID)
}
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
if !wasReadBefore, let key = actualSenderNoiseKey {
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
// Send delivery ack via Nostr embedded
if !wasReadBefore {
if let key = actualSenderNoiseKey {
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport()
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
SecureLogger.log("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug)
}
}
if wasReadBefore {
@@ -4496,11 +4669,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId)
if !sentReadReceipts.contains(messageId) {
if let key = actualSenderNoiseKey {
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport()
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(messageId)
SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug)
}
}
} else {
if shouldMarkAsUnread {
@@ -4538,6 +4719,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
objectWillChange.send()
}
case .verifyChallenge, .verifyResponse:
// Ignore verification payloads arriving via Nostr path for now
break
}
} catch {
@@ -4890,10 +5074,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Geohash Nickname Resolution (for /block in geohash)
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
// Look up current visible geohash participants for an exact displayName match
// Look up current visible geohash participants for an exact displayName match (iOS only)
#if os(iOS)
for p in visibleGeohashPeople() {
if p.displayName == name { return p.id }
}
#endif
return nil
}
+35 -2
View File
@@ -58,6 +58,7 @@ struct ContentView: View {
@State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@@ -291,13 +292,17 @@ struct ContentView: View {
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
@@ -396,13 +401,17 @@ struct ContentView: View {
let step = 200
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
#endif
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
@@ -926,6 +935,24 @@ struct ContentView: View {
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
// Show QR only on mesh channel's peer list
#if os(iOS)
if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
}
#else
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
#endif
}
.frame(height: 44) // Match header height
.padding(.horizontal, 12)
@@ -1216,6 +1243,8 @@ struct ContentView: View {
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
@@ -1223,6 +1252,10 @@ struct ContentView: View {
sidebarDragOffset = 0
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
}
}
.frame(height: 44)
.padding(.horizontal, 12)
+17 -2
View File
@@ -32,8 +32,9 @@ struct FingerprintView: View {
Spacer()
Button("DONE") {
dismiss()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
}
@@ -165,6 +166,20 @@ struct FingerprintView: View {
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
} else {
Button(action: {
viewModel.unverifyFingerprint(for: peerID)
dismiss()
}) {
Text("REMOVE VERIFICATION")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.red)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.top)
+38 -6
View File
@@ -44,9 +44,24 @@ struct MeshPeerList: View {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned
if isMe {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
Image(systemName: "person.fill")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isConnected {
// Mesh-connected peer: radio icon
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.system(size: 10))
.foregroundColor(.purple)
} else {
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
let displayName = isMe ? viewModel.nickname : peer.nickname
@@ -70,10 +85,27 @@ struct MeshPeerList: View {
.help("Blocked")
}
if let icon = item.enc.icon, !isMe {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
if !isMe {
if peer.isConnected {
if let icon = item.enc.icon {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
} else {
// Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.id),
viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if let icon = item.enc.icon {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
}
}
Spacer()
+347
View File
@@ -0,0 +1,347 @@
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
#if os(iOS)
import UIKit
#else
import AppKit
#endif
/// Placeholder view to display the user's verification QR payload as text.
struct MyQRView: View {
let qrString: String
@Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View {
VStack(spacing: 12) {
Text("scan to verify me")
.font(.system(size: 16, weight: .bold, design: .monospaced))
VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240)
.accessibilityLabel("verification qr code")
// Non-scrolling, fully visible URL (wraps across lines)
Text(qrString)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.background(boxColor)
.cornerRadius(8)
}
.padding()
.frame(maxWidth: .infinity)
.background(boxColor)
.cornerRadius(8)
}
.padding()
}
}
// Render a QR code image for a given string using CoreImage
struct QRCodeImage: View {
let data: String
let size: CGFloat
private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator()
var body: some View {
Group {
if let image = generateImage() {
ImageWrapper(image: image)
.frame(width: size, height: size)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size)
.overlay(
Text("qr unavailable")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
)
}
}
}
private func generateImage() -> CGImage? {
let inputData = Data(data.utf8)
filter.message = inputData
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
let scale = max(1, Int(size / 32))
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
return context.createCGImage(transformed, from: transformed.extent)
}
}
// Platform-specific wrapper to display CGImage in SwiftUI
struct ImageWrapper: View {
let image: CGImage
var body: some View {
#if os(iOS)
let ui = UIImage(cgImage: image)
return Image(uiImage: ui)
.interpolation(.none)
.resizable()
#else
let ns = NSImage(cgImage: image, size: .zero)
return Image(nsImage: ns)
.interpolation(.none)
.resizable()
#endif
}
}
/// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View {
@EnvironmentObject var viewModel: ChatViewModel
var isActive: Bool = true
@State private var input = ""
@State private var result: String = "" // not shown for iOS scanner
@State private var lastValid: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
#if os(iOS)
CameraScannerView(isActive: isActive) { code in
if let qr = VerificationService.shared.verifyScannedQR(code) {
let ok = viewModel.beginQRVerification(with: qr)
if !ok { /* already pending; continue scanning */ }
lastValid = code
} else {
// ignore invalid reads; continue scanning
}
}
.frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
Text("paste qr content to validate:")
.font(.system(size: 14, weight: .medium, design: .monospaced))
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
Button("validate") {
if let qr = VerificationService.shared.verifyScannedQR(input) {
let ok = viewModel.beginQRVerification(with: qr)
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
} else {
result = "invalid or expired qr payload"
}
}
.buttonStyle(.bordered)
#endif
// No status text under camera per design
Spacer()
}
.padding()
}
}
#if os(iOS)
import AVFoundation
struct CameraScannerView: UIViewRepresentable {
typealias UIViewType = PreviewView
var isActive: Bool
var onCode: (String) -> Void
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(sessionOwner: view, onCode: onCode)
context.coordinator.setActive(isActive)
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private weak var owner: PreviewView?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
self.owner = sessionOwner
self.onCode = onCode
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
sessionOwner.videoPreviewLayer.session = session
// Request permission and start
AVCaptureDevice.requestAccess(for: .video) { granted in
self.permissionGranted = granted
if granted && self.desiredActive && !self.isRunning {
self.setActive(true)
}
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) {
super.init(frame: frame)
videoPreviewLayer.videoGravity = .resizeAspectFill
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
#endif
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject var viewModel: ChatViewModel
@Binding var isPresented: Bool
@State private var showingScanner = false
@Environment(\.colorScheme) var colorScheme
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
}
var body: some View {
VStack(spacing: 0) {
// Top header (always at top)
HStack {
Text("VERIFY")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(accentColor)
Spacer()
Button(action: {
showingScanner = false
isPresented = false
}) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
Divider()
// Content area
Group {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
#if os(iOS)
QRScanView(isActive: showingScanner)
.environmentObject(viewModel)
.frame(height: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
#else
QRScanView()
.environmentObject(viewModel)
#endif
}
.padding()
.frame(maxWidth: .infinity)
.background(boxColor)
.cornerRadius(8)
} else {
let qr = myQRString()
MyQRView(qrString: qr)
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
// Centered controls moved up
VStack(spacing: 10) {
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
.font(.system(size: 13, design: .monospaced))
}
.buttonStyle(.bordered)
} else {
Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.system(size: 13, weight: .medium, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
}
// Optional: Remove verification for selected peer (if verified)
if let pid = viewModel.selectedPrivateChatPeer,
let fp = viewModel.getFingerprint(for: pid),
viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.system(size: 12, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(backgroundColor)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
.onDisappear { showingScanner = false }
}
}
+106 -1
View File
@@ -112,4 +112,109 @@ final class NostrProtocolTests: XCTestCase {
print("Expected error when decrypting with wrong key: \(error)")
}
}
}
func testAckRoundTripNIP44V2_Delivered() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
let messageID = "TEST-MSG-DELIVERED-1"
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed delivered ack")
return
}
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Ensure v2 format was used for ciphertext
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
// Verify sender is correct
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
// Parse BitChat payload
XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url),
let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
}
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type {
case .delivered:
let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID)
default:
XCTFail("Unexpected payload type: \(payload.type)")
}
}
func testAckRoundTripNIP44V2_ReadReceipt() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "TEST-MSG-READ-1"
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed read ack")
return
}
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url),
let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
}
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type {
case .readReceipt:
let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID)
default:
XCTFail("Unexpected payload type: \(payload.type)")
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
return Data(base64Encoded: str)
}
}