fix nip-17 (#305)

* NIP-59 stuff

* nip-17 ios to androing works, android to ios still wip

* fix impl

* ios compat

* default relays for DMs

* delivery ack works

* delivery ack

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
shroominic
2025-08-24 19:06:29 +02:00
committed by GitHub
co-authored by callebtc
parent 23c397fe7b
commit 47c40fb9f3
11 changed files with 308 additions and 158 deletions
@@ -465,7 +465,8 @@ class BluetoothMeshService(private val context: Context) {
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
*/ */
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty() || recipientNickname.isEmpty()) return if (content.isEmpty() || recipientPeerID.isEmpty()) return
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
serviceScope.launch { serviceScope.launch {
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
@@ -107,17 +107,18 @@ class NostrClient private constructor(private val context: Context) {
val recipientPubkeyHex = pubkeyBytes.toHexString() val recipientPubkeyHex = pubkeyBytes.toHexString()
// Create and send gift wrap // Create and send gift wraps (receiver and sender copies)
val giftWrap = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = content, content = content,
recipientPubkey = recipientPubkeyHex, recipientPubkey = recipientPubkeyHex,
senderIdentity = identity senderIdentity = identity
) )
// Track this as a pending gift wrap for logging // Track and send all gift wraps
NostrRelayManager.registerPendingGiftWrap(giftWrap.id) giftWraps.forEach { wrap ->
NostrRelayManager.registerPendingGiftWrap(wrap.id)
relayManager.sendEvent(giftWrap) relayManager.sendEvent(wrap)
}
Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...") Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...")
onSuccess?.invoke() onSuccess?.invoke()
@@ -141,7 +142,7 @@ class NostrClient private constructor(private val context: Context) {
val filter = NostrFilter.giftWrapsFor( val filter = NostrFilter.giftWrapsFor(
pubkey = identity.publicKeyHex, pubkey = identity.publicKeyHex,
since = System.currentTimeMillis() - 86400000L // Last 24 hours since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
) )
relayManager.subscribe(filter, "private-messages", { giftWrap -> relayManager.subscribe(filter, "private-messages", { giftWrap ->
@@ -241,7 +242,7 @@ class NostrClient private constructor(private val context: Context) {
) { ) {
// Age filtering (24h + 15min buffer for randomized timestamps) // Age filtering (24h + 15min buffer for randomized timestamps)
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 87300) { // 24 hours + 15 minutes if (messageAge > 173700) { // 48 hours + 15 minutes
Log.v(TAG, "Ignoring old private message") Log.v(TAG, "Ignoring old private message")
return return
} }
@@ -7,14 +7,11 @@ import org.bouncycastle.crypto.params.ECPublicKeyParameters
import org.bouncycastle.math.ec.ECPoint import org.bouncycastle.math.ec.ECPoint
import org.bouncycastle.crypto.generators.ECKeyPairGenerator import org.bouncycastle.crypto.generators.ECKeyPairGenerator
import org.bouncycastle.crypto.params.ECKeyGenerationParameters import org.bouncycastle.crypto.params.ECKeyGenerationParameters
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.macs.HMac import org.bouncycastle.crypto.macs.HMac
import org.bouncycastle.crypto.params.KeyParameter import org.bouncycastle.crypto.params.KeyParameter
import javax.crypto.Cipher import com.google.crypto.tink.subtle.XChaCha20Poly1305
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import java.security.SecureRandom import java.security.SecureRandom
import java.security.MessageDigest import java.security.MessageDigest
import java.math.BigInteger import java.math.BigInteger
@@ -26,6 +23,7 @@ import java.math.BigInteger
object NostrCrypto { object NostrCrypto {
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
// NIP-44 v2 only
// secp256k1 curve parameters // secp256k1 curve parameters
val secp256k1Curve = CustomNamedCurves.getByName("secp256k1") val secp256k1Curve = CustomNamedCurves.getByName("secp256k1")
@@ -99,7 +97,7 @@ object NostrCrypto {
val privateKeyBigInt = BigInteger(1, privateKeyBytes) val privateKeyBigInt = BigInteger(1, privateKeyBytes)
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params) val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
// Try to recover full public key point from x-only coordinate // Recover full public key point from x-only coordinate (prefer even y per BIP-340)
val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes) val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes)
val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params) val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params)
@@ -119,11 +117,39 @@ object NostrCrypto {
maxOf(0, 32 - sharedSecretBytes.size), maxOf(0, 32 - sharedSecretBytes.size),
minOf(sharedSecretBytes.size, 32) minOf(sharedSecretBytes.size, 32)
) )
} else {
// If BigInteger added a leading sign byte, keep the last 32 bytes
System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32)
} }
return result return result
} }
/**
* Perform ECDH with explicit parity choice for the x-only public key
* If preferOddY is true, use the odd-y lift; otherwise even-y lift.
*/
private fun performECDHWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ByteArray {
val privateKeyBytes = privateKeyHex.hexToByteArray()
val publicKeyBytes = publicKeyHex.hexToByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY)
val publicKeyParams = ECPublicKeyParameters(point, secp256k1Params)
val agreement = ECDHBasicAgreement()
agreement.init(privateKeyParams)
val sharedSecret = agreement.calculateAgreement(publicKeyParams)
val sharedSecretBytes = sharedSecret.toByteArray()
val result = ByteArray(32)
if (sharedSecretBytes.size <= 32) {
System.arraycopy(sharedSecretBytes, maxOf(0, sharedSecretBytes.size - 32), result, maxOf(0, 32 - sharedSecretBytes.size), minOf(sharedSecretBytes.size, 32))
} else {
// If BigInteger added a leading sign byte, keep the last 32 bytes
System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32)
}
return result
}
/** /**
* Recover full EC point from x-only coordinate * Recover full EC point from x-only coordinate
* Tries both possible y coordinates * Tries both possible y coordinates
@@ -148,95 +174,137 @@ object NostrCrypto {
} }
} }
/** private fun recoverPublicKeyPointWithParity(xOnlyBytes: ByteArray, preferOddY: Boolean): ECPoint {
* NIP-44 key derivation using HKDF require(xOnlyBytes.size == 32) { "X-only public key must be 32 bytes" }
*/ val prefix: Byte = if (preferOddY) 0x03 else 0x02
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray { val compressedBytes = ByteArray(33)
val salt = "nip44-v2".toByteArray(Charsets.UTF_8) compressedBytes[0] = prefix
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
// HKDF-Extract return secp256k1Curve.curve.decodePoint(compressedBytes)
val hmac = HMac(SHA256Digest())
hmac.init(KeyParameter(salt))
hmac.update(sharedSecret, 0, sharedSecret.size)
val prk = ByteArray(hmac.macSize)
hmac.doFinal(prk, 0)
// HKDF-Expand (we need 32 bytes for AES-256)
hmac.init(KeyParameter(prk))
hmac.update(byteArrayOf(0x01), 0, 1) // info = empty, N = 1
val okm = ByteArray(hmac.macSize)
hmac.doFinal(okm, 0)
// Return first 32 bytes
return okm.copyOf(32)
} }
/** /**
* NIP-44 encryption using AES-256-GCM * Compute the ECDH shared point (uncompressed) with an explicit parity choice for the x-only public key
*/ */
fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String { private fun computeSharedPointWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ECPoint {
val privateKeyBytes = privateKeyHex.hexToByteArray()
val publicKeyBytes = publicKeyHex.hexToByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY)
return point.multiply(privateKeyBigInt).normalize()
}
private fun compressedPoint(point: ECPoint): ByteArray {
val normalized = point.normalize()
val x = normalized.xCoord.encoded
val prefix: Byte = if (hasEvenY(normalized)) 0x02 else 0x03
val out = ByteArray(33)
out[0] = prefix
System.arraycopy(x, 0, out, 1, 32)
return out
}
/**
* NIP-44 v2 key derivation using HKDF-SHA256
* salt = empty, info = "nip44-v2", length = 32 bytes
*/
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray {
val zeroSalt = ByteArray(0)
val prk = hkdfExtract(zeroSalt, sharedSecret)
return hkdfExpand(prk, info = "nip44-v2".toByteArray(Charsets.UTF_8), length = 32)
}
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
val hmac = HMac(SHA256Digest())
hmac.init(KeyParameter(salt))
hmac.update(ikm, 0, ikm.size)
val prk = ByteArray(hmac.macSize)
hmac.doFinal(prk, 0)
return prk
}
private fun hkdfExpand(prk: ByteArray, info: ByteArray?, length: Int): ByteArray {
val hmac = HMac(SHA256Digest())
hmac.init(KeyParameter(prk))
if (info != null && info.isNotEmpty()) {
hmac.update(info, 0, info.size)
}
hmac.update(byteArrayOf(0x01), 0, 1)
val t = ByteArray(hmac.macSize)
hmac.doFinal(t, 0)
return t.copyOf(length)
}
/**
* NIP-44 v2 encryption using XChaCha20-Poly1305
* Output format: "v2:" + base64url(nonce24 || ciphertext || tag)
*/
fun encryptNIP44(
plaintext: String,
recipientPublicKeyHex: String,
senderPrivateKeyHex: String
): String {
try { try {
// Perform ECDH // Match iOS: derive HKDF input from the compressed shared point (33 bytes)
val sharedSecret = performECDH(senderPrivateKeyHex, recipientPublicKeyHex) val sharedPoint = computeSharedPointWithParity(senderPrivateKeyHex, recipientPublicKeyHex, preferOddY = false)
val secretMaterial = compressedPoint(sharedPoint)
// Derive encryption key val encryptionKey = deriveNIP44Key(secretMaterial)
val encryptionKey = deriveNIP44Key(sharedSecret) val aead = XChaCha20Poly1305(encryptionKey)
val combined = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) // nonce||ct||tag
// Generate random nonce (12 bytes for GCM) val b64 = base64UrlNoPad(combined)
val nonce = ByteArray(12) android.util.Log.d("NostrCrypto", "NIP44 v2 encrypt: len=${b64.length}")
secureRandom.nextBytes(nonce) return "v2:$b64"
// Encrypt using AES-256-GCM
val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val secretKey = SecretKeySpec(encryptionKey, "AES")
val gcmSpec = GCMParameterSpec(128, nonce) // 128-bit auth tag
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
val ciphertext = cipher.doFinal(plaintextBytes)
// Combine nonce + ciphertext (includes auth tag)
val result = ByteArray(nonce.size + ciphertext.size)
System.arraycopy(nonce, 0, result, 0, nonce.size)
System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size)
// Base64 encode
return android.util.Base64.encodeToString(result, android.util.Base64.NO_WRAP)
} catch (e: Exception) { } catch (e: Exception) {
throw RuntimeException("NIP-44 encryption failed: ${e.message}", e) throw RuntimeException("NIP-44 v2 encryption failed: ${e.message}", e)
} }
} }
/** /**
* NIP-44 decryption using AES-256-GCM * NIP-44 v2 decryption using XChaCha20-Poly1305
* Only accepts the exact "v2:" base64url format.
* Tries both even/odd Y parities for x-only pubkeys.
*/ */
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String { fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
try { try {
// Decode base64 require(ciphertext.startsWith("v2:")) { "Invalid NIP-44 version prefix" }
val encryptedData = android.util.Base64.decode(ciphertext, android.util.Base64.NO_WRAP) val encoded = ciphertext.substring(3)
val encryptedData = base64UrlDecode(encoded)
?: throw IllegalArgumentException("Invalid base64url payload")
// Extract nonce and ciphertext var lastError: Exception? = null
require(encryptedData.size >= 12 + 16) { "Ciphertext too short" } // Try even-Y first, then odd-Y
val nonce = encryptedData.copyOfRange(0, 12) for (preferOdd in listOf(false, true)) {
val ciphertextBytes = encryptedData.copyOfRange(12, encryptedData.size) try {
// Match iOS: derive HKDF input from the compressed shared point (33 bytes)
// Perform ECDH val point = computeSharedPointWithParity(recipientPrivateKeyHex, senderPublicKeyHex, preferOddY = preferOdd)
val sharedSecret = performECDH(recipientPrivateKeyHex, senderPublicKeyHex) val secretMaterial = compressedPoint(point)
val key = deriveNIP44Key(secretMaterial)
// Derive decryption key val aead = XChaCha20Poly1305(key)
val decryptionKey = deriveNIP44Key(sharedSecret) val pt = aead.decrypt(encryptedData, null) // expects nonce||ct||tag
return String(pt, Charsets.UTF_8)
// Decrypt using AES-256-GCM } catch (e: Exception) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding") lastError = e
val secretKey = SecretKeySpec(decryptionKey, "AES") }
val gcmSpec = GCMParameterSpec(128, nonce) }
throw lastError ?: RuntimeException("NIP-44 v2 decryption failed")
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
val plaintextBytes = cipher.doFinal(ciphertextBytes)
return String(plaintextBytes, Charsets.UTF_8)
} catch (e: Exception) { } catch (e: Exception) {
throw RuntimeException("NIP-44 decryption failed: ${e.message}", e) throw RuntimeException("NIP-44 v2 decryption failed: ${e.message}", e)
}
}
private fun base64UrlNoPad(data: ByteArray): String {
val b64 = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)
return b64.replace('+', '-').replace('/', '_').replace("=", "")
}
private fun base64UrlDecode(s: String): ByteArray? {
var str = s.replace('-', '+').replace('_', '/')
val pad = (4 - (str.length % 4)) % 4
if (pad > 0) str += "=".repeat(pad)
return try {
android.util.Base64.decode(str, android.util.Base64.NO_WRAP)
} catch (_: IllegalArgumentException) {
null
} }
} }
@@ -248,6 +316,15 @@ object NostrCrypto {
return (baseTimestamp + offset).toInt() return (baseTimestamp + offset).toInt()
} }
/**
* Random timestamp up to maxPastSeconds in the past (default 2 days)
*/
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
val now = (System.currentTimeMillis() / 1000).toInt()
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
return now - offset
}
/** /**
* Validate secp256k1 private key * Validate secp256k1 private key
*/ */
@@ -1,6 +1,7 @@
package com.bitchat.android.nostr package com.bitchat.android.nostr
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.security.MessageDigest import java.security.MessageDigest
@@ -106,6 +107,14 @@ data class NostrEvent(
) )
} }
/**
* Compute event ID (NIP-01) without signing
*/
fun computeEventIdHex(): String {
val (eventId, _) = calculateEventId()
return eventId
}
/** /**
* Calculate event ID according to NIP-01 * Calculate event ID according to NIP-01
* Returns (hex_id, hash_bytes) * Returns (hex_id, hash_bytes)
@@ -122,7 +131,7 @@ data class NostrEvent(
) )
// Convert to JSON without escaping slashes (compact format) // Convert to JSON without escaping slashes (compact format)
val gson = Gson() val gson = GsonBuilder().disableHtmlEscaping().create()
val jsonString = gson.toJson(serialized) val jsonString = gson.toJson(serialized)
// SHA256 hash of the JSON string // SHA256 hash of the JSON string
@@ -201,6 +210,8 @@ data class NostrEvent(
object NostrKind { object NostrKind {
const val METADATA = 0 const val METADATA = 0
const val TEXT_NOTE = 1 const val TEXT_NOTE = 1
const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned)
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
const val SEAL = 13 // NIP-17 sealed event const val SEAL = 13 // NIP-17 sealed event
const val GIFT_WRAP = 1059 // NIP-17 gift wrap const val GIFT_WRAP = 1059 // NIP-17 gift wrap
const val EPHEMERAL_EVENT = 20000 // For geohash channels const val EPHEMERAL_EVENT = 20000 // For geohash channels
@@ -119,7 +119,7 @@ class NostrGeohashService(
// Subscribe to gift wraps (NIP-17 private messages) // Subscribe to gift wraps (NIP-17 private messages)
val dmFilter = NostrFilter.giftWrapsFor( val dmFilter = NostrFilter.giftWrapsFor(
pubkey = currentIdentity.publicKeyHex, pubkey = currentIdentity.publicKeyHex,
since = System.currentTimeMillis() - 86400000L // Last 24 hours since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
) )
nostrRelayManager.subscribe( nostrRelayManager.subscribe(
@@ -234,7 +234,7 @@ class NostrGeohashService(
// Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer // Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 87300) { // 24 hours + 15 minutes if (messageAge > 173700) { // 48 hours + 15 minutes
return return
} }
@@ -316,8 +316,28 @@ class NostrGeohashService(
// Store Nostr key mapping // Store Nostr key mapping
nostrKeyMapping[targetPeerID] = senderPubkey nostrKeyMapping[targetPeerID] = senderPubkey
// Process payload and update UI/state
processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp) processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp)
// If this was a private message, send a delivery ACK back over Nostr
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
pm?.let { pmsg ->
val nostrTransport = NostrTransport.getInstance(application)
// Prefer mapped peer route; fallback to direct Nostr using sender pubkey
if (senderNoiseKey != null) {
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
} else {
// Fallback: direct to senders Nostr pubkey (geohash-style)
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (identity != null) {
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
}
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error processing Nostr message: ${e.message}") Log.e(TAG, "Error processing Nostr message: ${e.message}")
} }
@@ -900,18 +920,17 @@ class NostrGeohashService(
val dmFilter = NostrFilter.giftWrapsFor( val dmFilter = NostrFilter.giftWrapsFor(
pubkey = dmIdentity.publicKeyHex, pubkey = dmIdentity.publicKeyHex,
since = System.currentTimeMillis() - 86400000L // Last 24 hours since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization)
) )
nostrRelayManager.subscribeForGeohash( // IMPORTANT: For geohash DMs, use default relays (iOS behavior)
geohash = channel.channel.geohash, nostrRelayManager.subscribe(
filter = dmFilter, filter = dmFilter,
id = dmSubId, id = dmSubId,
handler = { giftWrap -> handler = { giftWrap ->
handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity)
}, },
includeDefaults = false, targetRelayUrls = null
nRelays = 5
) )
Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...") Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...")
@@ -1177,7 +1196,7 @@ class NostrGeohashService(
) )
if (decryptResult == null) { if (decryptResult == null) {
Log.w(TAG, "Failed to decrypt geohash DM") Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)")
return return
} }
@@ -1236,6 +1255,10 @@ class NostrGeohashService(
// Add to private chats // Add to private chats
privateChatManager.handleIncomingPrivateMessage(message) privateChatManager.handleIncomingPrivateMessage(message)
// Always send delivery ACK for geohash DMs
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
// Send read receipt if viewing this chat // Send read receipt if viewing this chat
if (isViewingThisChat) { if (isViewingThisChat) {
// Send read receipt via Nostr for geohash DM // Send read receipt via Nostr for geohash DM
@@ -14,46 +14,42 @@ object NostrProtocol {
private val gson = Gson() private val gson = Gson()
/** /**
* Create a NIP-17 private message * Create NIP-17 private message gift-wrap (receiver copy only per iOS)
* Returns gift-wrapped event ready for relay broadcast * Returns a single gift-wrapped event ready for relay broadcast
*/ */
fun createPrivateMessage( fun createPrivateMessage(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
): NostrEvent { ): List<NostrEvent> {
Log.v(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...") Log.d(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...")
// 1. Create the rumor (unsigned event) // 1. Create the rumor (unsigned kind 14) with p-tag
val rumor = NostrEvent( val rumorBase = NostrEvent(
pubkey = senderIdentity.publicKeyHex, pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(), createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.TEXT_NOTE, kind = NostrKind.DIRECT_MESSAGE,
tags = emptyList(), tags = listOf(listOf("p", recipientPubkey)),
content = content content = content
) )
val rumorId = rumorBase.computeEventIdHex()
val rumor = rumorBase.copy(id = rumorId)
// 2. Create ephemeral key for this message // 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days
val (ephemeralPrivateKey, ephemeralPublicKey) = NostrCrypto.generateKeyPair()
Log.v(TAG, "Created ephemeral key for seal")
// 3. Seal the rumor (encrypt to recipient)
val sealedEvent = createSeal( val sealedEvent = createSeal(
rumor = rumor, rumor = rumor,
recipientPubkey = recipientPubkey, recipientPubkey = recipientPubkey,
senderPrivateKey = ephemeralPrivateKey, senderPrivateKey = senderIdentity.privateKeyHex,
senderPublicKey = ephemeralPublicKey senderPublicKey = senderIdentity.publicKeyHex
) )
// 4. Gift wrap the sealed event (encrypt to recipient again) // 3. Gift wrap to recipient (kind 1059)
val giftWrap = createGiftWrap( val giftWrapToRecipient = createGiftWrap(
seal = sealedEvent, seal = sealedEvent,
recipientPubkey = recipientPubkey recipientPubkey = recipientPubkey
) )
Log.d(TAG, "Created gift wrap: toRecipient=${giftWrapToRecipient.id.take(16)}...")
Log.v(TAG, "Created gift wrap with id: ${giftWrap.id.take(16)}...") return listOf(giftWrapToRecipient)
return giftWrap
} }
/** /**
@@ -87,7 +83,7 @@ object NostrProtocol {
Triple(rumor.content, rumor.pubkey, rumor.createdAt) Triple(rumor.content, rumor.pubkey, rumor.createdAt)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to decrypt private message: ${e.message}") Log.w(TAG, "Failed to decrypt private message: ${e.message}")
null null
} }
} }
@@ -143,7 +139,7 @@ object NostrProtocol {
val seal = NostrEvent( val seal = NostrEvent(
pubkey = senderPublicKey, pubkey = senderPublicKey,
createdAt = NostrCrypto.randomizeTimestamp(), createdAt = NostrCrypto.randomizeTimestampUpToPast(),
kind = NostrKind.SEAL, kind = NostrKind.SEAL,
tags = emptyList(), tags = emptyList(),
content = encrypted content = encrypted
@@ -172,7 +168,7 @@ object NostrProtocol {
val giftWrap = NostrEvent( val giftWrap = NostrEvent(
pubkey = wrapPublicKey, pubkey = wrapPublicKey,
createdAt = NostrCrypto.randomizeTimestamp(), createdAt = NostrCrypto.randomizeTimestampUpToPast(),
kind = NostrKind.GIFT_WRAP, kind = NostrKind.GIFT_WRAP,
tags = listOf(listOf("p", recipientPubkey)), // Tag recipient tags = listOf(listOf("p", recipientPubkey)), // Tag recipient
content = encrypted content = encrypted
@@ -186,7 +182,7 @@ object NostrProtocol {
giftWrap: NostrEvent, giftWrap: NostrEvent,
recipientPrivateKey: String recipientPrivateKey: String
): NostrEvent? { ): NostrEvent? {
Log.v(TAG, "Unwrapping gift wrap") Log.d(TAG, "Unwrapping gift wrap; content prefix='${giftWrap.content.take(3)}' length=${giftWrap.content.length}")
return try { return try {
val decrypted = NostrCrypto.decryptNIP44( val decrypted = NostrCrypto.decryptNIP44(
@@ -215,7 +211,7 @@ object NostrProtocol {
Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}") Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}")
seal seal
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to unwrap gift wrap: ${e.message}") Log.w(TAG, "Failed to unwrap gift wrap: ${e.message}")
null null
} }
} }
@@ -248,7 +244,7 @@ object NostrProtocol {
sig = jsonObject.get("sig")?.asString sig = jsonObject.get("sig")?.asString
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to open seal: ${e.message}") Log.w(TAG, "Failed to open seal: ${e.message}")
null null
} }
} }
@@ -122,7 +122,11 @@ class NostrTestManager(private val context: Context) {
val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair() val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair()
val plaintext = "Hello, Nostr world! This is a test message." val plaintext = "Hello, Nostr world! This is a test message."
val encrypted = NostrCrypto.encryptNIP44(plaintext, recipientPublic, privateKey) val encrypted = NostrCrypto.encryptNIP44(
plaintext,
recipientPublic,
privateKey
)
require(encrypted.isNotEmpty()) { "Encryption failed" } require(encrypted.isNotEmpty()) { "Encryption failed" }
val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate) val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate)
@@ -98,14 +98,16 @@ class NostrTransport(
return@launch return@launch
} }
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded, content = embedded,
recipientPubkey = recipientHex, recipientPubkey = recipientHex,
senderIdentity = senderIdentity senderIdentity = senderIdentity
) )
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...") giftWraps.forEach { event ->
NostrRelayManager.getInstance(context).sendEvent(event) Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}") Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
@@ -182,14 +184,16 @@ class NostrTransport(
return@launch return@launch
} }
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = ack, content = ack,
recipientPubkey = recipientHex, recipientPubkey = recipientHex,
senderIdentity = senderIdentity senderIdentity = senderIdentity
) )
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...") giftWraps.forEach { event ->
NostrRelayManager.getInstance(context).sendEvent(event) Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
NostrRelayManager.getInstance(context).sendEvent(event)
}
scheduleNextReadAck() scheduleNextReadAck()
@@ -256,14 +260,16 @@ class NostrTransport(
return@launch return@launch
} }
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded, content = embedded,
recipientPubkey = recipientHex, recipientPubkey = recipientHex,
senderIdentity = senderIdentity senderIdentity = senderIdentity
) )
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...") giftWraps.forEach { event ->
NostrRelayManager.getInstance(context).sendEvent(event) Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}") Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
@@ -312,14 +318,16 @@ class NostrTransport(
return@launch return@launch
} }
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = ack, content = ack,
recipientPubkey = recipientHex, recipientPubkey = recipientHex,
senderIdentity = senderIdentity senderIdentity = senderIdentity
) )
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...") giftWraps.forEach { event ->
NostrRelayManager.getInstance(context).sendEvent(event) Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}") Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
@@ -346,15 +354,17 @@ class NostrTransport(
if (embedded == null) return@launch if (embedded == null) return@launch
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded, content = embedded,
recipientPubkey = toRecipientHex, recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity senderIdentity = fromIdentity
) )
// Register pending gift wrap for deduplication (like iOS) // Register pending gift wrap for deduplication and send all
NostrRelayManager.registerPendingGiftWrap(event.id) giftWraps.forEach { event ->
NostrRelayManager.getInstance(context).sendEvent(event) NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}") Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}")
@@ -379,15 +389,17 @@ class NostrTransport(
if (embedded == null) return@launch if (embedded == null) return@launch
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded, content = embedded,
recipientPubkey = toRecipientHex, recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity senderIdentity = fromIdentity
) )
// Register pending gift wrap for deduplication (like iOS) // Register pending gift wrap for deduplication and send all
NostrRelayManager.registerPendingGiftWrap(event.id) giftWraps.forEach { event ->
NostrRelayManager.getInstance(context).sendEvent(event) NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") Log.e(TAG, "Failed to send geohash read receipt: ${e.message}")
@@ -421,17 +433,17 @@ class NostrTransport(
return@launch return@launch
} }
val event = NostrProtocol.createPrivateMessage( val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded, content = embedded,
recipientPubkey = toRecipientHex, recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity senderIdentity = fromIdentity
) )
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...") giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
// Register pending gift wrap for deduplication (like iOS) NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event)
NostrRelayManager.getInstance(context).sendEvent(event) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send geohash private message: ${e.message}") Log.e(TAG, "Failed to send geohash private message: ${e.message}")
@@ -257,11 +257,17 @@ fun ChatHeaderContent(
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
PrivateChatHeader( PrivateChatHeader(
peerID = selectedPrivatePeer, peerID = selectedPrivatePeer,
peerNicknames = peerNicknames, peerNicknames = peerNicknames,
isFavorite = isFavorite, isFavorite = isFavorite,
sessionState = sessionState, sessionState = sessionState,
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
onBackClick = onBackClick, onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
) )
@@ -296,11 +302,25 @@ private fun PrivateChatHeader(
peerNicknames: Map<String, String>, peerNicknames: Map<String, String>,
isFavorite: Boolean, isFavorite: Boolean,
sessionState: String?, sessionState: String?,
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onToggleFavorite: () -> Unit onToggleFavorite: () -> Unit
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val peerNickname = peerNicknames[peerID] ?: peerID val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
val titleText: String = if (isNostrDM) {
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
"$geoPart/@$baseName"
} else {
peerNicknames[peerID] ?: peerID
}
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin // Back button - positioned all the way to the left with minimal margin
@@ -340,17 +360,20 @@ private fun PrivateChatHeader(
) { ) {
Text( Text(
text = peerNickname, text = titleText,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500) // Orange color = Color(0xFFFF9500) // Orange
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
NoiseSessionIcon( // For NIP-17 chats, do not show Noise session state icon (remove warning icon)
sessionState = sessionState, if (!isNostrDM) {
modifier = Modifier.size(14.dp) NoiseSessionIcon(
) sessionState = sessionState,
modifier = Modifier.size(14.dp)
)
}
} }
@@ -133,7 +133,7 @@ fun GeohashPeopleList(
if (person.id != myHex) { if (person.id != myHex) {
// TODO: Re-enable when NIP-17 geohash DM issues are fixed // TODO: Re-enable when NIP-17 geohash DM issues are fixed
// Start geohash DM (iOS-compatible) // Start geohash DM (iOS-compatible)
// viewModel.startGeohashDM(person.id) viewModel.startGeohashDM(person.id)
onTapPerson() onTapPerson()
} }
} }
@@ -271,15 +271,17 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
) )
} }
is DeliveryStatus.Sent -> { is DeliveryStatus.Sent -> {
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
Text( Text(
text = "", text = "",
fontSize = 10.sp, fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f) color = colorScheme.primary.copy(alpha = 0.6f)
) )
} }
is DeliveryStatus.Delivered -> { is DeliveryStatus.Delivered -> {
// Single check for Delivered (matches iOS expectations)
Text( Text(
text = "", text = "",
fontSize = 10.sp, fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.8f) color = colorScheme.primary.copy(alpha = 0.8f)
) )