import BitLogger 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 /// NIP-17 Protocol Implementation for Private Direct Messages struct NostrProtocol { /// Nostr event kinds 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-59 gift wrap case ephemeralEvent = 20000 case geohashPresence = 20001 } /// Create a NIP-17 private message static func createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { // Creating private message // 1. Create the rumor (unsigned event) let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .dm, // NIP-17: DM rumor kind 14 tags: [], content: content ) // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S // real identity key. NIP-17 requires the seal be signed by the sender // so the recipient can authenticate who sent the message; signing with // a throwaway key leaves DMs forgeable/impersonatable. let senderKey = try senderIdentity.schnorrSigningKey() let sealedEvent = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, senderKey: senderKey ) // 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap // layer hides the sender's identity from relays; createGiftWrap mints // its own ephemeral key internally). let giftWrap = try createGiftWrap( seal: sealedEvent, recipientPubkey: recipientPubkey ) // Created gift wrap return giftWrap } /// Decrypt a received NIP-17 message /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) static func decryptPrivateMessage( giftWrap: NostrEvent, recipientIdentity: NostrIdentity ) throws -> (content: String, senderPubkey: String, timestamp: Int) { // Starting decryption // 1. Unwrap the gift wrap let seal: NostrEvent do { seal = try unwrapGiftWrap( giftWrap: giftWrap, recipientKey: recipientIdentity.schnorrSigningKey() ) // Successfully unwrapped gift wrap } catch { SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session) throw error } // 2. Authenticate the seal. The seal MUST be signed by the sender's real // identity key (NIP-17); without this check a DM is forgeable by anyone // who knows the recipient's npub. Verify the seal's own signature. guard seal.isValidSignature() else { SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session) throw NostrError.invalidEvent } // 3. Open the seal let rumor: NostrEvent do { rumor = try openSeal( seal: seal, recipientKey: recipientIdentity.schnorrSigningKey() ) // Successfully opened seal } catch { SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) throw error } // 4. The sender claimed inside the rumor must match the key that actually // signed the seal, otherwise the sender field is unauthenticated and // spoofable. guard seal.pubkey == rumor.pubkey else { SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session) throw NostrError.invalidEvent } // Return the seal signer's pubkey as the authenticated sender. return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) } #if DEBUG static func createPrivateMessageWithInvalidSealSignatureForTesting( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .dm, tags: [], content: content ) var seal = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, senderKey: senderIdentity.schnorrSigningKey() ) seal.sig = String(repeating: "0", count: 128) return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) } static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( content: String, recipientPubkey: String, rumorIdentity: NostrIdentity, sealSignerIdentity: NostrIdentity ) throws -> NostrEvent { let rumor = NostrEvent( pubkey: rumorIdentity.publicKeyHex, createdAt: Date(), kind: .dm, tags: [], content: content ) let seal = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, senderKey: sealSignerIdentity.schnorrSigningKey() ) return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) } #endif /// Create a geohash-scoped ephemeral public message (kind 20000) static func createEphemeralGeohashEvent( content: String, geohash: String, senderIdentity: NostrIdentity, nickname: String? = nil, teleported: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } if teleported { tags.append(["t", "teleport"]) } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: tags, content: content ) let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } /// Create a geohash presence heartbeat (kind 20001) /// Must contain empty content and NO nickname tag static func createGeohashPresenceEvent( geohash: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { let tags = [["g", geohash]] let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: tags, content: "" ) let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, nickname: String? = nil ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: tags, content: content ) let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } // MARK: - Private Methods private static func createSeal( rumor: NostrEvent, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { let rumorJSON = try rumor.jsonString() let encrypted = try encrypt( plaintext: rumorJSON, recipientPubkey: recipientPubkey, senderKey: senderKey ) let seal = NostrEvent( pubkey: Data(senderKey.xonly.bytes).hexEncodedString(), createdAt: randomizedTimestamp(), kind: .seal, tags: [], content: encrypted ) // Sign the seal with the sender's Schnorr private key return try seal.sign(with: senderKey) } private static func createGiftWrap( seal: NostrEvent, recipientPubkey: String ) throws -> NostrEvent { let sealJSON = try seal.jsonString() // Create new ephemeral key for gift wrap let wrapKey = try P256K.Schnorr.PrivateKey() // Creating gift wrap with ephemeral key // Encrypt the seal with the new ephemeral key (not the seal's key) let encrypted = try encrypt( plaintext: sealJSON, recipientPubkey: recipientPubkey, senderKey: wrapKey // Use the gift wrap ephemeral key ) let giftWrap = NostrEvent( pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(), createdAt: randomizedTimestamp(), kind: .giftWrap, tags: [["p", recipientPubkey]], // Tag recipient content: encrypted ) // Sign the gift wrap with the wrap Schnorr private key return try giftWrap.sign(with: wrapKey) } private static func unwrapGiftWrap( giftWrap: NostrEvent, recipientKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { // Unwrapping gift wrap let decrypted = try decrypt( ciphertext: giftWrap.content, senderPubkey: giftWrap.pubkey, recipientKey: recipientKey ) guard let data = decrypted.data(using: .utf8), let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } let seal = try NostrEvent(from: sealDict) // Unwrapped seal return seal } private static func openSeal( seal: NostrEvent, recipientKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { let decrypted = try decrypt( ciphertext: seal.content, senderPubkey: seal.pubkey, recipientKey: recipientKey ) guard let data = decrypted.data(using: .utf8), let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } return try NostrEvent(from: rumorDict) } // MARK: - Encryption (NIP-44 v2) private static func encrypt( plaintext: String, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey ) throws -> String { guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { throw NostrError.invalidPublicKey } // 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) // 24-byte random nonce for XChaCha20-Poly1305 var nonce24 = Data(count: 24) _ = nonce24.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) } let pt = Data(plaintext.utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) // v2: base64url(nonce24 || ciphertext || tag) var combined = Data() combined.append(nonce24) combined.append(sealed.ciphertext) combined.append(sealed.tag) return "v2:" + Base64URLCoding.encode(combined) } private static func decrypt( ciphertext: String, senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { // Expect NIP-44 v2 format guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } let encoded = String(ciphertext.dropFirst(3)) guard let data = Base64URLCoding.decode(encoded), data.count > (24 + 16), let senderPubkeyData = Data(hexString: senderPubkey) else { throw NostrError.invalidCiphertext } 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) ) } // 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) ?? "" } 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) ?? "" } } private static func deriveSharedSecret( privateKey: P256K.Schnorr.PrivateKey, publicKey: Data ) throws -> Data { // Deriving shared secret // Convert Schnorr private key to KeyAgreement private key let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( dataRepresentation: privateKey.dataRepresentation ) // Create KeyAgreement public key from the public key data // For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix) var fullPublicKey = Data() if publicKey.count == 32 { // X-only key, need to add prefix // For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates // First try with even Y (0x02 prefix) fullPublicKey.append(0x02) fullPublicKey.append(publicKey) // Trying with even Y coordinate } else { fullPublicKey = publicKey } // Try to create public key, if it fails with even Y, try odd Y let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey do { keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( dataRepresentation: fullPublicKey, format: .compressed ) } catch { if publicKey.count == 32 { // Try with odd Y (0x03 prefix) // Even Y failed, trying odd Y fullPublicKey = Data() fullPublicKey.append(0x03) fullPublicKey.append(publicKey) keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( dataRepresentation: fullPublicKey, format: .compressed ) } else { throw error } } // Perform ECDH let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( with: keyAgreementPublicKey, format: .compressed ) // Convert SharedSecret to Data let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // ECDH shared secret derived // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key return sharedSecretData } // Direct version that doesn't try to add prefixes private static func deriveSharedSecretDirect( privateKey: P256K.Schnorr.PrivateKey, publicKey: Data ) throws -> Data { // Direct shared secret calculation // Convert Schnorr private key to KeyAgreement private key let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( dataRepresentation: privateKey.dataRepresentation ) // Use the public key as-is (should already have prefix) let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( dataRepresentation: publicKey, format: .compressed ) // Perform ECDH let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( with: keyAgreementPublicKey, format: .compressed ) // Convert SharedSecret to Data let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key return sharedSecretData } private static func randomizedTimestamp() -> Date { // Add random offset to current time for privacy // This prevents timing correlation attacks while the actual message timestamp // is preserved in the encrypted rumor let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes let now = Date() let randomized = now.addingTimeInterval(offset) // Log with explicit UTC and local time for debugging let formatter = DateFormatter() // formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.timeZone = TimeZone.current // Timestamp randomized for privacy return randomized } } /// Nostr Event structure struct NostrEvent: Codable { var id: String let pubkey: String let created_at: Int let kind: Int let tags: [[String]] let content: String var sig: String? init( pubkey: String, createdAt: Date, kind: NostrProtocol.EventKind, tags: [[String]], content: String ) { self.pubkey = pubkey self.created_at = Int(createdAt.timeIntervalSince1970) self.kind = kind.rawValue self.tags = tags self.content = content self.sig = nil self.id = "" // Will be set during signing } init(from dict: [String: Any]) throws { guard let pubkey = dict["pubkey"] as? String, let createdAt = dict["created_at"] as? Int, let kind = dict["kind"] as? Int, let tags = dict["tags"] as? [[String]], let content = dict["content"] as? String else { throw NostrError.invalidEvent } self.id = dict["id"] as? String ?? "" self.pubkey = pubkey self.created_at = createdAt self.kind = kind self.tags = tags self.content = content self.sig = dict["sig"] as? String } func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { let (eventId, eventIdHash) = try calculateEventId() // Sign with Schnorr (BIP-340) var messageBytes = [UInt8](eventIdHash) var auxRand = [UInt8](repeating: 0, count: 32) _ = auxRand.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand) let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() var signed = self signed.id = eventId signed.sig = signatureHex return signed } /// Validate that the event ID and Schnorr signature match the content and pubkey. /// Returns false when the signature is missing, malformed, or does not verify. func isValidSignature() -> Bool { guard let sig = sig, let sigData = Data(hexString: sig), let pubData = Data(hexString: pubkey), sigData.count == 64, pubData.count == 32, let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData), let (expectedId, eventHash) = try? calculateEventId(), expectedId == id else { return false } var messageBytes = [UInt8](eventHash) let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData) return xonly.isValid(signature, for: &messageBytes) } private func calculateEventId() throws -> (String, Data) { let serialized = [ 0, pubkey, created_at, kind, tags, content ] as [Any] let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) return (data.sha256Fingerprint(), data.sha256Hash()) } func jsonString() throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.withoutEscapingSlashes] let data = try encoder.encode(self) return String(data: data, encoding: .utf8) ?? "" } } enum NostrError: Error { case invalidPublicKey case invalidPrivateKey case invalidEvent case invalidCiphertext case signingFailed case encryptionFailed } // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) private extension NostrProtocol { static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), salt: Data(), info: Data("nip44-v2".utf8), outputByteCount: 32 ) return derivedKey.withUnsafeBytes { Data($0) } } }