Skip to content

Commit e690345

Browse files
authored
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>
1 parent 85f9998 commit e690345

File tree

7 files changed

+371
-130
lines changed

7 files changed

+371
-130
lines changed

bitchat.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@
140140
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
141141
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
142142
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
143+
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
144+
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
143145
/* End PBXBuildFile section */
144146

145147
/* Begin PBXContainerItemProxy section */
@@ -258,6 +260,7 @@
258260
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
259261
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
260262
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
263+
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
261264
/* End PBXFileReference section */
262265

263266
/* Begin PBXFrameworksBuildPhase section */
@@ -522,6 +525,7 @@
522525
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
523526
isa = PBXGroup;
524527
children = (
528+
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
525529
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
526530
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
527531
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
@@ -720,6 +724,7 @@
720724
isa = PBXSourcesBuildPhase;
721725
buildActionMask = 2147483647;
722726
files = (
727+
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
723728
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
724729
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
725730
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
@@ -774,6 +779,7 @@
774779
isa = PBXSourcesBuildPhase;
775780
buildActionMask = 2147483647;
776781
files = (
782+
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
777783
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
778784
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
779785
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,

bitchat/Nostr/NostrProtocol.swift

Lines changed: 91 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import CryptoKit
33
import P256K
4+
import Security
45

56
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
67
// Make sure BinaryEncodingUtils.swift is included in the target
@@ -12,8 +13,9 @@ struct NostrProtocol {
1213
enum EventKind: Int {
1314
case metadata = 0
1415
case textNote = 1
16+
case dm = 14 // NIP-17 DM rumor kind
1517
case seal = 13 // NIP-17 sealed event
16-
case giftWrap = 1059 // NIP-17 gift wrap
18+
case giftWrap = 1059 // NIP-59 gift wrap
1719
case ephemeralEvent = 20000
1820
}
1921

@@ -30,7 +32,7 @@ struct NostrProtocol {
3032
let rumor = NostrEvent(
3133
pubkey: senderIdentity.publicKeyHex,
3234
createdAt: Date(),
33-
kind: .textNote,
35+
kind: .dm, // NIP-17: DM rumor kind 14
3436
tags: [],
3537
content: content
3638
)
@@ -227,7 +229,7 @@ struct NostrProtocol {
227229
return try NostrEvent(from: rumorDict)
228230
}
229231

230-
// MARK: - Encryption (NIP-44 style)
232+
// MARK: - Encryption (NIP-44 v2)
231233

232234
private static func encrypt(
233235
plaintext: String,
@@ -239,120 +241,77 @@ struct NostrProtocol {
239241
throw NostrError.invalidPublicKey
240242
}
241243

242-
// Encrypting message
244+
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
243245

244246
// Derive shared secret
245247
let sharedSecret = try deriveSharedSecret(
246248
privateKey: senderKey,
247249
publicKey: recipientPubkeyData
248250
)
251+
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
252+
let key = try deriveNIP44V2Key(from: sharedSecret)
249253

250-
// Derived shared secret
251-
252-
// Generate nonce
253-
let nonce = AES.GCM.Nonce()
254-
255-
// Encrypt
256-
let sealed = try AES.GCM.seal(
257-
plaintext.data(using: .utf8)!,
258-
using: SymmetricKey(data: sharedSecret),
259-
nonce: nonce
260-
)
254+
// 24-byte random nonce for XChaCha20-Poly1305
255+
var nonce24 = Data(count: 24)
256+
_ = nonce24.withUnsafeMutableBytes { ptr in
257+
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
258+
}
261259

262-
// Combine nonce + ciphertext + tag
263-
var result = Data()
264-
result.append(nonce.withUnsafeBytes { Data($0) })
265-
result.append(sealed.ciphertext)
266-
result.append(sealed.tag)
260+
let pt = Data(plaintext.utf8)
261+
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
267262

268-
return result.base64EncodedString()
263+
// v2: base64url(nonce24 || ciphertext || tag)
264+
var combined = Data()
265+
combined.append(nonce24)
266+
combined.append(sealed.ciphertext)
267+
combined.append(sealed.tag)
268+
return "v2:" + base64URLEncode(combined)
269269
}
270270

271271
private static func decrypt(
272272
ciphertext: String,
273273
senderPubkey: String,
274274
recipientKey: P256K.Schnorr.PrivateKey
275275
) throws -> String {
276-
277-
// Decrypting message
278-
279-
guard let data = Data(base64Encoded: ciphertext),
276+
// Expect NIP-44 v2 format
277+
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
278+
let encoded = String(ciphertext.dropFirst(3))
279+
guard let data = base64URLDecode(encoded),
280+
data.count > (24 + 16),
280281
let senderPubkeyData = Data(hexString: senderPubkey) else {
281-
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
282-
category: SecureLogger.session, level: .error)
283282
throw NostrError.invalidCiphertext
284283
}
285-
286-
// Ciphertext data parsed
287-
288-
// Extract components
289-
let nonceData = data.prefix(12)
290-
let ciphertextData = data.dropFirst(12).dropLast(16)
291-
let tagData = data.suffix(16)
292-
293-
// Components parsed
294-
295-
// Derive shared secret - try with default Y coordinate first
296-
var sharedSecret: Data
297-
var decrypted: Data? = nil
298-
299-
do {
300-
sharedSecret = try deriveSharedSecret(
301-
privateKey: recipientKey,
302-
publicKey: senderPubkeyData
303-
)
304-
// Derived shared secret with first Y coordinate
305-
306-
// Try to decrypt
307-
let sealedBox = try AES.GCM.SealedBox(
308-
nonce: AES.GCM.Nonce(data: nonceData),
309-
ciphertext: ciphertextData,
310-
tag: tagData
284+
285+
let nonce24 = data.prefix(24)
286+
let rest = data.dropFirst(24)
287+
let tag = rest.suffix(16)
288+
let ct = rest.dropLast(16)
289+
290+
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
291+
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
292+
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
293+
let key = try deriveNIP44V2Key(from: ss)
294+
return try XChaCha20Poly1305Compat.open(
295+
ciphertext: Data(ct),
296+
tag: Data(tag),
297+
key: key,
298+
nonce24: Data(nonce24)
311299
)
312-
313-
do {
314-
decrypted = try AES.GCM.open(
315-
sealedBox,
316-
using: SymmetricKey(data: sharedSecret)
317-
)
318-
// AES-GCM decryption successful
319-
} catch {
320-
// AES-GCM decryption failed, trying alternate
321-
322-
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
323-
if senderPubkeyData.count == 32 {
324-
// Trying alternate Y coordinate
325-
326-
// Force deriveSharedSecret to use odd Y by manipulating the data
327-
var altPubkey = Data()
328-
altPubkey.append(0x03) // Force odd Y
329-
altPubkey.append(senderPubkeyData)
330-
331-
sharedSecret = try deriveSharedSecretDirect(
332-
privateKey: recipientKey,
333-
publicKey: altPubkey
334-
)
335-
336-
decrypted = try AES.GCM.open(
337-
sealedBox,
338-
using: SymmetricKey(data: sharedSecret)
339-
)
340-
// AES-GCM decryption successful with alternate Y
341-
} else {
342-
throw error
343-
}
344-
}
345-
} catch {
346-
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
347-
category: SecureLogger.session, level: .error)
348-
throw error
349300
}
350-
351-
guard let finalDecrypted = decrypted else {
352-
throw NostrError.encryptionFailed
301+
302+
// If 32 bytes (x-only) try both parities, otherwise single try
303+
if senderPubkeyData.count == 32 {
304+
let even = Data([0x02]) + senderPubkeyData
305+
if let pt = try? attemptDecrypt(using: even) {
306+
return String(data: pt, encoding: .utf8) ?? ""
307+
}
308+
let odd = Data([0x03]) + senderPubkeyData
309+
let pt = try attemptDecrypt(using: odd)
310+
return String(data: pt, encoding: .utf8) ?? ""
311+
} else {
312+
let pt = try attemptDecrypt(using: senderPubkeyData)
313+
return String(data: pt, encoding: .utf8) ?? ""
353314
}
354-
355-
return String(data: finalDecrypted, encoding: .utf8) ?? ""
356315
}
357316

358317
private static func deriveSharedSecret(
@@ -412,17 +371,8 @@ struct NostrProtocol {
412371
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
413372
// ECDH shared secret derived
414373

415-
// Derive key using HKDF for NIP-44 v2
416-
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
417-
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
418-
salt: "nip44-v2".data(using: .utf8)!,
419-
info: Data(),
420-
outputByteCount: 32
421-
)
422-
423-
let result = derivedKey.withUnsafeBytes { Data($0) }
424-
// Final derived key ready
425-
return result
374+
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
375+
return sharedSecretData
426376
}
427377

428378
// Direct version that doesn't try to add prefixes
@@ -452,15 +402,8 @@ struct NostrProtocol {
452402
// Convert SharedSecret to Data
453403
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
454404

455-
// Derive key using HKDF for NIP-44 v2
456-
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
457-
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
458-
salt: "nip44-v2".data(using: .utf8)!,
459-
info: Data(),
460-
outputByteCount: 32
461-
)
462-
463-
return derivedKey.withUnsafeBytes { Data($0) }
405+
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
406+
return sharedSecretData
464407
}
465408

466409
private static func randomizedTimestamp() -> Date {
@@ -537,7 +480,10 @@ struct NostrEvent: Codable {
537480

538481
// Sign with Schnorr
539482
var messageBytes = [UInt8](eventIdHash)
540-
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
483+
var auxRand = [UInt8](repeating: 0, count: 32)
484+
_ = auxRand.withUnsafeMutableBytes { ptr in
485+
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
486+
}
541487
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
542488

543489
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -581,3 +527,32 @@ enum NostrError: Error {
581527
case signingFailed
582528
case encryptionFailed
583529
}
530+
531+
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
532+
533+
private extension NostrProtocol {
534+
static func base64URLEncode(_ data: Data) -> String {
535+
return data.base64EncodedString()
536+
.replacingOccurrences(of: "+", with: "-")
537+
.replacingOccurrences(of: "/", with: "_")
538+
.replacingOccurrences(of: "=", with: "")
539+
}
540+
541+
static func base64URLDecode(_ s: String) -> Data? {
542+
var str = s
543+
let pad = (4 - (str.count % 4)) % 4
544+
if pad > 0 { str += String(repeating: "=", count: pad) }
545+
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
546+
return Data(base64Encoded: str)
547+
}
548+
549+
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
550+
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
551+
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
552+
salt: Data(),
553+
info: "nip44-v2".data(using: .utf8)!,
554+
outputByteCount: 32
555+
)
556+
return derivedKey.withUnsafeBytes { Data($0) }
557+
}
558+
}

0 commit comments

Comments
 (0)