mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 17:25:21 +00:00
Nostr geohash (#276)
* first nostr build * add test file * internet access * fix relay manager * fix serialization * demo service - remove later * fix nostr * event dedupe * dedupe * ui wip * can send messages * subscription works * works * favs * works * delete chat on change * fix mentions * remove autojoin channels * styling * adjust colors * ui changes * live updates working * use local timestamp * message history in background * robust * fixes * nicknames refresh optimization * nostr service * refactor nostr * style * geohash works * centralize colors * refactoring * disable DMs for now: click on peer nickname doesnt open chat list in geohash mode * use local time * less logging * robustness * scroll nickname * adjust some text
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Nostr Event structure following NIP-01
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrEvent(
|
||||
var id: String = "",
|
||||
val pubkey: String,
|
||||
@SerializedName("created_at") val createdAt: Int,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
var sig: String? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create from JSON dictionary
|
||||
*/
|
||||
fun fromJson(json: Map<String, Any>): NostrEvent? {
|
||||
return try {
|
||||
NostrEvent(
|
||||
id = json["id"] as? String ?: "",
|
||||
pubkey = json["pubkey"] as? String ?: return null,
|
||||
createdAt = (json["created_at"] as? Number)?.toInt() ?: return null,
|
||||
kind = (json["kind"] as? Number)?.toInt() ?: return null,
|
||||
tags = (json["tags"] as? List<List<String>>) ?: return null,
|
||||
content = json["content"] as? String ?: return null,
|
||||
sig = json["sig"] as? String?
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from JSON string
|
||||
*/
|
||||
fun fromJsonString(jsonString: String): NostrEvent? {
|
||||
return try {
|
||||
val gson = Gson()
|
||||
gson.fromJson(jsonString, NostrEvent::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new text note event
|
||||
*/
|
||||
fun createTextNote(
|
||||
content: String,
|
||||
publicKeyHex: String,
|
||||
privateKeyHex: String,
|
||||
tags: List<List<String>> = emptyList(),
|
||||
createdAt: Int = (System.currentTimeMillis() / 1000).toInt()
|
||||
): NostrEvent {
|
||||
val event = NostrEvent(
|
||||
pubkey = publicKeyHex,
|
||||
createdAt = createdAt,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new metadata event (kind 0)
|
||||
*/
|
||||
fun createMetadata(
|
||||
metadata: String,
|
||||
publicKeyHex: String,
|
||||
privateKeyHex: String,
|
||||
createdAt: Int = (System.currentTimeMillis() / 1000).toInt()
|
||||
): NostrEvent {
|
||||
val event = NostrEvent(
|
||||
pubkey = publicKeyHex,
|
||||
createdAt = createdAt,
|
||||
kind = NostrKind.METADATA,
|
||||
tags = emptyList(),
|
||||
content = metadata
|
||||
)
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign event with secp256k1 private key
|
||||
* Returns signed event with id and signature set
|
||||
*/
|
||||
fun sign(privateKeyHex: String): NostrEvent {
|
||||
val (eventId, eventIdHash) = calculateEventId()
|
||||
|
||||
// Create signature using secp256k1
|
||||
val signature = signHash(eventIdHash, privateKeyHex)
|
||||
|
||||
return this.copy(
|
||||
id = eventId,
|
||||
sig = signature
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate event ID according to NIP-01
|
||||
* Returns (hex_id, hash_bytes)
|
||||
*/
|
||||
private fun calculateEventId(): Pair<String, ByteArray> {
|
||||
// Create serialized array for hashing according to NIP-01
|
||||
val serialized = listOf(
|
||||
0,
|
||||
pubkey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// Convert to JSON without escaping slashes (compact format)
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(serialized)
|
||||
|
||||
// SHA256 hash of the JSON string
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val jsonBytes = jsonString.toByteArray(Charsets.UTF_8)
|
||||
val hash = digest.digest(jsonBytes)
|
||||
|
||||
// Convert to hex
|
||||
val hexId = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return Pair(hexId, hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign hash using BIP-340 Schnorr signatures
|
||||
*/
|
||||
private fun signHash(hash: ByteArray, privateKeyHex: String): String {
|
||||
return try {
|
||||
// Use the real BIP-340 Schnorr signature from NostrCrypto
|
||||
NostrCrypto.schnorrSign(hash, privateKeyHex)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("Failed to sign event: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON string
|
||||
*/
|
||||
fun toJsonString(): String {
|
||||
val gson = Gson()
|
||||
return gson.toJson(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate event signature using BIP-340 Schnorr verification
|
||||
*/
|
||||
fun isValidSignature(): Boolean {
|
||||
return try {
|
||||
val signatureHex = sig ?: return false
|
||||
if (id.isEmpty() || pubkey.isEmpty()) return false
|
||||
|
||||
// Recalculate the event ID hash for verification
|
||||
val (calculatedId, messageHash) = calculateEventId()
|
||||
|
||||
// Check if the calculated ID matches the stored ID
|
||||
if (calculatedId != id) return false
|
||||
|
||||
// Verify the Schnorr signature
|
||||
NostrCrypto.schnorrVerify(messageHash, signatureHex, pubkey)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate event structure and signature
|
||||
*/
|
||||
fun isValid(): Boolean {
|
||||
return try {
|
||||
// Basic field validation
|
||||
if (pubkey.isEmpty() || content.isEmpty()) return false
|
||||
if (createdAt <= 0 || kind < 0) return false
|
||||
if (!NostrCrypto.isValidPublicKey(pubkey)) return false
|
||||
|
||||
// Signature validation
|
||||
isValidSignature()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr event kinds
|
||||
*/
|
||||
object NostrKind {
|
||||
const val METADATA = 0
|
||||
const val TEXT_NOTE = 1
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension functions for hex encoding/decoding
|
||||
*/
|
||||
fun String.hexToByteArray(): ByteArray {
|
||||
check(length % 2 == 0) { "Must have an even length" }
|
||||
return chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
Reference in New Issue
Block a user