Merge pull request #401 from barrydeen/feat/signature-verification

feat: verify Schnorr signatures on incoming relay events
This commit is contained in:
Barry Deen
2026-03-31 21:23:36 -04:00
committed by GitHub
4 changed files with 41 additions and 0 deletions
@@ -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) {