Merge pull request #401 from barrydeen/feat/signature-verification
feat: verify Schnorr signatures on incoming relay events
This commit is contained in:
@@ -129,6 +129,22 @@ data class NostrEvent(
|
||||
fun toJson(): String = json.encodeToString(serializer(), this)
|
||||
|
||||
fun withSignature(sig: String): NostrEvent = copy(sig = sig)
|
||||
|
||||
/**
|
||||
* Verify the Schnorr signature of this event.
|
||||
* Recomputes the event ID and checks the sig against the pubkey.
|
||||
* Returns false if the ID doesn't match or the signature is invalid.
|
||||
*/
|
||||
fun verifySignature(): Boolean {
|
||||
if (sig.length != 128 || pubkey.length != 64) return false
|
||||
val expectedId = computeId(pubkey, created_at, kind, tags, content)
|
||||
if (id != expectedId) return false
|
||||
return try {
|
||||
Keys.verifySchnorr(sig.hexToByteArray(), id.hexToByteArray(), pubkey.hexToByteArray())
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object LongAsStringSerializer : KSerializer<Long> {
|
||||
|
||||
@@ -39,6 +39,13 @@ object Keys {
|
||||
return secp256k1.signSchnorr(message, privkey, null)
|
||||
}
|
||||
|
||||
fun verifySchnorr(signature: ByteArray, message: ByteArray, pubkey: ByteArray): Boolean {
|
||||
require(signature.size == 64) { "Schnorr signature must be 64 bytes" }
|
||||
require(message.size == 32) { "Message must be 32 bytes (SHA-256 hash)" }
|
||||
require(pubkey.size == 32) { "X-only pubkey must be 32 bytes" }
|
||||
return secp256k1.verifySchnorr(signature, message, pubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert x-only pubkey (32 bytes) to compressed form (33 bytes) by prepending 0x02.
|
||||
* Used for ECDH in NIP-44.
|
||||
|
||||
@@ -194,6 +194,10 @@ class RelayPool(private val prefs: SharedPreferences? = null) {
|
||||
private val _eoseSignals = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val eoseSignals: SharedFlow<String> = _eoseSignals
|
||||
|
||||
/** Event IDs that failed async signature verification and should be removed from UI. */
|
||||
private val _invalidEvents = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val invalidEvents: SharedFlow<String> = _invalidEvents
|
||||
|
||||
/** Emitted when a relay sends CLOSED for a group subscription (subId starts with "grp-"). */
|
||||
val groupRelayErrors = MutableSharedFlow<Triple<String, String, String>>(
|
||||
extraBufferCapacity = 16,
|
||||
@@ -401,6 +405,13 @@ class RelayPool(private val prefs: SharedPreferences? = null) {
|
||||
}
|
||||
}
|
||||
if (shouldEmit) {
|
||||
// Verify signature off the hot path — retract if invalid
|
||||
scope.launch(Dispatchers.Default) {
|
||||
if (!msg.event.verifySignature()) {
|
||||
Log.w("RelayPool", "Invalid signature: id=${msg.event.id.take(12)} kind=${msg.event.kind} relay=${relay.config.url}")
|
||||
_invalidEvents.tryEmit(msg.event.id)
|
||||
}
|
||||
}
|
||||
if (msg.event.kind == 1018) {
|
||||
Log.d("POLL", "[Pool] emit kind 1018 id=${msg.event.id.take(12)} sub=${msg.subscriptionId} relay=${relay.config.url}")
|
||||
}
|
||||
|
||||
@@ -250,6 +250,13 @@ class StartupCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
// Remove events that fail async signature verification
|
||||
scope.launch(processingContext) {
|
||||
relayPool.invalidEvents.collect { eventId ->
|
||||
eventRepo.removeEvent(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
// Profile sweep — eager burst at startup for fast profile coverage,
|
||||
// then relaxed periodic sweep as a safety net.
|
||||
metadataSweepJob = scope.launch(processingContext) {
|
||||
|
||||
Reference in New Issue
Block a user