mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
* Unified notices: merge board pins and location notes into one sheet One pin icon in the header now opens a single Notices sheet with a geo/mesh scope toggle, replacing the separate board, location-notes, and mesh-only note buttons: - geo tab: current geohash's notices — mesh-synced board posts merged and deduped with Nostr kind-1 location notes, with per-item mesh/net source badges. Scope follows the selected location channel, or the device's building geohash when chatting on mesh. - mesh tab: mesh-local board only (fully offline). - One composer: geo posts go to the board and bridge to Nostr (existing bridge), so mesh and internet see the same notice. - Merged delete: tombstoning an own board post now also retracts the bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event ids tracked in BoardManager); own Nostr-only notes are deletable too. - LocationNotesManager accepts any channel-precision geohash (1-12 chars), not just building-level. BoardView and LocationNotesView are superseded by NoticesView. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Notices round 2: honest composer, friendlier copy, new-pin chat alerts - Urgent + expiry controls now appear on the mesh tab only: the bridged Nostr copy of a geo post carries neither, so relay-side readers would never see them. Geo posts default to non-urgent with 7-day expiry, and the bridged note now gets a NIP-40 expiration tag so honoring relays drop it in step with the board copy. - Geo tab explainer reuses the original location-notes description (keeps its 29 existing translations); mesh tab gets a new plain- language description. - New-pin chat alerts, fully local (no wire traffic): BoardStore fires postArrivals for posts newly accepted from the wire; BoardAlertsModel filters own posts, dedups by postID, and for urgent pins created within the last 30 minutes emits one system line into the matching timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat), collapsing simultaneous arrivals into a count line. - Routine pins light up the header: the pin icon tints orange whenever the current scope has notices at all, and fills (pin.fill) while unseen new pins are waiting; opening the sheet clears them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * i18n: translate the unified-notices strings into all 28 non-English locales Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh description, source badges, urgent alert lines, button tooltip and accessibility strings) to the string catalog with translations for every locale the app ships. The geo tab already reuses the fully translated location_notes.description; this covers the rest. Insertion preserves the catalog's case-insensitive key order, so the diff is purely additive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe - BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and resets, so a panic wipe drops pending urgent lines (which could otherwise re-append pre-wipe content into chat after the collapse flush), unseen badge scopes, and handled-post history. - Opening the notices sheet clears unseen badges only for the scopes it actually shows (mesh + current geo scope); pins for other geohash channels keep their badge until visited. - LocationNotesManager.Note now retains the matched g tag, and the bridged-copy dedupe requires the note's geohash to equal the board post's — a same-text note from a neighboring cell is no longer swallowed as a duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
731 lines
25 KiB
Swift
731 lines
25 KiB
Swift
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
|
|
case deletion = 5 // NIP-09 event deletion request
|
|
}
|
|
|
|
/// 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 {
|
|
let event = NostrEvent(
|
|
pubkey: senderIdentity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .ephemeralEvent,
|
|
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
|
content: content
|
|
)
|
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
return try event.sign(with: schnorrKey)
|
|
}
|
|
|
|
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
|
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
|
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
|
/// surrounding task is cancelled) the event ships at the highest
|
|
/// committed difficulty still met, and if mining is impossible it ships
|
|
/// unmined — sending is never blocked.
|
|
static func createMinedEphemeralGeohashEvent(
|
|
content: String,
|
|
geohash: String,
|
|
senderIdentity: NostrIdentity,
|
|
nickname: String? = nil,
|
|
teleported: Bool = false,
|
|
powTargetBits: Int = NostrPoW.targetBits
|
|
) async throws -> NostrEvent {
|
|
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
|
// Fix created_at up front: the mined nonce commits to the full
|
|
// serialized event, so the signed event must reuse the exact value.
|
|
let createdAt = Int(Date().timeIntervalSince1970)
|
|
if let nonceTag = await NostrPoW.mineNonceTag(
|
|
pubkey: senderIdentity.publicKeyHex,
|
|
createdAt: createdAt,
|
|
kind: EventKind.ephemeralEvent.rawValue,
|
|
tags: tags,
|
|
content: content,
|
|
targetBits: powTargetBits
|
|
) {
|
|
tags.append(nonceTag)
|
|
}
|
|
let event = NostrEvent(
|
|
pubkey: senderIdentity.publicKeyHex,
|
|
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
|
kind: .ephemeralEvent,
|
|
tags: tags,
|
|
content: content
|
|
)
|
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
return try event.sign(with: schnorrKey)
|
|
}
|
|
|
|
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
|
/// variants).
|
|
private static func ephemeralGeohashTags(
|
|
geohash: String,
|
|
nickname: String?,
|
|
teleported: Bool
|
|
) -> [[String]] {
|
|
var tags = [["g", geohash]]
|
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
|
tags.append(["n", nickname])
|
|
}
|
|
if teleported {
|
|
tags.append(["t", "teleport"])
|
|
}
|
|
return tags
|
|
}
|
|
|
|
/// 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.
|
|
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
|
/// drop the note in step with a bridged board post's expiry.
|
|
static func createGeohashTextNote(
|
|
content: String,
|
|
geohash: String,
|
|
senderIdentity: NostrIdentity,
|
|
nickname: String? = nil,
|
|
expiresAt: Date? = nil
|
|
) throws -> NostrEvent {
|
|
var tags = [["g", geohash]]
|
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
|
tags.append(["n", nickname])
|
|
}
|
|
if let expiresAt {
|
|
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
|
}
|
|
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)
|
|
}
|
|
|
|
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
|
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
|
/// key that signed the original.
|
|
static func createDeleteEvent(
|
|
ofEventID eventID: String,
|
|
senderIdentity: NostrIdentity
|
|
) throws -> NostrEvent {
|
|
let event = NostrEvent(
|
|
pubkey: senderIdentity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .deletion,
|
|
tags: [["e", eventID]],
|
|
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<CryptoKit.SHA256>.deriveKey(
|
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
salt: Data(),
|
|
info: Data("nip44-v2".utf8),
|
|
outputByteCount: 32
|
|
)
|
|
return derivedKey.withUnsafeBytes { Data($0) }
|
|
}
|
|
}
|