11import Foundation
22import CryptoKit
33import 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